Merge branch 'MDL-62768' of https://github.com/paulholden/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 4 Mar 2020 17:33:00 +0000 (18:33 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 4 Mar 2020 17:33:00 +0000 (18:33 +0100)
126 files changed:
admin/registration/confirmregistration.php
admin/registration/renewregistration.php
admin/settings/courses.php
admin/tool/capability/renderer.php
admin/tool/capability/tests/behat/show_contexts.feature [new file with mode: 0644]
admin/tool/cohortroles/db/upgrade.php [new file with mode: 0644]
admin/tool/cohortroles/version.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/launch.php
admin/tool/mobile/settings.php
admin/tool/unsuproles/index.php
auth/email/classes/external.php
badges/classes/assertion.php
badges/classes/badge.php
badges/issuer_json.php [new file with mode: 0644]
cache/stores/file/lang/en/cachestore_file.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/repository.min.js
course/amd/build/local/activitychooser/repository.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/build/recommendations.min.js [new file with mode: 0644]
course/amd/build/recommendations.min.js.map [new file with mode: 0644]
course/amd/src/activitychooser.js
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/repository.js
course/amd/src/local/activitychooser/selectors.js
course/amd/src/recommendations.js [new file with mode: 0644]
course/classes/local/exporters/course_content_item_exporter.php
course/classes/local/exporters/course_content_items_exporter.php
course/classes/local/service/content_item_service.php
course/classes/output/recommendations/activity_list.php [new file with mode: 0644]
course/classes/output/recommendations/renderer.php [new file with mode: 0644]
course/externallib.php
course/recommendations.php [new file with mode: 0644]
course/templates/activity_list.mustache [new file with mode: 0644]
course/templates/chooser.mustache
course/templates/chooser_favourites.mustache [new file with mode: 0644]
course/templates/chooser_item.mustache
course/templates/course_search_form.mustache
course/tests/behat/activity_chooser.feature
course/tests/behat/recommend_activities.feature [new file with mode: 0644]
course/tests/exporters_content_item_test.php
course/tests/externallib_test.php
course/tests/services_content_item_service_test.php
enrol/self/lib.php
grade/grading/form/rubric/lang/en/gradingform_rubric.php
grade/report/history/classes/helper.php
grade/report/history/classes/output/tablelog.php
grade/report/history/tests/report_test.php
h5p/classes/api.php [new file with mode: 0644]
h5p/classes/form/uploadlibraries_form.php
h5p/classes/framework.php
h5p/classes/output/libraries.php [new file with mode: 0644]
h5p/classes/player.php
h5p/libraries.php
h5p/templates/h5plibraries.mustache
h5p/tests/api_test.php [new file with mode: 0644]
h5p/tests/behat/h5p_libraries.feature
h5p/tests/external_test.php
h5p/tests/framework_test.php
h5p/tests/helper_test.php
lang/en/admin.php
lang/en/badges.php
lang/en/cache.php
lang/en/course.php
lang/en/h5p.php
lang/en/moodle.php
lang/en/role.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/badgeslib.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/task/manager.php
lib/db/access.php
lib/db/caches.php
lib/db/services.php
lib/db/upgrade.php
lib/externallib.php
lib/grade/grade_item.php
lib/moodlelib.php
lib/outputlib.php
lib/outputrenderers.php
lib/templates/action_menu_trigger.mustache
lib/templates/block.mustache
lib/tests/adhoc_task_test.php
lib/tests/behat/app_behat_runtime.js
lib/tests/behat/behat_app.php
lib/tests/behat/behat_navigation.php
lib/tests/theme_config_test.php
lib/upgrade.txt
message/amd/build/message_drawer_view_conversation_constants.min.js
message/amd/build/message_drawer_view_conversation_constants.min.js.map
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js.map
message/amd/src/message_drawer_view_conversation_constants.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/output/airnotifier/message_output_airnotifier.php
message/templates/message_drawer_view_conversation_body_confirm_dialogue.mustache
mod/folder/lang/en/folder.php
mod/forum/classes/task/refresh_forum_post_counts.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/lti/certs.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/quiz/lang/en/quiz.php
repository/nextcloud/lang/en/repository_nextcloud.php
theme/boost/amd/build/drawer.min.js
theme/boost/amd/build/drawer.min.js.map
theme/boost/amd/src/drawer.js
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/style/moodle.css
theme/boost/templates/columns2.mustache
theme/classic/style/moodle.css
theme/classic/tests/behat/behat_theme_classic_behat_navigation.php
user/classes/participants_table.php
user/tests/behat/view_participants.feature
version.php
webservice/lib.php

index ff99680..6a1ed49 100644 (file)
@@ -45,7 +45,7 @@ $error = optional_param('error', '', PARAM_ALPHANUM);
 
 admin_externalpage_setup('registrationmoodleorg');
 
-if ($url !== HUB_MOODLEORGHUBURL) {
+if (parse_url($url, PHP_URL_HOST) !== parse_url(HUB_MOODLEORGHUBURL, PHP_URL_HOST)) {
     // Allow other plugins to confirm registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
index bc05da6..99a2764 100644 (file)
@@ -39,7 +39,7 @@ $token = optional_param('token', '', PARAM_TEXT);
 
 admin_externalpage_setup('registrationmoodleorg');
 
-if ($url !== HUB_MOODLEORGHUBURL) {
+if (parse_url($url, PHP_URL_HOST) !== parse_url(HUB_MOODLEORGHUBURL, PHP_URL_HOST)) {
     // Allow other plugins to renew registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
index ad5ede6..841b72b 100644 (file)
@@ -61,6 +61,12 @@ 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.
index 64f8762..59e0f66 100644 (file)
@@ -94,6 +94,7 @@ class tool_capability_renderer extends plugin_renderer_base {
         }
         $table->data = array();
 
+        $childcontextsids = [];
         foreach ($capabilities as $capability) {
             if (empty($capabilitycontexts[$capability])) {
                 $capabilitycontexts[$capability] = tool_capability_calculate_role_data($capability, $roles);
@@ -122,6 +123,10 @@ class tool_capability_renderer extends plugin_renderer_base {
             if (!$onlydiff || count($permissiontypes) > 1) {
                 $table->data[] = $row;
             }
+            if (!empty($contexts[$contextid]->children)) {
+                $childcontextsids = array_merge($childcontextsids, $contexts[$contextid]->children);
+                $childcontextsids = array_unique($childcontextsids);
+            }
         }
 
         // Start the list item, and print the context name as a link to the place to make changes.
@@ -142,8 +147,8 @@ class tool_capability_renderer extends plugin_renderer_base {
             $html .= html_writer::tag('p', get_string('nodifferences', 'tool_capability'));
         }
         // If there are any child contexts, print them recursively.
-        if (!empty($contexts[$contextid]->children)) {
-            foreach ($contexts[$contextid]->children as $childcontextid) {
+        if (!empty($childcontextsids)) {
+            foreach ($childcontextsids as $childcontextid) {
                 $html .= $this->capability_comparison_table($capabilities, $childcontextid, $roles, $onlydiff);
             }
         }
diff --git a/admin/tool/capability/tests/behat/show_contexts.feature b/admin/tool/capability/tests/behat/show_contexts.feature
new file mode 100644 (file)
index 0000000..f61ca9e
--- /dev/null
@@ -0,0 +1,133 @@
+@tool @tool_capability
+Feature: Show capabilities for multiple contexts
+  In order to check roles capabilities
+  As an admin
+  I need to be able to see capability overrides on several contexts
+
+  Background:
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "permission overrides" exist:
+      | capability                    | permission | role       | contextlevel | reference |
+      | enrol/category:config         | Allow      | student    | Course       | C1        |
+      | enrol/cohort:unenrol          | Allow      | student    | Course       | C2        |
+    And I log in as "admin"
+    And I navigate to "Users > Permissions > Capability overview" in site administration
+
+  Scenario: Show capabilities table with one capability with overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      | Student               |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with one capability without overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/cohort:config |
+      | Roles:      | Student               |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, 1st without overrides and 2nd with
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised, enrol/category:config |
+      | Roles:      | Student                                            |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, 1st with overrides and 2nd without
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:config |
+      | Roles:      | Student                                    |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, none with overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised, enrol/cohort:config |
+      | Roles:      | Student                                          |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with override and no role selected
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      |                       |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability without override and no role selected
+    When I set the following fields to these values:
+      | Capability: | enrol/cohort:config |
+      | Roles:      |                     |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with two overrides on different contexts
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:unenrol |
+      | Roles:      |                                           |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with override and only diff
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      | Student, Teacher      |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability without override and only diff and same capability
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised |
+      | Roles:      | Student, Teacher            |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities only one override and only diff checked
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:config |
+      | Roles:      | Student, Teacher                           |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
diff --git a/admin/tool/cohortroles/db/upgrade.php b/admin/tool/cohortroles/db/upgrade.php
new file mode 100644 (file)
index 0000000..fd228cc
--- /dev/null
@@ -0,0 +1,45 @@
+<?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/>.
+
+/**
+ * Plugin upgrade code
+ *
+ * @package    tool_cohortroles
+ * @copyright  2020 Paul Holden <paulh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Function to upgrade tool_cohortroles.
+ *
+ * @param int $oldversion the version we are upgrading from
+ * @return bool result
+ */
+function xmldb_tool_cohortroles_upgrade($oldversion) {
+    global $DB;
+
+    if ($oldversion < 2020020600) {
+        // Delete any tool_cohortroles mappings for roles which no longer exist.
+        $DB->delete_records_select('tool_cohortroles', 'roleid NOT IN (SELECT id FROM {role})');
+
+        // Cohortroles savepoint reached.
+        upgrade_plugin_savepoint(true, 2020020600, 'tool', 'cohortroles');
+    }
+
+    return true;
+}
index c8f4c09..c4944b3 100644 (file)
@@ -25,7 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 
-$plugin->version   = 2019111800; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2020020600; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200; // Requires this Moodle version.
 $plugin->component = 'tool_cohortroles'; // Full name of the plugin (used for diagnostics).
 
index 9a31758..b5f3e60 100644 (file)
@@ -31,7 +31,7 @@ $string['addcategory'] = 'Add category';
 $string['addnewdefaults'] = 'Add a new module default';
 $string['addpurpose'] = 'Add purpose';
 $string['approve'] = 'Approve';
-$string['approvedrequestsubmitted'] = 'Your request has been submitted and will be processed soon';
+$string['approvedrequestsubmitted'] = 'Your request has been submitted and will be processed soon.';
 $string['approverequest'] = 'Approve request';
 $string['automaticdatadeletionapproval'] = 'Automatic data deletion request approval';
 $string['automaticdatadeletionapproval_desc'] = 'If enabled, data deletion requests are automatically approved.<br/>Note that the automatic approval will only apply to new data deletion requests with this setting enabled. Existing data deletion requests pending approval will still have to be manually approved by the privacy officer.';
index 4add17f..78de5b5 100644 (file)
@@ -248,5 +248,5 @@ Feature: Data delete from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Delete my account"
     When I press "Save changes"
-    Then I should see "Your request has been submitted and will be processed soon"
+    Then I should see "Your request has been submitted and will be processed soon."
     And I should see "Approved" in the "Delete all of my personal data" "table_row"
index 65fec34..9c7d732 100644 (file)
@@ -159,5 +159,5 @@ Feature: Data export from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Export all of my personal data"
     When I press "Save changes"
-    Then I should see "Your request has been submitted and will be processed soon"
+    Then I should see "Your request has been submitted and will be processed soon."
     And I should see "Approved" in the "Export all of my personal data" "table_row"
index 146990c..c6160d3 100644 (file)
@@ -357,6 +357,7 @@ class api {
      */
     public static function get_features_list() {
         global $CFG;
+        require_once($CFG->libdir . '/authlib.php');
 
         $general = new lang_string('general');
         $mainmenu = new lang_string('mainmenu', 'tool_mobile');
@@ -366,6 +367,7 @@ class api {
         $user = new lang_string('user');
         $files = new lang_string('files');
         $remoteaddons = new lang_string('remoteaddons', 'tool_mobile');
+        $identityproviders = new lang_string('oauth2identityproviders', 'tool_mobile');
 
         $availablemods = core_plugin_manager::instance()->get_plugins_of_type('mod');
         $coursemodules = array();
@@ -433,6 +435,8 @@ class api {
                 '$mmLoginEmailSignup' => new lang_string('startsignup'),
                 'NoDelegate_ForgottenPassword' => new lang_string('forgotten'),
                 'NoDelegate_ResponsiveMainMenuItems' => new lang_string('responsivemainmenuitems', 'tool_mobile'),
+                'NoDelegate_H5POffline' => new lang_string('h5poffline', 'tool_mobile'),
+                'NoDelegate_DarkMode' => new lang_string('darkmode', 'tool_mobile'),
             ),
             "$mainmenu" => array(
                 '$mmSideMenuDelegate_mmaFrontpage' => new lang_string('sitehome'),
@@ -485,6 +489,31 @@ class api {
             $features["$remoteaddons"] = $remoteaddonslist;
         }
 
+        // Display OAuth 2 identity providers.
+        if (is_enabled_auth('oauth2')) {
+            $identityproviderslist = array();
+            $idps = \auth_plugin_base::get_identity_providers(['oauth2']);
+
+            foreach ($idps as $idp) {
+                // Only add identity providers that have an ID.
+                $id = isset($idp['url']) ? $idp['url']->get_param('id') : null;
+                if ($id != null) {
+                    $identityproviderslist['NoDelegate_IdentityProvider_' . $id] = $idp['name'];
+                }
+            }
+
+            if (!empty($identityproviderslist)) {
+                $features["$identityproviders"] = array();
+
+                if (count($identityproviderslist) > 1) {
+                    // Include an option to disable them all.
+                    $features["$identityproviders"]['NoDelegate_IdentityProviders'] = new lang_string('all');
+                }
+
+                $features["$identityproviders"] = array_merge($features["$identityproviders"], $identityproviderslist);
+            }
+        }
+
         return $features;
     }
 
index f982776..092f7da 100644 (file)
@@ -45,12 +45,16 @@ $string['custommenuitems_desc'] = 'Additional items can be added to the app\'s m
 
 Link-opening methods are: app (for linking to an activity supported by the app), inappbrowser (for opening a link in a browser without leaving the app), browser (for opening the link in the device default browser outside the app) and embedded (for displaying the link in an iframe in a new page in the app).
 
+When items are missing a translation for a given language, they will use other languages as fallback unless "_only" is appended to the language code.
+
 For example:
 <pre>
 App help|https://someurl.xyz/help|inappbrowser
 My grades|https://someurl.xyz/local/mygrades/index.php|embedded|en
 Mis calificaciones|https://someurl.xyz/local/mygrades/index.php|embedded|es
+You will only see this in English|https://someurl.xyz/english|browser|en_only
 </pre>';
+$string['darkmode'] = 'Dark mode';
 $string['disabledfeatures'] = 'Disabled features';
 $string['disabledfeatures_desc'] = 'Select here the features you want to disable in the Mobile app for your site. Please note that some features listed here could be already disabled via other site settings. You will have to log out and log in again in the app to see the changes.';
 $string['displayerrorswarning'] = 'Display debug messages (debugdisplay) is enabled. It should be disabled.';
@@ -62,6 +66,7 @@ $string['forcedurlscheme'] = 'If you want to allow only your custom branded app
 $string['forcedurlscheme_key'] = 'URL scheme';
 $string['forcelogout'] = 'Force log out';
 $string['forcelogout_desc'] = 'If enabled, the app option \'Change site\' is replaced by \'Log out\'. This results in the user being completely logged out. They must then re-enter their password the next time they wish to access the site.';
+$string['h5poffline'] = 'View H5P content offline';
 $string['httpsrequired'] = 'HTTPS required';
 $string['insecurealgorithmwarning'] = 'It seems that the HTTPS certificate uses an insecure algorithm for signing (SHA-1). Please try updating the certificate.';
 $string['invalidcertificatechainwarning'] = 'It seems that the certificate chain is invalid.';
@@ -86,6 +91,7 @@ $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
 $string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.';
 $string['mobilesettings'] = 'Mobile settings';
+$string['oauth2identityproviders'] = 'OAuth 2 identity providers';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
 $string['pluginnotenabledorconfigured'] = 'Plugin not enabled or configured.';
index 2c5fc18..64d3ee8 100644 (file)
@@ -30,10 +30,16 @@ require_once($CFG->libdir . '/externallib.php');
 
 $serviceshortname  = required_param('service',  PARAM_ALPHANUMEXT);
 $passport          = required_param('passport',  PARAM_RAW);    // Passport send from the app to validate the response URL.
-$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_ALPHANUM); // The URL scheme the app supports.
+$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_NOTAGS); // The URL scheme the app supports.
 $confirmed         = optional_param('confirmed', false, PARAM_BOOL);  // If we are being redirected after user confirmation.
 $oauthsso          = optional_param('oauthsso', 0, PARAM_INT); // Id of the OpenID issuer (for OAuth direct SSO).
 
+// Validate that the urlscheme is valid.
+if (!preg_match('/^[a-zA-Z][a-zA-Z0-9-\+\.]*$/', $urlscheme)) {
+    throw new moodle_exception('Invalid parameter: the value of urlscheme isn\'t valid. ' .
+            'It should start with a letter and can only contain letters, numbers and the characters "." "+" "-".');
+}
+
 // Check web services enabled.
 if (!$CFG->enablewebservices) {
     throw new moodle_exception('enablewsdescription', 'webservice');
index 2121e84..a9cdbcf 100644 (file)
@@ -63,7 +63,7 @@ if ($hassiteconfig) {
 
         $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
                     new lang_string('forcedurlscheme_key', 'tool_mobile'),
-                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_ALPHANUM));
+                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_NOTAGS));
 
         $temp->add(new admin_setting_configtext('tool_mobile/minimumversion',
                     new lang_string('minimumversion_key', 'tool_mobile'),
index e2522ba..3d6c5e8 100644 (file)
@@ -100,9 +100,9 @@ if (!$problems) {
         $count = $problem->racount;
         $edit = array();
         $aurl = new moodle_url('/admin/roles/define.php', array('roleid'=>$problem->roleid, 'action'=>'edit'));
-        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/edit', 'core', get_string('edit')));
+        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/edit', get_string('edit')));
         $aurl = new moodle_url($PAGE->url, array('roleid'=>$problem->roleid, 'contextlevel'=>$problem->contextlevel, 'action'=>'delete'));
-        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/delete', 'core', get_string('delete')));
+        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/delete', get_string('delete')));
         $data[] = array($levelname, $rolename, $count, implode('&nbsp;', $edit));
     }
     $table = new html_table();
index 34e9540..782f033 100644 (file)
@@ -146,7 +146,7 @@ class auth_email_external extends external_api {
                     new external_single_structure(
                         array(
                             'id' => new external_value(PARAM_INT, 'Profile field id', VALUE_OPTIONAL),
-                            'shortname' => new external_value(PARAM_ALPHANUM, 'Password policy', VALUE_OPTIONAL),
+                            'shortname' => new external_value(PARAM_ALPHANUMEXT, 'Profile field shortname', VALUE_OPTIONAL),
                             'name' => new external_value(PARAM_TEXT, 'Profield field name', VALUE_OPTIONAL),
                             'datatype' => new external_value(PARAM_ALPHANUMEXT, 'Profield field datatype', VALUE_OPTIONAL),
                             'description' => new external_value(PARAM_RAW, 'Profield field description', VALUE_OPTIONAL),
index 872495e..8920393 100644 (file)
@@ -199,7 +199,7 @@ class core_badges_assertion {
             $class['criteria'] = $this->_url->out(false); // Currently issued badge URL.
             if ($issued) {
                 if ($this->_obversion == OPEN_BADGES_V2) {
-                    $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0));
+                    $issuerurl = new moodle_url('/badges/issuer_json.php', array('id' => $this->get_badge_id()));
                 } else {
                     $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0));
                 }
@@ -233,7 +233,8 @@ class core_badges_assertion {
                     $issuer['email'] = $CFG->badges_defaultissuercontact;
                 }
             } else {
-                $issuer = badges_get_default_issuer();
+                $badge = new badge($this->get_badge_id());
+                $issuer = $badge->get_badge_issuer();
             }
         }
         $this->embed_data_badge_version2($issuer, OPEN_BADGES_V2_TYPE_ISSUER);
@@ -341,8 +342,7 @@ class core_badges_assertion {
                 '/badges/badge_json.php',
                 array('id' => $this->get_badge_id())
             );
-            $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0,
-                'obversion' => $this->_obversion));
+            $issuerurl = new moodle_url('/badges/issuer_json.php', ['id' => $this->get_badge_id()]);
             // For assertion.
             if ($type == OPEN_BADGES_V2_TYPE_ASSERTION) {
                 $json['@context'] = OPEN_BADGES_V2_CONTEXT;
index 7334681..a3dfdba 100644 (file)
@@ -929,12 +929,12 @@ class badge {
      */
     public function get_badge_issuer() {
         $issuer = array();
-        $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->id, 'action' => 0));
         $issuer['name'] = $this->issuername;
         $issuer['url'] = $this->issuerurl;
         $issuer['email'] = $this->issuercontact;
         $issuer['@context'] = OPEN_BADGES_V2_CONTEXT;
-        $issuer['id'] = $this->issuerurl;
+        $issueridurl = new moodle_url('/badges/issuer_json.php', array('id' => $this->id));
+        $issuer['id'] = $issueridurl->out(false);
         $issuer['type'] = OPEN_BADGES_V2_TYPE_ISSUER;
         return $issuer;
     }
diff --git a/badges/issuer_json.php b/badges/issuer_json.php
new file mode 100644 (file)
index 0000000..ebbf74f
--- /dev/null
@@ -0,0 +1,52 @@
+<?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/>.
+
+/**
+ * Serve Issuer JSON for related badge or default Issuer if no badge is defined.
+ *
+ * @package    core
+ * @subpackage badges
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define('AJAX_SCRIPT', true);
+define('NO_MOODLE_COOKIES', true); // No need for a session here.
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/badgeslib.php');
+
+
+$id = optional_param('id', null, PARAM_INT);
+
+if (empty($id)) {
+    // Get the default issuer for this site.
+    $json = badges_get_default_issuer();
+} else {
+    // Get the issuer for this badge.
+    $badge = new badge($id);
+    if ($badge->status != BADGE_STATUS_INACTIVE) {
+        $json = $badge->get_badge_issuer();
+    } else {
+        // The badge doen't exist or not accessible for the users.
+        header("HTTP/1.0 410 Gone");
+        $badgeurl = new moodle_url('/badges/issuer_json.php', array('id' => $id));
+        $json = ['id' => $badgeurl->out()];
+        $json['error'] = get_string('error:relatedbadgedoesntexist', 'badges');
+    }
+}
+
+echo $OUTPUT->header();
+echo json_encode($json);
index 2759410..f9ba89f 100644 (file)
@@ -37,11 +37,14 @@ $string['privacy:metadata'] = 'The File cache cachestore plugin stores data brie
 $string['prescan'] = 'Prescan directory';
 $string['prescan_help'] = 'If enabled the directory is scanned when the cache is first used and requests for files are first checked against the scan data. This can help if you have a slow file system and are finding that file operations are causing you a bottle neck.';
 $string['singledirectory'] = 'Single directory store';
-$string['singledirectory_help'] = 'If enabled files (cached items) will be stored in a single directory rather than being broken up into multiple directories.<br />
-Enabling this will speed up file interactions but comes at the cost of increased risk of hitting file system limitations.<br />
-It is advisable to only turn this on if the following is true:<br />
-  - If you know the number of items in the cache is going to be small enough that it won\'t cause issues on the file system you are running with.<br />
-  - The data being cached is not expensive to generate. If it is then sticking with the default may still be the better option as it reduces the chance of issues.';
+$string['singledirectory_help'] = 'If enabled files (cached items) will be stored in a single directory rather than being broken up into multiple directories.
+
+Enabling this will speed up file interactions but comes at the cost of increased risk of hitting file system limitations.
+
+It is advisable to only turn this on if the following is true:
+
+* If you know the number of items in the cache is going to be small enough that it won\'t cause issues on the file system you are running with.
+* The data being cached is not expensive to generate. If it is then sticking with the default may still be the better option as it reduces the chance of issues.';
 
 /**
  * This is is like the file store, but designed for siutations where:
index 929586d..114a575 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index c5fc3df..5dc3ca7 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index b28b3db..03dfa69 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 5ca8140..cdc15bc 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 a821358..e7e7505 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js and b/course/amd/build/local/activitychooser/repository.min.js differ
index 0d35705..e7bb504 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js.map and b/course/amd/build/local/activitychooser/repository.min.js.map differ
index d826dbf..73c917e 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 229f9dd..b3e6a0c 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
diff --git a/course/amd/build/recommendations.min.js b/course/amd/build/recommendations.min.js
new file mode 100644 (file)
index 0000000..59b3a55
Binary files /dev/null and b/course/amd/build/recommendations.min.js differ
diff --git a/course/amd/build/recommendations.min.js.map b/course/amd/build/recommendations.min.js.map
new file mode 100644 (file)
index 0000000..aea6e7f
Binary files /dev/null and b/course/amd/build/recommendations.min.js.map differ
index 2e5ecfc..6565e72 100644 (file)
@@ -78,11 +78,13 @@ const registerListenerEvents = (courseId) => {
     events.forEach((event) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
+                const data = await fetchModuleData();
                 const caller = e.target.closest(selectors.elements.sectionmodchooser);
-                const builtModuleData = sectionIdMapper(await fetchModuleData(), caller.dataset.sectionid);
+                const favouriteFunction = partiallyAppliedFavouriteManager(data, caller.dataset.sectionid);
+                const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);
                 const sectionModal = await modalBuilder(builtModuleData);
 
-                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData);
+                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData, favouriteFunction);
             }
         });
     });
@@ -111,7 +113,7 @@ const sectionIdMapper = (webServiceData, id) => {
  *
  * @method modalBuilder
  * @param {Map} data our map of section ID's & modules to generate modals for
- * @return {Object} TODO
+ * @return {Object} Our modal that we are going to show the user
  */
 const modalBuilder = data => buildModal(templateDataBuilder(data));
 
@@ -124,8 +126,8 @@ const modalBuilder = data => buildModal(templateDataBuilder(data));
  */
 const templateDataBuilder = (data) => {
     // Filter the incoming data to find favourite & recommended modules.
-    const favourites = [];
-    const recommended = [];
+    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.
 
@@ -164,3 +166,105 @@ const buildModal = data => {
         }
     });
 };
+
+/**
+ * A small helper function to handle the case where there are no more favourites
+ * and we need to mess a bit with the available tabs in the chooser
+ *
+ * @method nullFavouriteDomManager
+ * @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav
+ * @param {HTMLElement} modalBody Our current modals' body
+ */
+const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
+    favouriteTabNav.classList.add('d-none');
+    // Need to set active to an available tab.
+    if (favouriteTabNav.classList.contains('active')) {
+        favouriteTabNav.classList.remove('active');
+        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');
+            const recommendedTab = modalBody.querySelector(selectors.regions.recommendedTab);
+            recommendedTab.classList.add('active');
+        } else {
+            defaultTabNav.classList.add('active');
+            const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
+            defaultTab.classList.add('active');
+        }
+
+    }
+};
+
+/**
+ * Export a curried function where the builtModules has been applied.
+ * We have our array of modules so we can rerender the favourites area and have all of the items sorted.
+ *
+ * @method partiallyAppliedFavouriteManager
+ * @param {Array} moduleData This is our raw WS data that we need to manipulate
+ * @param {Number} sectionId We need this to add the sectionID to the URL's in the faves area after rerender
+ * @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array
+ */
+const partiallyAppliedFavouriteManager = (moduleData, sectionId) => {
+    /**
+     * Curried function that is being returned.
+     *
+     * @param {String} internal Internal name of the module to manage
+     * @param {Boolean} favourite Is the caller adding a favourite or removing one?
+     * @param {HTMLElement} modalBody What we need to update whilst we are here
+     */
+    return async(internal, favourite, modalBody) => {
+        const favouriteArea = modalBody.querySelector(selectors.render.favourites);
+
+        // eslint-disable-next-line max-len
+        const favouriteButtons = modalBody.querySelectorAll(`[data-internal="${internal}"] ${selectors.actions.optionActions.manageFavourite}`);
+        const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);
+        const result = moduleData.content_items.find(({name}) => name === internal);
+        const newFaves = {};
+        if (result) {
+            if (favourite) {
+                result.favourite = true;
+
+                newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
+
+                const builtFaves = sectionIdMapper(newFaves, sectionId);
+
+                const {html, js} = await Templates.renderForPromise('core_course/chooser_favourites', {favourites: builtFaves});
+
+                await Templates.replaceNodeContents(favouriteArea, html, js);
+
+                Array.from(favouriteButtons).forEach((element) => {
+                    element.classList.remove('text-muted');
+                    element.classList.add('text-primary');
+                    element.dataset.favourited = 'true';
+                    element.setAttribute('aria-pressed', true);
+                    element.firstElementChild.classList.remove('fa-star-o');
+                    element.firstElementChild.classList.add('fa-star');
+                });
+
+                favouriteTabNav.classList.remove('d-none');
+            } else {
+                result.favourite = false;
+
+                const nodeToRemove = favouriteArea.querySelector(`[data-internal="${internal}"]`);
+
+                nodeToRemove.parentNode.removeChild(nodeToRemove);
+
+                Array.from(favouriteButtons).forEach((element) => {
+                    element.classList.add('text-muted');
+                    element.classList.remove('text-primary');
+                    element.dataset.favourited = 'false';
+                    element.setAttribute('aria-pressed', false);
+                    element.firstElementChild.classList.remove('fa-star');
+                    element.firstElementChild.classList.add('fa-star-o');
+                });
+                const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);
+
+                if (newFaves.length === 0) {
+                    nullFavouriteDomManager(favouriteTabNav, modalBody);
+                }
+            }
+        }
+    };
+};
index f443d9a..9d7b73c 100644 (file)
@@ -28,6 +28,8 @@ import selectors from 'core_course/local/activitychooser/selectors';
 import * as Templates from 'core/templates';
 import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
 import {addIconToContainer} from 'core/loadingicon';
+import * as Repository from 'core_course/local/activitychooser/repository';
+import Notification from 'core/notification';
 
 /**
  * Given an event from the main module 'page' navigate to it's help section via a carousel.
@@ -69,14 +71,42 @@ const showModuleHelp = (carousel, moduleData) => {
     carousel.carousel('next');
 };
 
+/**
+ * Given a user wants to change the favourite state of a module we either add or remove the status.
+ * We also propergate this change across our map of modals.
+ *
+ * @method manageFavouriteState
+ * @param {HTMLElement} modalBody The DOM node of the modal to manipulate
+ * @param {HTMLElement} caller
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
+ */
+const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
+    const isFavourite = caller.dataset.favourited;
+    const id = caller.dataset.id;
+    const name = caller.dataset.name;
+    const internal = caller.dataset.internal;
+    // Switch on fave or not.
+    if (isFavourite === 'true') {
+        await Repository.unfavouriteModule(name, id);
+
+        partialFavourite(internal, false, modalBody);
+    } else {
+        await Repository.favouriteModule(name, id);
+
+        partialFavourite(internal, true, modalBody);
+    }
+
+};
+
 /**
  * Register chooser related event listeners.
  *
  * @method registerListenerEvents
  * @param {Promise} modal Our modal that we are working with
  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-const registerListenerEvents = (modal, mappedModules) => {
+const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
     const bodyClickListener = e => {
         if (e.target.closest(selectors.actions.optionActions.showSummary)) {
             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
@@ -87,6 +117,11 @@ const registerListenerEvents = (modal, mappedModules) => {
             showModuleHelp(carousel, moduleData);
         }
 
+        if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
+            const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
+            manageFavouriteState(modal.getBody()[0], caller, partialFavourite);
+        }
+
         // From the help screen go back to the module overview.
         if (e.target.matches(selectors.actions.closeOption)) {
             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
@@ -273,18 +308,22 @@ const focusChooserOption = (currentChooserOption, previousChooserOption = false)
     if (previousChooserOption !== false) {
         const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
         const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+        const previousChooserOptionFavourite = previousChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
         // Set tabindex to -1 to remove the previous chooser option element from the focus order.
         previousChooserOption.tabIndex = -1;
         previousChooserOptionLink.tabIndex = -1;
         previousChooserOptionHelp.tabIndex = -1;
+        previousChooserOptionFavourite.tabIndex = -1;
     }
 
     const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
     const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+    const currentChooserOptionFavourite = currentChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
     // Set tabindex to 0 to add current chooser option element to the focus order.
     currentChooserOption.tabIndex = 0;
     currentChooserOptionLink.tabIndex = 0;
     currentChooserOptionHelp.tabIndex = 0;
+    currentChooserOptionFavourite.tabIndex = 0;
     // Focus the current chooser option element.
     currentChooserOption.focus();
 };
@@ -312,8 +351,9 @@ const clickErrorHandler = (item, fallback) => {
  * @param {HTMLElement} origin The calling button
  * @param {Object} modal Our created modal for the section
  * @param {Array} sectionModules An array of all of the built module information
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-export const displayChooser = (origin, modal, sectionModules) => {
+export const displayChooser = (origin, modal, sectionModules, partialFavourite) => {
 
     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
     const mappedModules = new Map();
@@ -322,7 +362,7 @@ export const displayChooser = (origin, modal, sectionModules) => {
     });
 
     // Register event listeners.
-    registerListenerEvents(modal, mappedModules);
+    registerListenerEvents(modal, mappedModules, partialFavourite);
 
     // We want to focus on the action select when the dialog is closed.
     modal.getRoot().on(ModalEvents.hidden, () => {
index e6a09c5..52c5444 100644 (file)
@@ -38,3 +38,43 @@ export const activityModules = (courseid) => {
     };
     return ajax.call([request])[0];
 };
+
+/**
+ * Given a module name, module ID & the current course we want to specify that the module
+ * is a users' favourite.
+ *
+ * @method favouriteModule
+ * @param {String} modName Frankenstyle name of the component to add favourite
+ * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names
+ * @return {object} jQuery promise
+ */
+export const favouriteModule = (modName, modID) => {
+    const request = {
+        methodname: 'core_course_add_content_item_to_user_favourites',
+        args: {
+            componentname: modName,
+            contentitemid: modID,
+        },
+    };
+    return ajax.call([request])[0];
+};
+
+/**
+ * Given a module name, module ID & the current course we want to specify that the module
+ * is no longer a users' favourite.
+ *
+ * @method unfavouriteModule
+ * @param {String} modName Frankenstyle name of the component to add favourite
+ * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names
+ * @return {object} jQuery promise
+ */
+export const unfavouriteModule = (modName, modID) => {
+    const request = {
+        methodname: 'core_course_remove_content_item_from_user_favourites',
+        args: {
+            componentname: modName,
+            contentitemid: modID,
+        },
+    };
+    return ajax.call([request])[0];
+};
index f00b620..f2f10ea 100644 (file)
@@ -62,11 +62,15 @@ export default {
     actions: {
         optionActions: {
             showSummary: getDataSelector('action', 'show-option-summary'),
+            manageFavourite: getDataSelector('action', 'manage-module-favourite'),
         },
         addChooser: getDataSelector('action', 'add-chooser-option'),
         closeOption: getDataSelector('action', 'close-chooser-option-summary'),
         hide: getDataSelector('action', 'hide')
     },
+    render: {
+        favourites: getDataSelector('render', 'favourites-area'),
+    },
     elements: {
         section: '.section',
         sectionmodchooser: 'button.section-modchooser-link',
diff --git a/course/amd/src/recommendations.js b/course/amd/src/recommendations.js
new file mode 100644 (file)
index 0000000..17aae87
--- /dev/null
@@ -0,0 +1,54 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle toggling activity chooser recommendations.
+ *
+ * @module     core_course/recommendations
+ * @copyright  2020 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+
+/**
+ * Do an ajax call to toggle the recommendation
+ *
+ * @param  {object} e The event
+ * @return {void}
+ */
+const toggleRecommendation = (e) => {
+    let data = {
+        methodname: 'core_course_toggle_activity_recommendation',
+        args: {
+            area: e.currentTarget.dataset.area,
+            id: e.currentTarget.dataset.id
+        }
+    };
+    Ajax.call([data])[0].fail(Notification.exception);
+};
+
+/**
+ * Initialisation function
+ *
+ * @return {void}
+ */
+export const init = () => {
+    const checkboxelements = document.querySelectorAll("[data-area]");
+    checkboxelements.forEach((checkbox) => {
+        checkbox.addEventListener('change', toggleRecommendation);
+    });
+};
index 3123418..fb88964 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use core\external\exporter;
 use core_course\local\entity\content_item;
+use core_course\local\service\content_item_service;
 
 /**
  * The course_content_item_exporter class.
@@ -82,7 +83,8 @@ class course_content_item_exporter extends exporter {
             'legacyitem' => [
                 'type' => PARAM_BOOL,
                 'description' => 'If this item was pulled from the old callback and has no item id.'
-            ]
+            ],
+            'recommended' => ['type' => PARAM_BOOL, 'description' => 'Has this item been recommended'],
         ];
     }
 
@@ -113,17 +115,28 @@ class course_content_item_exporter extends exporter {
             }
         }
 
+        $recommended = false;
+        $itemtype = content_item_service::RECOMMENDATION_PREFIX . $this->contentitem->get_component_name();
+        if (isset($this->related['recommended'])) {
+            foreach ($this->related['recommended'] as $favobj) {
+                if ($favobj->itemtype === $itemtype && in_array($this->contentitem->get_id(), $favobj->ids)) {
+                    $recommended = true;
+                }
+            }
+        }
+
         $properties = [
             'id' => $this->contentitem->get_id(),
             'name' => $this->contentitem->get_name(),
             'title' => $this->contentitem->get_title()->get_value(),
             'link' => $this->contentitem->get_link()->out(false),
             'icon' => $this->contentitem->get_icon(),
-            'help' => $this->contentitem->get_help(),
+            'help' => format_text($this->contentitem->get_help(), FORMAT_MARKDOWN),
             'archetype' => $this->contentitem->get_archetype(),
             'componentname' => $this->contentitem->get_component_name(),
             'favourite' => $favourite,
-            'legacyitem' => ($this->contentitem->get_id() == -1)
+            'legacyitem' => ($this->contentitem->get_id() == -1),
+            'recommended' => $recommended
         ];
 
         return $properties;
@@ -137,7 +150,8 @@ class course_content_item_exporter extends exporter {
     protected static function define_related(): array {
         return [
             'context' => '\context',
-            'favouriteitems' => '\stdClass[]?'
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
         ];
     }
 }
index e468a7c..9b1c694 100644 (file)
@@ -80,6 +80,7 @@ class course_content_items_exporter extends exporter {
                 [
                     'context' => $this->related['context'],
                     'favouriteitems' => $this->related['favouriteitems'],
+                    'recommended' => $this->related['recommended']
                 ]
             );
             return $exporter->export($output);
@@ -100,7 +101,8 @@ class course_content_items_exporter extends exporter {
     protected static function define_related() {
         return [
             'context' => '\context',
-            'favouriteitems' => '\stdClass[]?'
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
         ];
     }
 }
index 5505dcf..3c2a605 100644 (file)
@@ -40,6 +40,15 @@ class content_item_service {
     /** @var content_item_readonly_repository_interface $repository a repository for content items. */
     private $repository;
 
+    /** string the component for this favourite. */
+    public const COMPONENT = 'core_course';
+    /** string the favourite prefix itemtype in the favourites table. */
+    public const FAVOURITE_PREFIX = 'contentitem_';
+    /** string the recommendation prefix itemtype in the favourites table. */
+    public const RECOMMENDATION_PREFIX = 'recommend_';
+    /** string the cache name for recommendations. */
+    public const RECOMMENDATION_CACHE = 'recommendation_favourite_course_content_items';
+
     /**
      * The content_item_service constructor.
      *
@@ -69,6 +78,47 @@ class content_item_service {
             return $favmods;
         }
 
+        $favourites = $this->get_content_favourites(self::FAVOURITE_PREFIX, \context_user::instance($user->id));
+
+        $favcache->set($key, $favourites);
+        return $favourites;
+    }
+
+    /**
+     * Returns an array of objects representing recommended content items.
+     *
+     * Each object contains the following properties:
+     * itemtype: a string containing the 'itemtype' key used by the favourites subsystem.
+     * ids[]: an array of ids, representing the content items within a component.
+     *
+     * Since two components can return (via their hook implementation) the same id, the itemtype is used for uniqueness.
+     *
+     * @return array
+     */
+    private function get_recommendations(): array {
+        global $CFG;
+
+        $recommendationcache = \cache::make('core', self::RECOMMENDATION_CACHE);
+        $key = $CFG->siteguest;
+        $favmods = $recommendationcache->get($key);
+        if ($favmods !== false) {
+            return $favmods;
+        }
+
+        $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, \context_user::instance($CFG->siteguest));
+
+        $recommendationcache->set($CFG->siteguest, $favourites);
+        return $favourites;
+    }
+
+    /**
+     * Gets content favourites from the favourites system depending on the area.
+     *
+     * @param  string        $prefix      Prefix for the item type.
+     * @param  \context_user $usercontext User context for the favourite
+     * @return array An array of favourite objects.
+     */
+    private function get_content_favourites(string $prefix, \context_user $usercontext): array {
         // Get all modules and any submodules which implement get_course_content_items() hook.
         // This gives us the set of all itemtypes which we'll use to register favourite content items.
         // The ids that each plugin returns will be used together with the itemtype to uniquely identify
@@ -78,25 +128,24 @@ class content_item_service {
         $itemtypes = [];
         foreach ($plugins as $plugin) {
             // Add the mod itself.
-            $itemtypes[] = 'contentitem_mod_' . $plugin->name;
+            $itemtypes[] = $prefix . 'mod_' . $plugin->name;
 
             // Add any subplugins to the list of item types.
             $subplugins = $pluginmanager->get_subplugins_of_plugin('mod_' . $plugin->name);
             foreach ($subplugins as $subpluginname => $subplugininfo) {
                 if (component_callback_exists($subpluginname, 'get_course_content_items')) {
-                    $itemtypes[] = 'contentitem_' . $subpluginname;
+                    $itemtypes[] = $prefix . $subpluginname;
                 }
             }
         }
 
-        $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($user->id));
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
         $favourites = [];
         foreach ($itemtypes as $itemtype) {
-            $favs = $ufservice->find_favourites_by_type('core_course', $itemtype);
+            $favs = $ufservice->find_favourites_by_type(self::COMPONENT, $itemtype);
             $favobj = (object) ['itemtype' => $itemtype, 'ids' => array_column($favs, 'itemid')];
             $favourites[] = $favobj;
         }
-        $favcache->set($key, $favourites);
         return $favourites;
     }
 
@@ -112,11 +161,13 @@ class content_item_service {
 
         // Export the objects to get the formatted objects for transfer/display.
         $favourites = $this->get_favourite_content_items_for_user($user);
+        $recommendations = $this->get_recommendations();
         $ciexporter = new course_content_items_exporter(
             $allcontentitems,
             [
                 'context' => \context_system::instance(),
-                'favouriteitems' => $favourites
+                'favouriteitems' => $favourites,
+                'recommended' => $recommendations
             ]
         );
         $exported = $ciexporter->export($PAGE->get_renderer('core'));
@@ -186,11 +237,13 @@ class content_item_service {
 
         // Export the objects to get the formatted objects for transfer/display.
         $favourites = $this->get_favourite_content_items_for_user($user);
+        $recommended = $this->get_recommendations();
         $ciexporter = new course_content_items_exporter(
             $availablecontentitems,
             [
                 'context' => \context_course::instance($course->id),
-                'favouriteitems' => $favourites
+                'favouriteitems' => $favourites,
+                'recommended' => $recommended
             ]
         );
         $exported = $ciexporter->export($PAGE->get_renderer('course'));
@@ -217,9 +270,9 @@ class content_item_service {
 
         // Because each plugin decides its own ids for content items, a combination of
         // itemtype and id is used to guarantee uniqueness across all content items.
-        $itemtype = 'contentitem_' . $componentname;
+        $itemtype = self::FAVOURITE_PREFIX . $componentname;
 
-        $ufservice->create_favourite('core_course', $itemtype, $contentitemid, $usercontext);
+        $ufservice->create_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
 
         $favcache = \cache::make('core', 'user_favourite_course_content_items');
         $favcache->delete($user->id);
@@ -242,9 +295,9 @@ class content_item_service {
 
         // Because each plugin decides its own ids for content items, a combination of
         // itemtype and id is used to guarantee uniqueness across all content items.
-        $itemtype = 'contentitem_' . $componentname;
+        $itemtype = self::FAVOURITE_PREFIX . $componentname;
 
-        $ufservice->delete_favourite('core_course', $itemtype, $contentitemid, $usercontext);
+        $ufservice->delete_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
 
         $favcache = \cache::make('core', 'user_favourite_course_content_items');
         $favcache->delete($user->id);
@@ -252,4 +305,36 @@ class content_item_service {
         $items = $this->get_all_content_items($user);
         return $items[array_search($contentitemid, array_column($items, 'id'))];
     }
+
+    /**
+     * Toggle an activity to being recommended or not.
+     *
+     * @param  string $itemtype The component such as mod_assign, or assignsubmission_file
+     * @param  int    $itemid   The id related to this component item.
+     * @return bool True on creating a favourite, false on deleting it.
+     */
+    public function toggle_recommendation(string $itemtype, int $itemid): bool {
+        global $CFG;
+
+        $context = \context_system::instance();
+
+        $itemtype = self::RECOMMENDATION_PREFIX . $itemtype;
+
+        // Favourites are created using a user context. We'll use the site guest user ID as that should not change and there
+        // can be only one.
+        $usercontext = \context_user::instance($CFG->siteguest);
+
+        $recommendationcache = \cache::make('core', self::RECOMMENDATION_CACHE);
+
+        $favouritefactory = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        if ($favouritefactory->favourite_exists(self::COMPONENT, $itemtype, $itemid, $context)) {
+            $favouritefactory->delete_favourite(self::COMPONENT, $itemtype, $itemid, $context);
+            $result = $recommendationcache->delete($CFG->siteguest);
+            return false;
+        } else {
+            $favouritefactory->create_favourite(self::COMPONENT, $itemtype, $itemid, $context);
+            $result = $recommendationcache->delete($CFG->siteguest);
+            return true;
+        }
+    }
 }
diff --git a/course/classes/output/recommendations/activity_list.php b/course/classes/output/recommendations/activity_list.php
new file mode 100644 (file)
index 0000000..0352734
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains activity_list renderable used for the recommended activities page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output\recommendations;
+
+/**
+ * Activity list renderable.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class activity_list implements \renderable, \templatable {
+
+    /** @var array $modules activities to display in the recommendations page. */
+    protected $modules;
+
+    /**
+     * Constructor method.
+     *
+     * @param array $modules Activities to display
+     */
+    public function __construct(array $modules) {
+        $this->modules = $modules;
+    }
+
+    /**
+     * Export method to configure information into something the template can use.
+     *
+     * @param  \renderer_base $output Not actually used.
+     * @return array Template context information.
+     */
+    public function export_for_template(\renderer_base $output): array {
+
+        $info = array_map(function($module) {
+            return [
+                'id' => $module->id ?? '',
+                'name' => $module->title,
+                'componentname' => $module->componentname,
+                'icon' => $module->icon,
+                'recommended' => $module->recommended ?? ''
+            ];
+        }, $this->modules);
+
+        return ['categories' => ['categoryname' => get_string('activities'), 'categorydata' => $info]];
+    }
+}
diff --git a/course/classes/output/recommendations/renderer.php b/course/classes/output/recommendations/renderer.php
new file mode 100644 (file)
index 0000000..676d414
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains renderers for the recommendations page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output\recommendations;
+
+/**
+ * Main renderer for the recommendations page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render a list of activities to recommend.
+     *
+     * @param  \core_course\output\recommendations\activity_list $page activity list renderable
+     * @return string html for displaying.
+     */
+    public function render_activity_list(\core_course\output\recommendations\activity_list $page): string {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('core_course/activity_list', $data);
+    }
+}
index 3095668..5ddf89e 100644 (file)
@@ -4287,4 +4287,53 @@ class core_course_external extends external_api {
         $contentitems = $contentitemservice->get_content_items_for_user_in_course($USER, $course);
         return ['content_items' => $contentitems];
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function toggle_activity_recommendation_parameters() {
+        return new external_function_parameters([
+            'area' => new external_value(PARAM_TEXT, 'The favourite area (itemtype)', VALUE_REQUIRED),
+            'id' => new external_value(PARAM_INT, 'id of the activity or whatever', VALUE_REQUIRED),
+        ]);
+    }
+
+    /**
+     * Update the recommendation for an activity item.
+     *
+     * @param  string $area identifier for this activity.
+     * @param  int $id Associated id. This is needed in conjunction with the area to find the recommendation.
+     * @return array some warnings or something.
+     */
+    public static function toggle_activity_recommendation(string $area, int $id): array {
+        ['area' => $area, 'id' => $id] = self::validate_parameters(self::toggle_activity_recommendation_parameters(),
+                ['area' => $area, 'id' => $id]);
+
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        require_capability('moodle/course:recommendactivity', $context);
+
+        $manager = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+
+        $status = $manager->toggle_recommendation($area, $id);
+        return ['id' => $id, 'area' => $area, 'status' => $status];
+    }
+
+    /**
+     * Returns warnings.
+     *
+     * @return external_description
+     */
+    public static function toggle_activity_recommendation_returns() {
+        return new external_single_structure(
+            [
+                'id' => new external_value(PARAM_INT, 'id of the activity or whatever'),
+                'area' => new external_value(PARAM_TEXT, 'The favourite area (itemtype)'),
+                'status' => new external_value(PARAM_BOOL, 'If created or deleted'),
+            ]
+        );
+    }
 }
diff --git a/course/recommendations.php b/course/recommendations.php
new file mode 100644 (file)
index 0000000..f7c2d1d
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Site recommendations for the activity chooser.
+ *
+ * @package    core_course
+ * @copyright  2020 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once("../config.php");
+
+$context = context_system::instance();
+$url = new moodle_url('/course/recommendations.php');
+
+$pageheading = format_string($SITE->fullname, true, ['context' => $context]);
+
+$PAGE->set_context($context);
+$PAGE->set_url($url);
+$PAGE->set_pagelayout('admin');
+
+$PAGE->set_title(get_string('activitychooserrecommendations', 'course'));
+$PAGE->set_heading($pageheading);
+
+require_login();
+require_capability('moodle/course:recommendactivity', $context);
+
+$renderer = $PAGE->get_renderer('core_course', 'recommendations');
+
+echo $renderer->header();
+echo $renderer->heading(get_string('activitychooserrecommendations', 'course'));
+
+$manager = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+$modules = $manager->get_all_content_items($USER);
+
+$activitylist = new \core_course\output\recommendations\activity_list($modules);
+
+echo $renderer->render_activity_list($activitylist);
+
+echo $renderer->footer();
diff --git a/course/templates/activity_list.mustache b/course/templates/activity_list.mustache
new file mode 100644 (file)
index 0000000..8c0965c
--- /dev/null
@@ -0,0 +1,56 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/activity_list
+
+    Displays a list of activities to recommend in the activity chooser.
+
+    No example given as the js will fire and create records from the template library page.
+}}
+{{#categories}}
+<h3>{{categoryname}}</h3>
+<table class="table table-striped table-hover">
+    <thead>
+        <tr class="d-flex">
+            <th scope="col" class="col-7 c0">{{#str}}module, course{{/str}}</th>
+            <th scope="col" class="col-5 c1">{{#str}}recommend, course{{/str}}</th>
+        </tr>
+    </thead>
+    <tbody>
+        {{#categorydata}}
+        <tr class="d-flex">
+            <td class="font-weight-bold col-7 c0"><span>{{{icon}}}</span>{{name}}</td>
+            {{#id}}
+            <td class="col-5 c1 colselect">
+            <input class="activity-recommend-checkbox" type="checkbox" aria-label="{{#str}}recommendcheckbox, course, {{name}}{{/str}}" data-area="{{componentname}}" data-id="{{id}}" {{#recommended}}checked="checked"{{/recommended}}  />
+            </td>
+            {{/id}}
+            {{^id}}
+            <td class="col-5"></td>
+            {{/id}}
+        </tr>
+        {{/categorydata}}
+    </tbody>
+</table>
+{{/categories}}
+{{#js}}
+require([
+    'core_course/recommendations',
+], function(Recommendations) {
+    Recommendations.init();
+});
+{{/js}}
index 46c0762..14667b9 100644 (file)
@@ -85,9 +85,7 @@
                 <div class="tab-pane {{#favouritesFirst}}active{{/favouritesFirst}}" id="starred-{{uniqid}}" data-region="favourites" role="tabpanel" aria-labelledby="starred-tab-{{uniqid}}">
                     <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
                         <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container" data-render="favourites-area">
-                            {{#favourites}}
-                                {{>core_course/chooser_item}}
-                            {{/favourites}}
+                            {{>core_course/chooser_favourites}}
                         </div>
                     </div>
                 </div>
diff --git a/course/templates/chooser_favourites.mustache b/course/templates/chooser_favourites.mustache
new file mode 100644 (file)
index 0000000..9d4db58
--- /dev/null
@@ -0,0 +1,36 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/chooser_favourites
+
+    Chooser favourite template partial.
+
+    Example context (json):
+    {
+        "favourites": {
+            "label": "Option name",
+            "description": "Option description",
+            "urls": {
+                "addoption": "http://addoptionurl.com"
+            },
+            "icon": "<img class='icon' src='http://urltooptionicon'>"
+        }
+    }
+}}
+{{#favourites}}
+    {{>core_course/chooser_item}}
+{{/favourites}}
index dd75b6a..105717a 100644 (file)
@@ -29,7 +29,7 @@
         "icon": "<img class='icon' src='http://urltooptionicon'>"
     }
 }}
-<div role="menuitem" tabindex="-1" aria-label="{{label}}" class="option d-block text-center py-3 px-2" data-region="chooser-option-container" data-modname="{{componentname}}_{{link}}">
+<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option d-block text-center py-3 px-2" data-region="chooser-option-container" data-internal="{{name}}" data-modname="{{componentname}}_{{link}}">
     <div class="optioninfo w-100" data-region="chooser-option-info-container">
         <a class="d-block" href="{{link}}" title="{{#str}} addnew, moodle, {{title}} {{/str}}" tabindex="-1" data-action="add-chooser-option">
             <span class="optionicon d-block">
             <span class="optionname d-block">{{title}}</span>
         </a>
         <div class="optionactions btn-group" role="group" data-region="chooser-option-actions-container">
+            {{^legacyitem}}
+                <button class="btn btn-icon icon-no-margin icon-size-3 m-0 optionaction {{#favourite}}text-primary{{/favourite}}{{^favourite}}text-muted{{/favourite}}"
+                        data-action="manage-module-favourite"
+                        data-favourited="{{favourite}}"
+                        data-id="{{id}}"
+                        data-name="{{componentname}}"
+                        data-internal="{{name}}"
+                        {{^favourite}}
+                            aria-pressed="false"
+                        {{/favourite}}
+                        {{#favourite}}
+                            aria-pressed="true"
+                        {{/favourite}}
+                        aria-label="{{#str}} aria:modulefavourite, core_course, {{title}} {{/str}}"
+                        tabindex="-1"
+                >
+                    {{#favourite}}
+                        {{#pix}} i/star, core {{/pix}}
+                    {{/favourite}}
+                    {{^favourite}}
+                        {{#pix}} i/star-o, core {{/pix}}
+                    {{/favourite}}
+                </button>
+            {{/legacyitem}}
             <button class="btn btn-icon icon-no-margin icon-size-3 m-0 optionaction" data-action="show-option-summary" tabindex="-1">
                 <span aria-hidden="true">{{#pix}} docs, core {{/pix}}</span>
                 <span class="sr-only">{{#str}} informationformodule, core_course, {{title}} {{/str}}</span>
index f8ec186..c48f250 100644 (file)
@@ -36,7 +36,7 @@
 <form action="{{searchurl}}" id="{{id}}" method="get" class="form-inline">
     <fieldset class="coursesearchbox invisiblefieldset">
         <label for="{{inputid}}">{{#str}}searchcourses{{/str}}</label>
-        <input id="{{inputid}}" name="q" type="text" size="{{inputsize}}" value="{{value}}" class="form-control">
+        <input id="{{inputid}}" name="q" type="text" size="{{inputsize}}" value="{{value}}" class="form-control mb-1 mb-sm-0">
         <input name="areaids" type="hidden" value="{{areaids}}">
         <button class="btn btn-secondary" type="submit">{{#str}}go{{/str}}</button>
         {{#helpicon}}
index b8be7a9..5ad55b1 100644 (file)
@@ -54,8 +54,44 @@ Feature: Display and choose from the available activities in course
     And "Back" "button" should not exist in the "modules" "core_course > Activity chooser screen"
     And I should not see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "Add an activity or resource" "dialogue"
 
-  # Currently stubbed out in MDL-67321 as further issues will add more tabs.
-  Scenario: Navigate between module tabs
+  Scenario: View recommended activities
+    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 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
+    And I log in as "teacher"
+    And I am on "Course" course homepage with editing mode on
+    And I open the activity chooser
+    Then I should see "Recommended" in the "Add an activity or resource" "dialogue"
+    And I click on "Recommended" "link" in the "Add an activity or resource" "dialogue"
+    And I should see "Book" in the "recommended" "core_course > Activity chooser tab"
+
+  Scenario: Favourite a module in the activity chooser
+    Given I open the activity chooser
+    And I should not see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
+    And I click on "Information about the Assignment activity" "button" in the "favourites" "core_course > Activity chooser tab"
+    And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
+
+  Scenario: Add a favourite module and check it exists when reopening the chooser
+    Given I open the activity chooser
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Forum module" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Close" "button" in the "Add an activity or resource" "dialogue"
+    When I click on "Add an activity or resource" "button" in the "Topic 3" "section"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    Then I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
+
+  Scenario: Add a favourite and then remove it whilst checking the tabs work as expected
     Given I open the activity chooser
-    And I should see "Activities" in the "Add an activity or resource" "dialogue"
-    Then I should see "Forum" in the "default" "core_course > Activity chooser tab"
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    Then I should not see "Starred" in the "Add an activity or resource" "dialogue"
diff --git a/course/tests/behat/recommend_activities.feature b/course/tests/behat/recommend_activities.feature
new file mode 100644 (file)
index 0000000..2b2e68d
--- /dev/null
@@ -0,0 +1,28 @@
+@core @core_course @javascript
+Feature: Recommending activities
+  As an admin I want to recommend activities in the activity chooser
+
+  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 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
+    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 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 "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
+    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 872ece6..1aa2860 100644 (file)
@@ -65,7 +65,7 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
         $this->assertObjectHasAttribute('icon', $exporteditem);
         $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
         $this->assertObjectHasAttribute('help', $exporteditem);
-        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN));
         $this->assertObjectHasAttribute('archetype', $exporteditem);
         $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
         $this->assertObjectHasAttribute('componentname', $exporteditem);
@@ -89,7 +89,8 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
             new \core_course\local\entity\string_title('test_title'),
             new \moodle_url(''),
             '',
-            '',
+            '* First point
+            * Another point',
             MOD_ARCHETYPE_OTHER,
             'core_test'
         );
@@ -109,7 +110,7 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
         $this->assertObjectHasAttribute('icon', $exporteditem);
         $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
         $this->assertObjectHasAttribute('help', $exporteditem);
-        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN));
         $this->assertObjectHasAttribute('archetype', $exporteditem);
         $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
         $this->assertObjectHasAttribute('componentname', $exporteditem);
index 82811be..c1877c1 100644 (file)
@@ -3170,4 +3170,32 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
 
         $this->assertEmpty($result['content_items']);
     }
+
+    /**
+     * Test toggling the recommendation of an activity.
+     */
+    public function test_toggle_activity_recommendation() {
+        global $CFG;
+
+        $this->resetAfterTest();
+
+        $context = context_system::instance();
+        $usercontext = context_user::instance($CFG->siteguest);
+        $component = 'core_course';
+        $favouritefactory = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        $areaname = 'test_core';
+        $areaid = 3;
+
+        // Test we have the favourite.
+        $this->setAdminUser();
+        $result = core_course_external::toggle_activity_recommendation($areaname, $areaid);
+        $this->assertTrue($favouritefactory->favourite_exists($component,
+                \core_course\local\service\content_item_service::RECOMMENDATION_PREFIX . $areaname, $areaid, $context));
+        $this->assertTrue($result['status']);
+        // Test that it is now gone.
+        $result = core_course_external::toggle_activity_recommendation($areaname, $areaid);
+        $this->assertFalse($favouritefactory->favourite_exists($component, $areaname, $areaid, $context));
+        $this->assertFalse($result['status']);
+    }
 }
index 2e7d2a4..32e76ce 100644 (file)
@@ -195,4 +195,34 @@ class services_content_item_service_testcase extends \advanced_testcase {
         $this->assertFalse($allitemsassign->favourite);
         $this->assertFalse($courseitemsassign->favourite);
     }
+
+    /**
+     * Test that toggling a recommendation works as anticipated.
+     */
+    public function test_toggle_recommendation() {
+        $this->resetAfterTest();
+
+        // Create a user in a course.
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cis = new content_item_service(new content_item_readonly_repository());
+
+        // Grab a the assign content item, which we'll recommend for the user.
+        $items = $cis->get_all_content_items($user);
+        $assign = $items[array_search('assign', array_column($items, 'name'))];
+        $result = $cis->toggle_recommendation($assign->componentname, $assign->id);
+        $this->assertTrue($result);
+
+        $courseitems = $cis->get_all_content_items($user);
+        $courseitemsassign = $courseitems[array_search('assign', array_column($courseitems, 'name'))];
+        $this->assertTrue($courseitemsassign->recommended);
+
+        // Let's toggle the recommendation off.
+        $result = $cis->toggle_recommendation($assign->componentname, $assign->id);
+        $this->assertFalse($result);
+
+        $courseitems = $cis->get_all_content_items($user);
+        $courseitemsassign = $courseitems[array_search('assign', array_column($courseitems, 'name'))];
+        $this->assertFalse($courseitemsassign->recommended);
+    }
 }
index 65b1440..f5beff8 100644 (file)
@@ -716,7 +716,7 @@ class enrol_self_plugin extends enrol_plugin {
      * @return bool
      */
     public function edit_instance_form($instance, MoodleQuickForm $mform, $context) {
-        global $CFG;
+        global $CFG, $DB;
 
         // Merge these two settings to one value for the single selection element.
         if ($instance->notifyall and $instance->expirynotify) {
index 949f83f..2e7f0ad 100644 (file)
@@ -55,7 +55,7 @@ $string['leveldefinition'] = 'Level {$a} definition';
 $string['levelempty'] = 'Click to edit level';
 $string['levelsgroup'] = 'Levels group';
 $string['lockzeropoints'] = 'Calculate grade having a minimum score of the minimum achievable grade for the rubric';
-$string['lockzeropoints_help'] = 'This setting only applies if the sum of the minimum number of points for each criterion is greater than 0. If ticked, the minimum achievable grade for the rubric will be the minimum achievable grade for the rubric. If unticked, the minimum possible score for the rubric will be mapped to the minimum grade available for the activity (which is 0 unless a scale is used).';
+$string['lockzeropoints_help'] = 'This setting only applies if the sum of the minimum number of points for each criterion is greater than 0. If ticked, the minimum score of the activity will be the minimum achievable grade for the rubric. If unticked, the minimum possible score for the rubric will be mapped to the minimum grade available for the activity (which is 0 unless a scale is used).';
 $string['name'] = 'Name';
 $string['needregrademessage'] = 'The rubric definition was changed after this student had been graded. The student can not see this rubric until you check the rubric and update the grade.';
 $string['notset'] = 'Not set';
index b0a3ff9..00f4c6d 100644 (file)
@@ -128,6 +128,7 @@ class helper {
      * @return array sql and params list
      */
     protected static function get_users_sql_and_params($context, $search = '', $count = false) {
+        global $DB, $USER;
 
         // Fields we need from the user table.
         $extrafields = get_extra_user_fields($context);
@@ -147,13 +148,33 @@ class helper {
             $select = "SELECT DISTINCT $ufields ";
             $orderby = " ORDER BY u.lastname ASC, u.firstname ASC";
         }
+
+        $groupjoinsql = '';
+        $groupwheresql = '';
+        $courseid = $context->instanceid;
+        $groupmode = groups_get_course_groupmode(get_course($courseid));
+
+        // We're only interested in separate groups mode because it's the only group mode that requires the user to be a member of
+        // specific group(s), except when they have the 'moodle/site:accessallgroups' capability.
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) {
+            // Fetch the groups that the user can see.
+            $groups = groups_get_all_groups($courseid, $USER->id, 0, 'g.id');
+
+            // Add join condition to include users that only belong to the same group as the user.
+            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED, 'gid', true, 0);
+            $groupjoinsql = " JOIN {groups_members} gm ON gm.userid = u.id ";
+            $groupwheresql = " AND gm.groupid $insql ";
+            $params = array_merge($params, $inparams);
+        }
+
         $sql = "$select
                  FROM {user} u
                  JOIN {grade_grades_history} ggh ON u.id = ggh.userid
                  JOIN {grade_items} gi ON gi.id = ggh.itemid
-                WHERE $filtersql gi.courseid = :courseid";
+                 $groupjoinsql
+                WHERE $filtersql gi.courseid = :courseid $groupwheresql";
         $sql .= $orderby;
-        $params['courseid'] = $context->instanceid;
+        $params['courseid'] = $courseid;
 
         return array($sql, $params);
     }
@@ -166,18 +187,31 @@ class helper {
      * @return array list of graders.
      */
     public static function get_graders($courseid) {
-        global $DB;
+        global $DB, $USER;
+
+        $groupjoinsql = $groupwheresql = '';
+        $inparams = [];
+        $groupmode = groups_get_course_groupmode(get_course($courseid));
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', \context_course::instance($courseid))) {
+            // Fetch the groups that the user can see.
+            $groups = groups_get_all_groups($courseid, $USER->id, 0, 'g.id');
+            // Add join condition to include users that only belong to the same group as the user.
+            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED, 'gid', true, 0);
+            $groupjoinsql = " JOIN {groups_members} gm ON gm.userid = u.id ";
+            $groupwheresql = " AND gm.groupid $insql ";
+        }
 
         $ufields = get_all_user_name_fields(true, 'u');
         $sql = "SELECT u.id, $ufields
                   FROM {user} u
                   JOIN {grade_grades_history} ggh ON ggh.usermodified = u.id
                   JOIN {grade_items} gi ON gi.id = ggh.itemid
-                 WHERE gi.courseid = :courseid
+                 $groupjoinsql
+                 WHERE gi.courseid = :courseid $groupwheresql
               GROUP BY u.id, $ufields
               ORDER BY u.lastname ASC, u.firstname ASC";
 
-        $graders = $DB->get_records_sql($sql, array('courseid' => $courseid));
+        $graders = $DB->get_records_sql($sql, array('courseid' => $courseid) + $inparams);
         $return = array(0 => get_string('allgraders', 'gradereport_history'));
         foreach ($graders as $grader) {
             $return[$grader->id] = fullname($grader);
index 4d9c9fc..5cc89df 100644 (file)
@@ -337,7 +337,7 @@ class tablelog extends \table_sql implements \renderable {
      * @return array containing sql to use and an array of params.
      */
     protected function get_filters_sql_and_params() {
-        global $DB;
+        global $DB, $USER;
 
         $coursecontext = $this->context;
         $filter = 'gi.courseid = :courseid';
@@ -368,6 +368,16 @@ class tablelog extends \table_sql implements \renderable {
             $params += array('grader' => $this->filters->grader);
         }
 
+        // If the course is separate group mode and the current user is not allowed to see all groups make sure
+        // that we display only users from the same groups as current user.
+        $groupmode = get_course($coursecontext->instanceid)->groupmode;
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $coursecontext)) {
+            $groupids = array_column(groups_get_all_groups($coursecontext->instanceid, $USER->id, 0, 'g.id'), 'id');
+            list($gsql, $gparams) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gmuparam', true, 0);
+            $filter .= " AND EXISTS (SELECT 1 FROM {groups_members} gmu WHERE gmu.userid=ggh.userid AND gmu.groupid $gsql)";
+            $params += $gparams;
+        }
+
         return array($filter, $params);
     }
 
index 7a3959d..7a58ae1 100644 (file)
@@ -53,6 +53,12 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $u5 = $this->getDataGenerator()->create_user();
         $grader1 = $this->getDataGenerator()->create_user();
         $grader2 = $this->getDataGenerator()->create_user();
+        self::getDataGenerator()->enrol_user($grader1->id, $c1->id, 'teacher');
+        self::getDataGenerator()->enrol_user($grader2->id, $c1->id, 'teacher');
+        self::getDataGenerator()->enrol_user($u2->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u3->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u4->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u5->id, $c1->id, 'student');
 
         // Modules.
         $c1m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c1));
@@ -65,6 +71,8 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $giparams = array('itemtype' => 'mod', 'itemmodule' => 'assign');
         $grades = array();
 
+        $this->setUser($grader1);
+
         $gi = grade_item::fetch($giparams + array('iteminstance' => $c1m1->id));
         $grades['c1m1u1'] = $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id,
                 'timemodified' => time() - 3600));
@@ -163,6 +171,20 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $this->assertEquals(null, $results[$grades['c2m2u1a']->id]->prevgrade);
         $this->assertEquals($grades['c2m2u1a']->finalgrade, $results[$grades['c2m2u1c']->id]->prevgrade);
         $this->assertEquals($grades['c2m2u1c']->finalgrade, $results[$grades['c2m2u1e']->id]->prevgrade);
+
+        // Put course in separate groups mode, add grader1 and two students to the same group.
+        $c1->groupmode = SEPARATEGROUPS;
+        update_course($c1);
+        $this->assertFalse(has_capability('moodle/site:accessallgroups', \context_course::instance($c1->id)));
+        $g1 = self::getDataGenerator()->create_group(['courseid' => $c1->id, 'name' => 'g1']);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $grader1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $u1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $u2->id]);
+        $this->assertEquals(2, $this->get_tablelog_results($c1ctx, array(), true));
+
+        // Grader2 is not in any groups.
+        $this->setUser($grader2);
+        $this->assertEquals(0, $this->get_tablelog_results($c1ctx, array(), true));
     }
 
     /**
@@ -219,6 +241,118 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $this->assertEquals(1, \gradereport_history\helper::get_users_count($c1ctx, 'c'));
     }
 
+    /**
+     * Data provider method for \gradereport_history_report_testcase::test_get_users_with_groups()
+     */
+    public function get_users_provider() {
+        return [
+            'Visible groups, non-editing teacher, not in any group' => [
+                VISIBLEGROUPS, 'teacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Visible groups, non-editing teacher' => [
+                VISIBLEGROUPS, 'teacher', [], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Visible groups, editing teacher' => [
+                VISIBLEGROUPS, 'editingteacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Separate groups, non-editing teacher' => [
+                SEPARATEGROUPS, 'teacher', ['g1', 'g2'], ['s1', 's2']
+            ],
+            'Separate groups, non-editing teacher, not in any group' => [
+                SEPARATEGROUPS, 'teacher', [], []
+            ],
+            'Separate groups, non-editing teacher and student share two groups' => [
+                SEPARATEGROUPS, 'teacher', ['g4', 'g5'], ['s5']
+            ],
+            'Separate groups, editing teacher' => [
+                SEPARATEGROUPS, 'editingteacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+        ];
+    }
+
+    /**
+     * Test for helper::get_users() with course group mode set.
+     *
+     * @dataProvider get_users_provider
+     * @param $groupmode
+     * @param $teacherrole
+     * @param $teachergroups
+     * @param $expectedusers
+     */
+    public function test_get_users_with_groups($groupmode, $teacherrole, $teachergroups, $expectedusers) {
+        global $DB;
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator();
+
+        // Create a test course.
+        $course = $generator->create_course(['groupmode' => $groupmode]);
+
+        // Create an assignment module.
+        $assign = $generator->create_module('assign', ['course' => $course]);
+
+        // Fetch roles.
+        $role = $DB->get_record('role', ['shortname' => $teacherrole], '*', MUST_EXIST);
+        $studentrole =  $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+
+        // Create users.
+        $t1 = $generator->create_user(['username' => 't1', 'email' => 't1@example.com']);
+        $s1 = $generator->create_user(['username' => 's1', 'email' => 's1@example.com']);
+        $s2 = $generator->create_user(['username' => 's2', 'email' => 's2@example.com']);
+        $s3 = $generator->create_user(['username' => 's3', 'email' => 's3@example.com']);
+        $s4 = $generator->create_user(['username' => 's4', 'email' => 's4@example.com']);
+        $s5 = $generator->create_user(['username' => 's5', 'email' => 's5@example.com']);
+
+        // Enrol users.
+        $generator->enrol_user($t1->id, $course->id, $role->id);
+        $generator->enrol_user($s1->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s2->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s3->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s4->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s5->id, $course->id, $studentrole->id);
+
+        // Create groups.
+        $groups = [];
+        $groups['g1'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g1']);
+        $groups['g2'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g2']);
+        $groups['g3'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g3']);
+        $groups['g4'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g4']);
+        $groups['g5'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g5']);
+
+        // Add teacher to the assigned groups.
+        foreach ($teachergroups as $groupname) {
+            $group = $groups[$groupname];
+            $generator->create_group_member(['groupid' => $group->id, 'userid' => $t1->id]);
+        }
+
+        // Add students to groups.
+        $generator->create_group_member(['groupid' => $groups['g1']->id, 'userid' => $s1->id]);
+        $generator->create_group_member(['groupid' => $groups['g2']->id, 'userid' => $s2->id]);
+        $generator->create_group_member(['groupid' => $groups['g3']->id, 'userid' => $s3->id]);
+        $generator->create_group_member(['groupid' => $groups['g4']->id, 'userid' => $s5->id]);
+        $generator->create_group_member(['groupid' => $groups['g5']->id, 'userid' => $s5->id]);
+
+        // Creating grade history for the students.
+        $gi = grade_item::fetch(['iteminstance' => $assign->id, 'itemtype' => 'mod', 'itemmodule' => 'assign']);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s1->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s2->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s3->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s4->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s5->id]);
+
+        // Log in as the teacher.
+        $this->setUser($t1);
+
+        // Fetch the users.
+        $users = \gradereport_history\helper::get_users(context_course::instance($course->id));
+        // Confirm that the number of users fetched is the same as the count of expected users.
+        $this->assertCount(count($expectedusers), $users);
+        foreach ($users as $user) {
+            // Confirm that each user returned is in the list of expected users.
+            $this->assertTrue(in_array($user->username, $expectedusers));
+        }
+    }
+
     /**
      * Test the get graders helper method.
      */
@@ -228,9 +362,11 @@ class gradereport_history_report_testcase extends advanced_testcase {
         // Making the setup.
         $c1 = $this->getDataGenerator()->create_course();
         $c2 = $this->getDataGenerator()->create_course();
+        $c3 = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS]);
 
         $c1m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c1));
         $c2m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c2));
+        $c3m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c3));
 
         // Users.
         $u1 = $this->getDataGenerator()->create_user(array('firstname' => 'Eric', 'lastname' => 'Cartman'));
@@ -238,6 +374,12 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $u3 = $this->getDataGenerator()->create_user(array('firstname' => 'Kyle', 'lastname' => 'Broflovski'));
         $u4 = $this->getDataGenerator()->create_user(array('firstname' => 'Kenny', 'lastname' => 'McCormick'));
 
+        foreach ([$c1, $c2, $c3] as $course) {
+            foreach ([$u1, $u2, $u3, $u4] as $user) {
+                self::getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+            }
+        }
+
         // Creating grade history for some users.
         $gi = grade_item::fetch(array('iteminstance' => $c1m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
         $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u1->id));
@@ -247,6 +389,10 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $gi = grade_item::fetch(array('iteminstance' => $c2m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
         $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u4->id));
 
+        $gi = grade_item::fetch(array('iteminstance' => $c3m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
+        $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u1->id));
+        $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u2->id, 'usermodified' => $u2->id));
+
         // Checking fetching some users.
         $graders = \gradereport_history\helper::get_graders($c1->id);
         $this->assertCount(4, $graders); // Including "all graders" .
@@ -256,6 +402,17 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $graders = \gradereport_history\helper::get_graders($c2->id);
         $this->assertCount(2, $graders); // Including "all graders" .
         $this->assertArrayHasKey($u4->id, $graders);
+
+        // Third course is in separate groups mode. Only graders from the same group will be returned.
+        $g = self::getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'g1']);
+        self::getDataGenerator()->create_group_member(['groupid' => $g->id, 'userid' => $u1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g->id, 'userid' => $u2->id]);
+        $this->setUser($u1);
+        $graders = \gradereport_history\helper::get_graders($c3->id);
+        $this->assertCount(3, $graders); // Including "all graders" .
+        $this->setUser($u3);
+        $graders = \gradereport_history\helper::get_graders($c3->id);
+        $this->assertCount(1, $graders); // Including "all graders" .
     }
 
     /**
diff --git a/h5p/classes/api.php b/h5p/classes/api.php
new file mode 100644 (file)
index 0000000..fdd5b29
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains API class for the H5P area.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Contains API class for the H5P area.
+ *
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api {
+
+    /**
+     * Delete a library and also all the libraries depending on it and the H5P contents using it. For the H5P content, only the
+     * database entries in {h5p} are removed (the .h5p files are not removed in order to let users to deploy them again).
+     *
+     * @param  factory   $factory The H5P factory.
+     * @param  \stdClass $library The library to delete.
+     */
+    public static function delete_library(factory $factory, \stdClass $library): void {
+        global $DB;
+
+        // Get the H5P contents using this library, to remove them from DB. The .h5p files won't be removed
+        // so they will be displayed by the player next time a user with the proper permissions accesses it.
+        $sql = 'SELECT DISTINCT hcl.h5pid
+                  FROM {h5p_contents_libraries} hcl
+                 WHERE hcl.libraryid = :libraryid';
+        $params = ['libraryid' => $library->id];
+        $h5pcontents = $DB->get_records_sql($sql, $params);
+        foreach ($h5pcontents as $h5pcontent) {
+            $factory->get_framework()->deleteContentData($h5pcontent->h5pid);
+        }
+
+        $fs = $factory->get_core()->fs;
+        $framework = $factory->get_framework();
+        // Delete the library from the file system.
+        $fs->delete_library(array('libraryId' => $library->id));
+        // Delete also the cache assets to rebuild them next time.
+        $framework->deleteCachedAssets($library->id);
+
+        // Remove library data from database.
+        $DB->delete_records('h5p_library_dependencies', array('libraryid' => $library->id));
+        $DB->delete_records('h5p_libraries', array('id' => $library->id));
+
+        // Remove the libraries using this library.
+        $requiredlibraries = self::get_dependent_libraries($library->id);
+        foreach ($requiredlibraries as $requiredlibrary) {
+            self::delete_library($factory, $requiredlibrary);
+        }
+    }
+
+    /**
+     * Get all the libraries using a defined library.
+     *
+     * @param  int    $libraryid The library to get its dependencies.
+     * @return array  List of libraryid with all the libraries required by a defined library.
+     */
+    public static function get_dependent_libraries(int $libraryid): array {
+        global $DB;
+
+        $sql = 'SELECT *
+                  FROM {h5p_libraries}
+                 WHERE id IN (SELECT DISTINCT hl.id
+                                FROM {h5p_library_dependencies} hld
+                                JOIN {h5p_libraries} hl ON hl.id = hld.libraryid
+                               WHERE hld.requiredlibraryid = :libraryid)';
+        $params = ['libraryid' => $libraryid];
+
+        return $DB->get_records_sql($sql, $params);
+    }
+
+    /**
+     * Get a library from an identifier.
+     *
+     * @param  int    $libraryid The library identifier.
+     * @return \stdClass The library object having the library identifier defined.
+     * @throws dml_exception A DML specific exception is thrown if the libraryid doesn't exist.
+     */
+    public static function get_library(int $libraryid): \stdClass {
+        global $DB;
+
+        return $DB->get_record('h5p_libraries', ['id' => $libraryid], '*', MUST_EXIST);
+    }
+}
index a3091b7..829b474 100644 (file)
@@ -52,6 +52,6 @@ class uploadlibraries_form extends \moodleform {
         $mform->addHelpButton('h5ppackage', 'h5ppackage', 'core_h5p');
         $mform->addRule('h5ppackage', null, 'required');
 
-        $this->add_action_buttons(false, get_string('uploadlibraries', 'core_h5p'));
+        $mform->addElement('submit', 'uploadlibraries', get_string('uploadlibraries', 'core_h5p'));
     }
 }
index 4818d86..e6a13ed 100644 (file)
@@ -1133,15 +1133,8 @@ class framework implements \H5PFrameworkInterface {
      * @param stdClass $library Library object with id, name, major version and minor version
      */
     public function deleteLibrary($library) {
-        global $DB;
-
-        $fs = new \core_h5p\file_storage();
-        // Delete the library from the file system.
-        $fs->delete_library(array('libraryId' => $library->id));
-
-        // Remove library data from database.
-        $DB->delete_records('h5p_library_dependencies', array('libraryid' => $library->id));
-        $DB->delete_records('h5p_libraries', array('id' => $library->id));
+        $factory = new \core_h5p\factory();
+        \core_h5p\api::delete_library($factory, $library);
     }
 
     /**
@@ -1259,28 +1252,38 @@ class framework implements \H5PFrameworkInterface {
     }
 
     /**
-     * Get the default behaviour for the display option defined.
+     * Get stored setting.
      * Implements getOption.
      *
      * @param string $name Identifier for the setting
      * @param string $default Optional default value if settings is not set
-     * @return mixed Return The default \H5PDisplayOptionBehaviour for this display option
+     * @return mixed Return  Whatever has been stored as the setting
      */
     public function getOption($name, $default = false) {
-        // TODO: Define the default behaviour for each display option.
-        // For now, all them are disabled by default, so only will be rendered when defined in the displayoptions DB field.
-        return \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        if ($name == core::DISPLAY_OPTION_DOWNLOAD || $name == core::DISPLAY_OPTION_EMBED) {
+            // For now, the download and the embed displayoptions are disabled by default, so only will be rendered when
+            // defined in the displayoptions DB field.
+            // This check should be removed if they are added as new H5P settings, to let admins to define the default value.
+            return \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        }
+
+        $value = get_config('core_h5p', $name);
+        if ($value === false) {
+            return $default;
+        }
+        return $value;
     }
 
     /**
      * Stores the given setting.
+     * For example when did we last check h5p.org for updates to our libraries.
      * Implements setOption.
      *
      * @param string $name Identifier for the setting
      * @param mixed $value Data Whatever we want to store as the setting
      */
     public function setOption($name, $value) {
-        // Currently not storing settings.
+        set_config($name, $value, 'core_h5p');
     }
 
     /**
@@ -1451,6 +1454,10 @@ class framework implements \H5PFrameworkInterface {
             list($sql, $params) = $DB->get_in_or_equal($hashes, SQL_PARAMS_NAMED);
             // Remove all invalid keys.
             $DB->delete_records_select('h5p_libraries_cachedassets', 'hash ' . $sql, $params);
+
+            // Remove also the cachedassets files.
+            $fs = new file_storage();
+            $fs->deleteCachedAssets($hashes);
         }
 
         return $hashes;
diff --git a/h5p/classes/output/libraries.php b/h5p/classes/output/libraries.php
new file mode 100644 (file)
index 0000000..07a8fe9
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class core_h5p\output\libraries
+ *
+ * @package   core_h5p
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+use moodle_url;
+use action_menu;
+use action_menu_link;
+use pix_icon;
+
+/**
+ * Class to help display H5P library management table.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class libraries implements renderable, templatable {
+
+    /** @var H5P factory */
+    protected $factory;
+
+    /** @var H5P library list */
+    protected $libraries;
+
+    /**
+     * Constructor.
+     *
+     * @param factory $factory The H5P factory
+     * @param array $libraries array of h5p libraries records
+     */
+    public function __construct(\core_h5p\factory $factory, array $libraries) {
+        $this->factory = $factory;
+        $this->libraries = $libraries;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        $installed = [];
+        $filestorage = $this->factory->get_core()->fs;
+        foreach ($this->libraries as $libraryname => $versions) {
+            foreach ($versions as $version) {
+                // Get the icon URL.
+                $version->icon = $filestorage->get_icon_url(
+                    $version->id,
+                    $version->machine_name,
+                    $version->major_version,
+                    $version->minor_version
+                );
+                // Get the action menu options.
+                $actionmenu = new action_menu();
+                $actionmenu->set_menu_trigger(get_string('actions', 'core_h5p'));
+                $actionmenu->set_alignment(action_menu::TL, action_menu::BL);
+                $actionmenu->prioritise = true;
+                $actionmenu->add_primary_action(new action_menu_link(
+                    new moodle_url('/h5p/libraries.php', ['deletelibrary' => $version->id]),
+                    new pix_icon('t/delete', get_string('deletelibraryversion', 'core_h5p')),
+                    get_string('deletelibraryversion', 'core_h5p')
+                ));
+                $version->actionmenu = $actionmenu->export_for_template($output);
+                $installed[] = $version;
+            }
+        }
+        $r = new stdClass();
+        $r->contenttypes = $installed;
+        return $r;
+    }
+}
index 6ef87ab..f9d32ab 100644 (file)
@@ -348,12 +348,15 @@ class player {
         $url->remove_params(array_keys($url->params()));
         $path = $url->out_as_local_url();
 
+        // We only need the slasharguments.
+        $path = substr($path, strpos($path, '.php/') + 5);
         $parts = explode('/', $path);
         $filename = array_pop($parts);
-        // First is an empty row and then the pluginfile.php part. Both can be ignored.
-        array_shift($parts);
-        array_shift($parts);
 
+        // If the request is made by tokenpluginfile.php we need to avoid userprivateaccesskey.
+        if (strpos($this->url, '/tokenpluginfile.php')) {
+            array_shift($parts);
+        }
         // Get the contextid, component and filearea.
         $contextid = array_shift($parts);
         $component = array_shift($parts);
@@ -515,20 +518,40 @@ class player {
      */
     private function get_export_settings(bool $downloadenabled): ?\moodle_url {
 
-        if ( ! $downloadenabled) {
+        if (!$downloadenabled) {
             return null;
         }
 
         $systemcontext = \context_system::instance();
         $slug = $this->content['slug'] ? $this->content['slug'] . '-' : '';
-        $url  = \moodle_url::make_pluginfile_url(
-            $systemcontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            "{$slug}{$this->content['id']}.h5p"
-        );
+        // We have to build the right URL.
+        // Depending the request was made through webservice/pluginfile.php or pluginfile.php.
+        if (strpos($this->url, '/webservice/pluginfile.php')) {
+            $url  = \moodle_url::make_webservice_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p"
+            );
+        } else {
+            // If the request is made by tokenpluginfile.php we need to indicates to generate a token for current user.
+            $includetoken = false;
+            if (strpos($this->url, '/tokenpluginfile.php')) {
+                $includetoken = true;
+            }
+            $url  = \moodle_url::make_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p",
+                false,
+                $includetoken
+            );
+        }
 
         return $url;
     }
index bffce14..8d784f6 100644 (file)
@@ -26,6 +26,9 @@ require_once(__DIR__ . '/../config.php');
 
 require_login(null, false);
 
+$deletelibrary = optional_param('deletelibrary', null, PARAM_INT);
+$confirm = optional_param('confirm', false, PARAM_BOOL);
+
 $context = context_system::instance();
 require_capability('moodle/h5p:updatelibraries', $context);
 
@@ -34,16 +37,38 @@ $url = new \moodle_url("/h5p/libraries.php");
 
 $PAGE->set_context($context);
 $PAGE->set_url($url);
-$PAGE->set_title($pagetitle);
 $PAGE->set_pagelayout('admin');
-$PAGE->set_heading($pagetitle);
+$PAGE->set_title("$SITE->shortname: " . $pagetitle);
+$PAGE->set_heading($SITE->fullname);
+
+$h5pfactory = new \core_h5p\factory();
+if ($deletelibrary) {
+    $library = \core_h5p\api::get_library($deletelibrary);
+    if ($confirm) {
+        require_sesskey();
+        \core_h5p\api::delete_library($h5pfactory, $library);
+        redirect(new moodle_url('/h5p/libraries.php'));
+    }
+
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('deleting', 'core_h5p'));
+    echo $OUTPUT->confirm(
+        get_string('deletelibraryconfirm', 'core_h5p', [
+            'name' => format_string($library->title),
+            'version' => format_string($library->majorversion . '.' . $library->minorversion . '.' . $library->patchversion),
+        ]),
+        new moodle_url($PAGE->url, ['deletelibrary' => $deletelibrary, 'confirm' => 1]),
+        new moodle_url('/h5p/libraries.php')
+    );
+    echo $OUTPUT->footer();
+    die();
+}
 
 echo $OUTPUT->header();
 echo $OUTPUT->heading($pagetitle);
 echo $OUTPUT->box(get_string('librariesmanagerdescription', 'core_h5p'));
 
 $form = new \core_h5p\form\uploadlibraries_form();
-$h5pfactory = new \core_h5p\factory();
 if ($data = $form->get_data()) {
     require_sesskey();
 
@@ -55,7 +80,7 @@ if ($data = $form->get_data()) {
     $file = reset($files);
 
     // Validate and save the H5P package.
-    // Because we are passing skipcontent = true to save_h5p function, the returning value is false in an error
+    // Because we are passing skipcontent = true to save_h5p function, the returning value is false if an error
     // is encountered, null when successfully saving the package without creating the content.
     if (\core_h5p\helper::save_h5p($h5pfactory, $file, new stdClass(), false, true) === false) {
         echo $OUTPUT->notification(get_string('invalidpackage', 'core_h5p'), 'error');
@@ -67,23 +92,11 @@ $form->display();
 
 // Load installed Libraries.
 $framework = $h5pfactory->get_framework();
-$filestorage = $h5pfactory->get_core()->fs;
 $libraries = $framework->loadLibraries();
-$installed = [];
-foreach ($libraries as $libraryname => $versions) {
-    foreach ($versions as $version) {
-        $version->icon = $filestorage->get_icon_url(
-            $version->id,
-            $version->machine_name,
-            $version->major_version,
-            $version->minor_version
-        );
-        $installed[] = $version;
-    }
-}
 
-if (count($installed)) {
-    echo $OUTPUT->render_from_template('core_h5p/h5plibraries', (object)['contenttypes' => $installed]);
+if (!empty($libraries)) {
+    $libs = new \core_h5p\output\libraries($h5pfactory, $libraries);
+    echo $OUTPUT->render_from_template('core_h5p/h5plibraries', $libs->export_for_template($OUTPUT));
 }
 
 echo $OUTPUT->footer();
index a6125fb..cb6e7b0 100644 (file)
@@ -69,6 +69,7 @@
                     <tr>
                         <th>{{#str}}description, core{{/str}}</th>
                         <th>{{#str}}version, core{{/str}}</th>
+                        <th aria-label="{{#str}}actions, core_h5p{{/str}}"></th>
                     </tr>
                 </thead>
                 <tbody>
                                 {{{ title }}}
                             </td>
                             <td>{{{ major_version }}}.{{{ minor_version }}}.{{{ patch_version }}}</td>
+                            <td>
+                                {{#actionmenu}}
+                                    {{>core/action_menu}}
+                                {{/actionmenu}}
+                            </td>
                         </tr>
                         {{/runnable}}
                     {{/contenttypes}}
                     <tr>
                         <th>{{#str}}description, core{{/str}}</th>
                         <th>{{#str}}version, core{{/str}}</th>
+                        <th aria-label="{{#str}}actions, core_h5p{{/str}}"></th>
                     </tr>
                 </thead>
                 <tbody>
                                 {{{ title }}}
                             </td>
                             <td>{{{ major_version }}}.{{{ minor_version }}}.{{{ patch_version }}}</td>
+                            <td>
+                                {{#actionmenu}}
+                                    {{>core/action_menu}}
+                                {{/actionmenu}}
+                            </td>
                         </tr>
                         {{/runnable}}
                     {{/contenttypes}}
diff --git a/h5p/tests/api_test.php b/h5p/tests/api_test.php
new file mode 100644 (file)
index 0000000..d64e1eb
--- /dev/null
@@ -0,0 +1,276 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Testing the H5P API.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test class covering the H5P API.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api_testcase extends \advanced_testcase {
+
+    /**
+     * Test the behaviour of delete_library().
+     *
+     * @dataProvider  delete_library_provider
+     * @param  string $libraryname          Machine name of the library to delete.
+     * @param  int    $expectedh5p          Total of H5P contents expected after deleting the library.
+     * @param  int    $expectedlibraries    Total of H5P libraries expected after deleting the library.
+     * @param  int    $expectedcontents     Total of H5P content_libraries expected after deleting the library.
+     * @param  int    $expecteddependencies Total of H5P library dependencies expected after deleting the library.
+     */
+    public function test_delete_library(string $libraryname, int $expectedh5p, int $expectedlibraries,
+            int $expectedcontents, int $expecteddependencies): void {
+        global $DB;
+
+        $this->setRunTestInSeparateProcess(true);
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Check the current content in H5P tables is the expected.
+        $counth5p = $DB->count_records('h5p');
+        $counth5plibraries = $DB->count_records('h5p_libraries');
+        $counth5pcontents = $DB->count_records('h5p_contents_libraries');
+        $counth5pdependencies = $DB->count_records('h5p_library_dependencies');
+
+        $this->assertSame(1, $counth5p);
+        $this->assertSame(7, $counth5plibraries);
+        $this->assertSame(5, $counth5pcontents);
+        $this->assertSame(7, $counth5pdependencies);
+
+        // Delete this library.
+        $factory = new factory();
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname]);
+        if ($library) {
+            api::delete_library($factory, $library);
+        }
+
+        // Check the expected libraries and content have been removed.
+        $counth5p = $DB->count_records('h5p');
+        $counth5plibraries = $DB->count_records('h5p_libraries');
+        $counth5pcontents = $DB->count_records('h5p_contents_libraries');
+        $counth5pdependencies = $DB->count_records('h5p_library_dependencies');
+
+        $this->assertSame($expectedh5p, $counth5p);
+        $this->assertSame($expectedlibraries, $counth5plibraries);
+        $this->assertSame($expectedcontents, $counth5pcontents);
+        $this->assertSame($expecteddependencies, $counth5pdependencies);
+    }
+
+    /**
+     * Data provider for test_delete_library().
+     *
+     * @return array
+     */
+    public function delete_library_provider(): array {
+        return [
+            'Delete MainLibrary' => [
+                'MainLibrary',
+                0,
+                6,
+                0,
+                4,
+            ],
+            'Delete Library1' => [
+                'Library1',
+                0,
+                5,
+                0,
+                1,
+            ],
+            'Delete Library2' => [
+                'Library2',
+                0,
+                4,
+                0,
+                1,
+            ],
+            'Delete Library3' => [
+                'Library3',
+                0,
+                4,
+                0,
+                0,
+            ],
+            'Delete Library4' => [
+                'Library4',
+                0,
+                4,
+                0,
+                1,
+            ],
+            'Delete Library5' => [
+                'Library5',
+                0,
+                3,
+                0,
+                0,
+            ],
+            'Delete a library without dependencies' => [
+                'H5P.TestingLibrary',
+                1,
+                6,
+                5,
+                7,
+            ],
+            'Delete unexisting library' => [
+                'LibraryX',
+                1,
+                7,
+                5,
+                7,
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_dependent_libraries().
+     *
+     * @dataProvider  get_dependent_libraries_provider
+     * @param  string $libraryname     Machine name of the library to delete.
+     * @param  int    $expectedvalue   Total of H5P required libraries expected.
+     */
+    public function test_get_dependent_libraries(string $libraryname, int $expectedvalue): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Get required libraries.
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname], 'id');
+        if ($library) {
+            $libraries = api::get_dependent_libraries((int)$library->id);
+        } else {
+            $libraries = [];
+        }
+
+        $this->assertCount($expectedvalue, $libraries);
+    }
+
+    /**
+     * Data provider for test_get_dependent_libraries().
+     *
+     * @return array
+     */
+    public function get_dependent_libraries_provider(): array {
+        return [
+            'Main library of a content' => [
+                'MainLibrary',
+                0,
+            ],
+            'Library1' => [
+                'Library1',
+                1,
+            ],
+            'Library2' => [
+                'Library2',
+                2,
+            ],
+            'Library without dependencies' => [
+                'H5P.TestingLibrary',
+                0,
+            ],
+            'Unexisting library' => [
+                'LibraryX',
+                0,
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_library().
+     *
+     * @dataProvider  get_library_provider
+     * @param  string $libraryname     Machine name of the library to delete.
+     * @param  bool   $emptyexpected   Wether the expected result is empty or not.
+     */
+    public function test_get_library(string $libraryname, bool $emptyexpected): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Get the library identifier.
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname], 'id');
+        if ($library) {
+            $result = api::get_library((int)$library->id);
+        } else {
+            $result = null;
+        }
+
+        if ($emptyexpected) {
+            $this->assertEmpty($result);
+        } else {
+            $this->assertEquals($library->id, $result->id);
+            $this->assertEquals($libraryname, $result->machinename);
+        }
+
+    }
+
+    /**
+     * Data provider for test_get_library().
+     *
+     * @return array
+     */
+    public function get_library_provider(): array {
+        return [
+            'Main library of a content' => [
+                'MainLibrary',
+                false,
+            ],
+            'Library1' => [
+                'Library1',
+                false,
+            ],
+            'Library without dependencies' => [
+                'H5P.TestingLibrary',
+                false,
+            ],
+            'Unexisting library' => [
+                'LibraryX',
+                true,
+            ],
+        ];
+    }
+}
index 103c057..2d990b3 100644 (file)
@@ -13,7 +13,7 @@ Feature: Upload and list H5P libraries and content types installed
     Given I log in as "admin"
     And I navigate to "H5P > Manage H5P content types" in site administration
     When I upload "h5p/tests/fixtures/h5ptest.zip" file to "H5P content type" filemanager
-    And I click on "Upload H5P content types" "button" in the "#fitem_id_submitbutton" "css_element"
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
     And I wait until the page is ready
     Then I should see "Invalid H5P content type"
     And I should not see "Installed H5P"
@@ -23,7 +23,7 @@ Feature: Upload and list H5P libraries and content types installed
     Given I log in as "admin"
     And I navigate to "H5P > Manage H5P content types" in site administration
     When I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
-    And I click on "Upload H5P content types" "button" in the "#fitem_id_submitbutton" "css_element"
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
     And I wait until the page is ready
     Then I should see "H5P content types uploaded successfully"
     And I should see "Installed H5P"
@@ -36,7 +36,7 @@ Feature: Upload and list H5P libraries and content types installed
     And I should see "1.4" in the "Question" "table_row"
     And I should not see "1.3" in the "Question" "table_row"
     And I upload "h5p/tests/fixtures/essay.zip" file to "H5P content type" filemanager
-    And I click on "Upload H5P content types" "button" in the "#fitem_id_submitbutton" "css_element"
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
     And I wait until the page is ready
 #   Existing content types are kept and new added
     And I should see "Fill in the Blanks"
@@ -44,3 +44,20 @@ Feature: Upload and list H5P libraries and content types installed
     And I click on "Installed H5P libraries" "link"
     And I should see "1.3" in the "Question" "table_row"
     And I should see "1.4"
+
+  @javascript
+  Scenario: Delete H5P library.
+    Given I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And I wait until the page is ready
+    And I click on "Installed H5P libraries" "link"
+    When I click on "Delete version" "link" in the "H5P.FontIcons" "table_row"
+    And I press "Continue"
+    And I click on "Installed H5P content types" "link"
+    Then I should not see "Fill in the Blanks"
+    And I click on "Installed H5P libraries" "link"
+    And I should not see "H5P.FontIcons"
+    And I should not see "Joubel UI"
+    And I should see "Transition"
index 9d02199..b9ae3ca 100644 (file)
@@ -165,6 +165,124 @@ class core_h5p_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(get_string('h5pfilenotfound', 'core_h5p'), $result['warnings'][0]['message']);
     }
 
+    /**
+     * Test the request to get_trusted_h5p_file
+     * using webservice/pluginfile.php as url param.
+     */
+    public function test_allow_webservice_pluginfile_in_url_param() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // This is a valid .H5P file.
+        $filename = 'find-the-words.h5p';
+        $path = __DIR__ . '/fixtures/'.$filename;
+        $syscontext = \context_system::instance();
+        $filerecord = [
+            'contextid' => $syscontext->id,
+            'component' => \core_h5p\file_storage::COMPONENT,
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $filename,
+        ];
+        // Load the h5p file into DB.
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_pathname($filerecord, $path);
+        // Make the URL to pass to the WS.
+        $url  = \moodle_url::make_webservice_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            'unittest',
+            0,
+            '/',
+            $filename
+        );
+        // Call the WS.
+        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
+        // Expected result: Just 1 record on files and none on warnings.
+        $this->assertCount(1, $result['files']);
+        $this->assertCount(0, $result['warnings']);
+        // Get the export file in the DB to compare with the ws's results.
+        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
+        $fileh5purl  = \moodle_url::make_webservice_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            '',
+            '',
+            $fileh5p->get_filename()
+        );
+        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
+        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
+        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
+        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
+        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
+        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+    }
+
+    /**
+     * Test the request to get_trusted_h5p_file
+     * using tokenpluginfile.php as url param.
+     */
+    public function test_allow_tokenluginfile_in_url_param() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // This is a valid .H5P file.
+        $filename = 'find-the-words.h5p';
+        $path = __DIR__ . '/fixtures/'.$filename;
+        $syscontext = \context_system::instance();
+        $filerecord = [
+            'contextid' => $syscontext->id,
+            'component' => \core_h5p\file_storage::COMPONENT,
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $filename,
+        ];
+        // Load the h5p file into DB.
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_pathname($filerecord, $path);
+        // Make the URL to pass to the WS.
+        $url  = \moodle_url::make_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            'unittest',
+            0,
+            '/',
+            $filename,
+            false,
+            true
+        );
+        // Call the WS.
+        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
+        // Expected result: Just 1 record on files and none on warnings.
+        $this->assertCount(1, $result['files']);
+        $this->assertCount(0, $result['warnings']);
+        // Get the export file in the DB to compare with the ws's results.
+        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
+        $fileh5purl  = \moodle_url::make_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            '',
+            '',
+            $fileh5p->get_filename(),
+            false,
+            true
+        );
+        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
+        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
+        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
+        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
+        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
+        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+    }
+
     /**
      * Get the H5P export file.
      *
index 099844b..8f72d80 100644 (file)
@@ -1739,6 +1739,57 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEquals($expected, $dynamicdependencies);
     }
 
+    /**
+     * Test the behaviour of getOption().
+     */
+    public function test_getOption(): void {
+        $this->resetAfterTest();
+
+        // Get value for display_option_download.
+        $value = $this->framework->getOption(\H5PCore::DISPLAY_OPTION_DOWNLOAD);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+
+        // Get value for display_option_embed using default value (it should be ignored).
+        $value = $this->framework->getOption(\H5PCore::DISPLAY_OPTION_EMBED, \H5PDisplayOptionBehaviour::NEVER_SHOW);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+
+        // Get value for unexisting setting without default.
+        $value = $this->framework->getOption('unexistingsetting');
+        $expected = false;
+        $this->assertEquals($expected, $value);
+
+        // Get value for unexisting setting with default.
+        $value = $this->framework->getOption('unexistingsetting', 'defaultvalue');
+        $expected = 'defaultvalue';
+        $this->assertEquals($expected, $value);
+    }
+
+    /**
+     * Test the behaviour of setOption().
+     */
+    public function test_setOption(): void {
+        $this->resetAfterTest();
+
+        // Set value for 'newsetting' setting.
+        $name = 'newsetting';
+        $value = $this->framework->getOption($name);
+        $this->assertEquals(false, $value);
+        $newvalue = 'value1';
+        $this->framework->setOption($name, $newvalue);
+        $value = $this->framework->getOption($name);
+        $this->assertEquals($newvalue, $value);
+
+        // Set value for display_option_download and then get it again. Check it hasn't changed.
+        $name = \H5PCore::DISPLAY_OPTION_DOWNLOAD;
+        $newvalue = \H5PDisplayOptionBehaviour::NEVER_SHOW;
+        $this->framework->setOption($name, $newvalue);
+        $value = $this->framework->getOption($name);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+    }
+
     /**
      * Test the behaviour of updateContentFields().
      */
index 9323757..6158c53 100644 (file)
@@ -259,7 +259,7 @@ class helper_testcase extends \advanced_testcase {
     /**
      * Test the behaviour of can_update_library().
      */
-    public function can_update_library(): void {
+    public function test_can_update_library(): void {
         $this->resetAfterTest();
         $factory = new \core_h5p\factory();
 
index f4d3e7d..a2c3c11 100644 (file)
@@ -61,7 +61,7 @@ $string['allowindexingexceptlogin'] = 'Everywhere except login and signup pages'
 $string['allowindexingnowhere'] = 'Nowhere';
 $string['allowusermailcharset'] = 'Allow user to select character set';
 $string['allowframembedding'] = 'Allow frame embedding';
-$string['allowframembedding_help'] = 'If enabled, this site may be embedded in a frame in a remote system, as recommended when using the \'Publish as LTI tool\' enrolment plugin. Otherwise, it is recommended to leave frame embedding disabled for security reasons.<br />Please, note also that for the mobile app this setting is ignored and frame embedding is always allowed.';
+$string['allowframembedding_help'] = 'If enabled, this site may be embedded in a frame in a remote system, as recommended when using the \'Publish as LTI tool\' enrolment plugin. Otherwise, it is recommended to leave frame embedding disabled for security reasons. Please note that for the mobile app this setting is ignored and frame embedding is always allowed.';
 $string['allowguestmymoodle'] = 'Allow guest access to Dashboard';
 $string['allowobjectembed'] = 'Allow EMBED and OBJECT tags';
 $string['allowthemechangeonurl'] = 'Allow theme changes in the URL';
@@ -191,15 +191,15 @@ $string['configcourseswithsummarieslimit'] = 'The maximum number of courses to d
 $string['configcronclionly'] = 'Running the cron from a web browser can expose privileged information to anonymous users. Thus it is recommended to only run the cron from the command line or set a cron password for remote access.';
 $string['configcronremotepassword'] = 'This means that the cron.php script cannot be run from a web browser without supplying the password using the following form of URL:<pre> https://site.example.com/admin/cron.php?password=opensesame </pre>If this is left empty, no password is required.';
 $string['configcurlcache'] = 'Time-to-live for cURL cache, in seconds.';
-$string['configcustommenuitems'] = 'You can configure a custom menu here to be shown by themes. Each line consists of some menu text, a link URL (optional), a tooltip title (optional) and a language code or comma-separated list of codes (optional, for displaying the line to users of the specified language only), separated by pipe characters. Lines starting with a hyphen will appear as menu items in the previous top level menu, and dividers can be used by adding a line of one or more # characters where desired. For example:
+$string['configcustommenuitems'] = 'A custom menu may be configured here. Enter each menu item on a new line with format: menu text, a link URL (optional, not for a top menu item with sub-items), a tooltip title (optional) and a language code or comma-separated list of codes (optional, for displaying the line to users of the specified language only), separated by pipe characters. Lines starting with a hyphen will appear as menu items in the previous top level menu and ### makes a divider. For example:
 <pre>
-Moodle community|https://moodle.org
--Moodle free support|https://moodle.org/support
--Moodle Docs|https://docs.moodle.org|Moodle Docs
--German Moodle Docs|https://docs.moodle.org/de|Documentation in German|de
+Courses
+-All courses|/course/
+-Course search|/course/search.php
 -###
--Moodle development|https://moodle.org/development
-Moodle.com|https://moodle.com/
+-FAQ|https://someurl.xyz/faq
+-Preguntas más frecuentes|https://someurl.xyz/pmf||es
+Mobile app|https://someurl.xyz/app|Download our app
 </pre>';
 $string['configcustomusermenuitems'] = 'You can configure the contents of the user menu (with the exception of the log out link, which is automatically added). Each line is separated by pipe characters and consists of 1) a string in "langstringname, componentname" form or as plain text, 2) a URL, and 3) an icon either as a pix icon (in the folder pix with the structure [subfoldername]/[iconname], e.g. i/publish) or as a URL. Dividers can be used by adding a line of one or more # characters where desired.';
 $string['configdbsessions'] = 'If enabled, this setting will use the database to store information about current sessions. Note that changing this setting now will log out all current users (including you). If you are using MySQL please make sure that \'max_allowed_packet\' in my.cnf (or my.ini) is at least 4M. Other session drivers can be configured directly in config.php, see config-dist.php for more information. This option disappears if you specify session driver in config.php file.';
@@ -238,7 +238,7 @@ $string['configenablerssfeedsdisabled2'] = 'RSS feeds are currently disabled at
 $string['configenablesafebrowserintegration'] = 'This adds the choice \'Require Safe Exam Browser\' to the \'Browser security\' field on the quiz settings form. See https://www.safeexambrowser.org/ for more information.';
 $string['configenablestats'] = 'If you choose \'yes\' here, Moodle\'s cronjob will process the logs and gather some statistics.  Depending on the amount of traffic on your site, this can take awhile. If you enable this, you will be able to see some interesting graphs and statistics about each of your courses, or on a sitewide basis.';
 $string['configenabletrusttext'] = 'By default Moodle will always thoroughly clean text that comes from users to remove any possible bad scripts, media etc that could be a security risk.  The Trusted Content system is a way of giving particular users that you trust the ability to include these advanced features in their content without interference.  To enable this system, you need to first enable this setting, and then grant the Trusted Content permission to a specific Moodle role.  Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.';
-$string['configenablewebservices'] = 'Web services enable other systems to log in to this Moodle and perform operations.  For extra security this feature should be disabled unless you are really using it.';
+$string['configenablewebservices'] = 'Web services enable other systems, such as the Moodle app, to log in to the site and perform operations. For extra security, the setting should be disabled if you are not using the app, or an external tool/service that requires integration via web services.';
 $string['configenablewsdocumentation'] = 'Enable auto-generation of web services documentation. A user can access to his own documentation on his security keys page {$a}. It displays the documentation for the enabled protocols only.';
 $string['configerrorlevel'] = 'Choose the amount of PHP warnings that you want to be displayed.  Normal is usually the best choice.';
 $string['configexportlookahead'] = 'Days to look ahead during export';
index 3d3c4de..ef6f96b 100644 (file)
@@ -246,7 +246,7 @@ $string['criteria_5_help'] = 'Allows a badge to be awarded to users who have com
 $string['criteria_6'] = 'Profile completion';
 $string['criteria_6_help'] = 'Allows a badge to be awarded to users for completing certain fields in their profile. You can select from default and custom profile fields that are available to users. ';
 $string['criteria_7'] = 'Awarded badges';
-$string['criteria_7_help'] = 'Allows a badge to be awarded to users based on the other badges thay have earned.';
+$string['criteria_7_help'] = 'Allows a badge to be awarded to users based on other badges they have earned.';
 $string['criteria_8'] = 'Cohort membership';
 $string['criteria_8_help'] = 'Allows a badge to be awarded to users based on cohort membership.';
 $string['criteria_9'] = 'Competencies';
index 6822d32..2bb8a14 100644 (file)
@@ -71,6 +71,7 @@ $string['cachedef_portfolio_add_button_portfolio_instances'] = 'Portfolio instan
 $string['cachedef_postprocessedcss'] = 'Post processed CSS';
 $string['cachedef_tagindexbuilder'] = 'Search results for tagged items';
 $string['cachedef_questiondata'] = 'Question definitions';
+$string['cachedef_recommendation_favourite_course_content_items'] = 'Recommendation of course content items';
 $string['cachedef_repositories'] = 'Repositories instances data';
 $string['cachedef_roledefs'] = 'Role definitions';
 $string['cachedef_grade_categories'] = 'Grade category queries';
index a4d8531..ed272bb 100644 (file)
@@ -22,6 +22,7 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['activitychooserrecommendations'] = 'Recommended activities';
 $string['aria:coursecategory'] = 'Course category';
 $string['aria:courseimage'] = 'Course image';
 $string['aria:courseshortname'] = 'Course short name';
@@ -30,6 +31,7 @@ $string['aria:defaulttab'] = 'The default modules';
 $string['aria:favourite'] = 'Course is starred';
 $string['aria:favouritestab'] = 'Your starred modules';
 $string['aria:recommendedtab'] = 'The recommended modules';
+$string['aria:modulefavourite'] = 'Star {$a} module';
 $string['coursealreadyfinished'] = 'Course already finished';
 $string['coursenotyetstarted'] = 'The course has not yet started';
 $string['coursenotyetfinished'] = 'The course has not yet finished';
@@ -46,29 +48,28 @@ $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start
 $string['favourite'] = 'Starred course';
 $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).';
 $string['informationformodule'] = 'Information about the {$a} activity';
+$string['module'] = 'Module';
 $string['nocourseactivity'] = 'Not enough course activity between the start and the end of the course';
 $string['nocourseendtime'] = 'The course does not have an end time';
 $string['nocoursesections'] = 'No course sections';
 $string['nocoursestudents'] = 'No students';
 $string['noaccesssincestartinfomessage'] = 'Hi {$a->userfirstname},
-
-</br><br/>A number of students in {$a->coursename} have never accessed the course.';
+<p>A number of students in {$a->coursename} have never accessed the course.</p>';
 $string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname},
-
-</br><br/>A number of students in {$a->coursename} have not accessed the course recently.';
+<p>A number of students in {$a->coursename} have not accessed the course recently.</p>';
 $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},
-
-</br><br/>Courses with start dates in the next week have been identified as having no teacher or student enrolments.';
+<p>Courses with start dates in the next week have been identified as having no teacher or student enrolments.</p>';
 $string['privacy:perpage'] = 'The number of courses to show per page.';
 $string['privacy:completionpath'] = 'Course completion';
 $string['privacy:favouritespath'] = 'Course starred information';
 $string['privacy:metadata:activityfavouritessummary'] = 'The course system contains information about which items from the activity chooser have been starred by the user.';
 $string['privacy:metadata:completionsummary'] = 'The course contains completion information about the user.';
 $string['privacy:metadata:favouritessummary'] = 'The course contains information relating to the course being starred by the user.';
+$string['recommend'] = 'Recommend';
+$string['recommendcheckbox'] = 'Recommend activity: {$a}';
 $string['studentsatriskincourse'] = 'Students at risk in {$a} course';
 $string['studentsatriskinfomessage'] = 'Hi {$a->userfirstname},
-
-</br><br/>Students in the {$a->coursename} course have been identified as being at risk.';
+<p>Students in the {$a->coursename} course have been identified as being at risk.</p>';
 $string['target:coursecompletion'] = 'Students at risk of not meeting the course completion conditions';
 $string['target:coursecompletion_help'] = 'This target describes whether the student is considered at risk of not meeting the course completion conditions.';
 $string['target:coursecompetencies'] = 'Students at risk of not achieving the competencies assigned to a course';
index febe040..355f523 100644 (file)
@@ -23,6 +23,7 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['actions'] = 'Actions';
 $string['addedandupdatedpp'] = 'Added {$a->%new} new H5P libraries and updated {$a->%old} old ones.';
 $string['addedandupdatedps'] = 'Added {$a->%new} new H5P libraries and updated {$a->%old} old one.';
 $string['addedandupdatedsp'] = 'Added {$a->%new} new H5P library and updated {$a->%old} old ones.';
@@ -67,6 +68,9 @@ $string['couldNotParseJSONFromZip'] = 'Unable to parse JSON from the package: {$
 $string['couldNotReadFileFromZip'] = 'Unable to read file from the package: {$a->%fileName}';
 $string['creativecommons'] = 'Creative Commons';
 $string['date'] = 'Date';
+$string['deletelibraryconfirm'] = '<p>Are you sure you want to delete version <em>\'{$a->version}\'</em> from library <em>\'{$a->name}\'</em>? It will remove the library and all its uses.</p><p>This operation can not be undone.</p>';
+$string['deletelibraryversion'] = 'Delete version';
+$string['deleting'] = 'Deleting a library';
 $string['description'] = 'Description';
 $string['disablefullscreen'] = 'Disable fullscreen';
 $string['download'] = 'Download';
index 9a8250f..36e7b77 100644 (file)
@@ -523,7 +523,7 @@ $string['displayonpage'] = 'Display on page';
 $string['dndcourse'] = 'You can drag and drop this course to alter its sorting or to move it to another category.';
 $string['dndenabled_inbox'] = 'You can drag and drop files here to add them.';
 $string['dndnotsupported'] = 'Drag and drop upload not supported';
-$string['dndnotsupported_help'] = 'Your browser does not support drag and drop upload.<br />This feature is available in all recent versions of Chrome, Firefox and Safari, as well as Internet Explorer v10 and above.';
+$string['dndnotsupported_help'] = 'Your browser does not support drag and drop upload. This feature is available in all recent versions of Chrome, Firefox and Safari, as well as Internet Explorer v10 and above.';
 $string['dndnotsupported_insentence'] = 'drag and drop not supported';
 $string['dnduploadwithoutcontent'] = 'This upload does not have any content';
 $string['dndworkingfiletextlink'] = 'Drag and drop files, text or links onto course sections to upload them';
@@ -1263,14 +1263,18 @@ $string['moddoesnotsupporttype'] = 'Module {$a->modname} does not support upload
 $string['modhide'] = 'Hide';
 $string['modshow'] = 'Show';
 $string['modvisible'] = 'Availability';
-$string['modvisible_help'] = 'If the availability is set to \'Show on course page\', the activity or resource is available to students (subject to any access restrictions which may be set).<br><br>
+$string['modvisible_help'] = 'If the availability is set to \'Show on course page\', the activity or resource is available to students (subject to any access restrictions which may be set).
+
 If the availability is set to \'Hide from students\', the activity or resource is only available to users with permission to view hidden activities (by default, users with the role of teacher or non-editing teacher).';
 $string['modvisiblewithstealth'] = 'Availability';
-$string['modvisiblewithstealth_help'] = 'If the availability is set to \'Show on course page\', the activity or resource is available to students (subject to any access restrictions which may be set).<br><br>
-If the availability is set to \'Hide from students\', the activity or resource is only available to users with permission to view hidden activities (by default, users with the role of teacher or non-editing teacher).<br><br>
+$string['modvisiblewithstealth_help'] = 'If the availability is set to \'Show on course page\', the activity or resource is available to students (subject to any access restrictions which may be set).
+
+If the availability is set to \'Hide from students\', the activity or resource is only available to users with permission to view hidden activities (by default, users with the role of teacher or non-editing teacher).
+
 If the course contains many activities or resources, the course page may be simplified by setting the availability to \'Make available but not shown on course page\'. In this case, a link to the activity or resource must be provided from elsewhere, such as from a page resource. The activity would still be listed in the gradebook and other reports.';
 $string['modvisiblehiddensection'] = 'Availability';
-$string['modvisiblehiddensection_help'] = 'If the availability is set to \'Hide from students\', the activity or resource is only available to users with permission to view hidden activities (by default, users with the role of teacher or non-editing teacher).<br><br>
+$string['modvisiblehiddensection_help'] = 'If the availability is set to \'Hide from students\', the activity or resource is only available to users with permission to view hidden activities (by default, users with the role of teacher or non-editing teacher).
+
 If the course contains many activities or resources, the course page may be simplified by setting the availability to \'Make available but not shown on course page\'. In this case, a link to the activity or resource must be provided from elsewhere, such as from a page resource. The activity would still be listed in the gradebook and other reports.';
 $string['moodlelogo'] = 'Moodle logo';
 $string['month'] = 'Month';
@@ -1670,7 +1674,11 @@ $string['registrationyes'] = 'Yes, notify me of new Moodle releases, security al
 $string['reject'] = 'Reject';
 $string['rejectdots'] = 'Reject...';
 $string['relativedatesmode'] = 'Relative dates mode';
-$string['relativedatesmode_help'] = 'Display course or activity dates relative to the user\'s start date in the course.<br />The user\'s course start date will be their enrolment start date, unless they are enrolled before the course begins in which case their start date will be the course start date.<br/><strong>WARNING: This is an experimental feature and not all activities may support it. Once the course has been created, this course setting can no longer be changed.</strong>';
+$string['relativedatesmode_help'] = 'Display course or activity dates relative to the user\'s start date in the course.
+
+The user\'s course start date will be their enrolment start date, unless they are enrolled before the course begins in which case their start date will be the course start date.
+
+WARNING: This is an experimental feature and not all activities may support it. Once the course has been created, this course setting can no longer be changed.';
 $string['relativedatesmode_warning'] = '<strong>Warning:</strong> Relative dates mode cannot be changed once the course has been created.';
 $string['reload'] = 'Reload';
 $string['remoteappuser'] = 'Remote {$a} User';
index 96d071a..0ae3f80 100644 (file)
@@ -179,6 +179,7 @@ $string['course:markcomplete'] = 'Mark users as complete in course completion';
 $string['course:movesections'] = 'Move sections';
 $string['course:overridecompletion'] = 'Override activity completion status';
 $string['course:renameroles'] = 'Rename roles';
+$string['course:recommendactivity'] = 'Recommend activities to the activity chooser';
 $string['course:request'] = 'Request new courses';
 $string['course:reset'] = 'Reset course';
 $string['course:reviewotherusers'] = 'Review other users';
index 73145a2..8e8cb1d 100644 (file)
@@ -25,7 +25,7 @@
 $string['antivirusfailed'] = 'There is a problem with AntiVirus scanning at the moment. Your file {$a->item} has not been uploaded. Please try again later.';
 $string['configclamactlikevirus'] = 'Treat files like viruses';
 $string['configclamdonothing'] = 'Treat files as OK';
-$string['configclamfailureonupload'] = 'If you have configured clam to scan uploaded files, but it is configured incorrectly or fails to run for some unknown reason, how should it behave?  If you choose \'Treat files like viruses\', they\'ll be moved into the quarantine area, or deleted. If you choose \'Treat files as OK\', the files will be moved to the destination directory like normal. If you choose \'Refuse upload, try again\' (useful if failures occur during regular virus updating periods) a try again later message will be displayed to the user. Either way, admins will be alerted that clam has failed.  If you choose \'Treat files like viruses\' and for some reason clam fails to run (usually because you have entered an invalid pathtoclam), ALL files that are uploaded will be moved to the given quarantine area, or deleted. Be careful with this setting.';
+$string['configclamfailureonupload'] = 'If \'Treat files as OK\' is selected, files will be moved to the destination directory. If \'Refuse upload, try again\' is selected, the user will be prompted to try again later. If \'Treat files like viruses\' is selected, files will be moved into the quarantine area, or deleted. Warning: With this option, if for some reason clam fails to run (usually because of an invalid pathtoclam), then ALL uploaded files will be moved to the given quarantine area, or deleted.';
 $string['configclamtryagain'] = 'Refuse upload, try again';
 $string['clamfailed'] = 'ClamAV has failed to run.  The return error message was "{$a}". Here is the output from ClamAV:';
 $string['clamfailureonupload'] = 'On ClamAV failure';
index 940d567..6d58efa 100644 (file)
@@ -871,7 +871,7 @@ function badges_get_default_issuer() {
     global $CFG, $SITE;
 
     $issuer = array();
-    $issuerurl = new moodle_url('/badges/issuer.php');
+    $issuerurl = new moodle_url('/');
     $issuer['name'] = $CFG->badges_defaultissuername;
     if (empty($issuer['name'])) {
         $issuer['name'] = $SITE->fullname ? $SITE->fullname : $SITE->shortname;
@@ -879,7 +879,8 @@ function badges_get_default_issuer() {
     $issuer['url'] = $issuerurl->out(false);
     $issuer['email'] = $CFG->badges_defaultissuercontact;
     $issuer['@context'] = OPEN_BADGES_V2_CONTEXT;
-    $issuer['id'] = $issuerurl->out(false);
+    $issuerid = new moodle_url('/badges/issuer_json.php');
+    $issuer['id'] = $issuerid->out(false);
     $issuer['type'] = OPEN_BADGES_V2_TYPE_ISSUER;
     return $issuer;
 }
index c9dca91..74fab98 100644 (file)
@@ -320,6 +320,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/show' => 'fa-eye-slash',
             'core:i/siteevent' => 'fa-globe',
             'core:i/star' => 'fa-star',
+            'core:i/star-o' => 'fa-star-o',
             'core:i/star-rating' => 'fa-star',
             'core:i/stats' => 'fa-line-chart',
             'core:i/switch' => 'fa-exchange',
index e0ab0e4..c7f5033 100644 (file)
@@ -561,7 +561,7 @@ class manager {
 
         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
         $params = array('timestart1' => $timestart);
-        $records = $DB->get_records_select('task_adhoc', $where, $params);
+        $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC');
 
         $records = self::ensure_adhoc_task_qos($records);
 
index 8e33d64..95e1d4f 100644 (file)
@@ -2471,4 +2471,13 @@ $capabilities = array(
             'manager' => CAP_ALLOW,
         ]
     ],
+
+    // Allow users to recommend activities in the activity chooser.
+    'moodle/course:recommendactivity' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+        ]
+    ]
 );
index 2fd4a36..ce4c641 100644 (file)
@@ -427,4 +427,9 @@ $definitions = array(
         'mode' => cache_store::MODE_APPLICATION,
         'simplekeys' => true,
     ],
+
+    \core_course\local\service\content_item_service::RECOMMENDATION_CACHE => [
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+    ],
 );
index 3579809..9442f9c 100644 (file)
@@ -653,6 +653,14 @@ $functions = array(
         'type' => 'read',
         'ajax' => true,
     ),
+    'core_course_toggle_activity_recommendation' => array(
+        'classname' => 'core_course_external',
+        'methodname' => 'toggle_activity_recommendation',
+        'classpath' => 'course/externallib.php',
+        'description' => 'Adds or removes an activity as a recommendation in the activity chooser.',
+        'type' => 'write',
+        'ajax' => true,
+    ),
     'core_enrol_get_course_enrolment_methods' => array(
         'classname' => 'core_enrol_external',
         'methodname' => 'get_course_enrolment_methods',
index de135c8..6ce9568 100644 (file)
@@ -2106,9 +2106,6 @@ function xmldb_main_upgrade($oldversion) {
     // Put any upgrade step following this.
 
     if ($oldversion < 2019120500.01) {
-        // Delete any tool_cohortroles mappings for roles which no longer exist.
-        $DB->delete_records_select('tool_cohortroles', "roleid NOT IN (SELECT id FROM {role})");
-
         // Delete any role assignments for roles which no longer exist.
         $DB->delete_records_select('role_assignments', "roleid NOT IN (SELECT id FROM {role})");
 
index f7394d0..e6d5e99 100644 (file)
@@ -773,7 +773,8 @@ function external_generate_token($tokentype, $serviceorid, $userid, $contextorid
     if (!empty($iprestriction)) {
         $newtoken->iprestriction = $iprestriction;
     }
-    $newtoken->privatetoken = null;
+    // Generate the private token, it must be transmitted only via https.
+    $newtoken->privatetoken = random_string(64);
     $DB->insert_record('external_tokens', $newtoken);
     return $newtoken->token;
 }
index 9cb8385..f67c71c 100644 (file)
@@ -1732,9 +1732,13 @@ class grade_item extends grade_object {
      * @param string $feedback Optional teacher feedback
      * @param int $feedbackformat A format like FORMAT_PLAIN or FORMAT_HTML
      * @param int $usermodified The ID of the user making the modification
+     * @param int $timemodified Optional parameter to set the time modified, if not present current time.
      * @return bool success
      */
-    public function update_final_grade($userid, $finalgrade=false, $source=NULL, $feedback=false, $feedbackformat=FORMAT_MOODLE, $usermodified=null) {
+    public function update_final_grade($userid, $finalgrade = false,
+                                       $source = null, $feedback = false,
+                                       $feedbackformat = FORMAT_MOODLE,
+                                       $usermodified = null, $timemodified = null) {
         global $USER, $CFG;
 
         $result = true;
@@ -1800,8 +1804,8 @@ class grade_item extends grade_object {
 
         $gradechanged = false;
         if (empty($grade->id)) {
-            $grade->timecreated  = null;   // hack alert - date submitted - no submission yet
-            $grade->timemodified = time(); // hack alert - date graded
+            $grade->timecreated = null;   // Hack alert - date submitted - no submission yet.
+            $grade->timemodified = $timemodified ?? time(); // Hack alert - date graded.
             $result = (bool)$grade->insert($source);
 
             // If the grade insert was successful and the final grade was not null then trigger a user_graded event.
@@ -1825,7 +1829,7 @@ class grade_item extends grade_object {
                 return $result;
             }
 
-            $grade->timemodified = time(); // hack alert - date graded
+            $grade->timemodified = $timemodified ?? time(); // Hack alert - date graded.
             $result = $grade->update($source);
 
             // If the grade update was successful and the actual grade has changed then trigger a user_graded event.
index 078190b..91852d0 100644 (file)
@@ -5043,16 +5043,16 @@ function check_password_policy($password, &$errmsg, $user = null) {
         if (!check_consecutive_identical_characters($password, $CFG->maxconsecutiveidentchars)) {
             $errmsg .= '<div>'. get_string('errormaxconsecutiveidentchars', 'auth', $CFG->maxconsecutiveidentchars) .'</div>';
         }
-    }
 
-    // Fire any additional password policy functions from plugins.
-    // Plugin functions should output an error message string or empty string for success.
-    $pluginsfunction = get_plugins_with_function('check_password_policy');
-    foreach ($pluginsfunction as $plugintype => $plugins) {
-        foreach ($plugins as $pluginfunction) {
-            $pluginerr = $pluginfunction($password, $user);
-            if ($pluginerr) {
-                $errmsg .= '<div>'. $pluginerr .'</div>';
+        // Fire any additional password policy functions from plugins.
+        // Plugin functions should output an error message string or empty string for success.
+        $pluginsfunction = get_plugins_with_function('check_password_policy');
+        foreach ($pluginsfunction as $plugintype => $plugins) {
+            foreach ($plugins as $pluginfunction) {
+                $pluginerr = $pluginfunction($password, $user);
+                if ($pluginerr) {
+                    $errmsg .= '<div>'. $pluginerr .'</div>';
+                }
             }
         }
     }
index 56a1110..d50d022 100644 (file)
@@ -961,12 +961,31 @@ class theme_config {
      */
     public function editor_scss_to_css() {
         $css = '';
+        $dir = $this->dir;
+        $filenames = [];
 
+        // Use editor_scss file(s) provided by this theme if set.
         if (!empty($this->editor_scss)) {
+            $filenames = $this->editor_scss;
+        } else {
+            // If no editor_scss set, move up theme hierarchy until one is found (if at all).
+            // This is so child themes only need to set editor_scss if an override is required.
+            foreach (array_reverse($this->parent_configs) as $parentconfig) {
+                if (!empty($parentconfig->editor_scss)) {
+                    $dir = $parentconfig->dir;
+                    $filenames = $parentconfig->editor_scss;
+
+                    // Config found, stop looking.
+                    break;
+                }
+            }
+        }
+
+        if (!empty($filenames)) {
             $compiler = new core_scss();
 
-            foreach ($this->editor_scss as $filename) {
-                $compiler->set_file("{$this->dir}/scss/{$filename}.scss");
+            foreach ($filenames as $filename) {
+                $compiler->set_file("{$dir}/scss/{$filename}.scss");
 
                 try {
                     $css .= $compiler->to_css();
index 037ec67..5f90694 100644 (file)
@@ -1745,7 +1745,7 @@ class core_renderer extends renderer_base {
         $id = !empty($bc->attributes['id']) ? $bc->attributes['id'] : uniqid('block-');
         $context = new stdClass();
         $context->skipid = $bc->skipid;
-        $context->blockinstanceid = $bc->blockinstanceid;
+        $context->blockinstanceid = $bc->blockinstanceid ?: uniqid('fakeid-');
         $context->dockable = $bc->dockable;
         $context->id = $id;
         $context->hidden = $bc->collapsible == block_contents::HIDDEN;
index a287f1a..0c1c321 100644 (file)
@@ -78,7 +78,7 @@
     }
 }}
 <div class="dropdown{{^secondary.items}} hidden{{/secondary.items}}">
-    <a href="#" tabindex="0" class="{{triggerextraclasses}} dropdown-toggle icon-no-margin" id="dropdown-{{instance}}" aria-label="{{title}}" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" aria-controls="action-menu-{{instance}}-menu">
+    <a href="#" tabindex="0" class="{{triggerextraclasses}} dropdown-toggle icon-no-margin" id="action-menu-toggle-{{instance}}" aria-label="{{title}}" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" aria-controls="action-menu-{{instance}}-menu">
         {{{actiontext}}}
         {{{menutrigger}}}
             {{#icon}}
index a08aeed..c2f1481 100644 (file)
@@ -1,5 +1,5 @@
 {{!
-    @template theme_boost/block
+    @template core/block
 
     Example context (json):
     {
index f90f238..b0fdbb5 100644 (file)
@@ -396,4 +396,50 @@ class core_adhoc_task_testcase extends advanced_testcase {
         $concurrencylimit = $task->get_concurrency_limit();
         $this->assertEquals(5, $concurrencylimit);
     }
+
+    /**
+     * Test adhoc task sorting.
+     */
+    public function test_get_next_adhoc_task_sorting() {
+        $this->resetAfterTest(true);
+
+        // Create adhoc tasks.
+        $task1 = new \core\task\adhoc_test_task();
+        $task1->set_next_run_time(1510000000);
+        $task1->set_custom_data_as_string('Task 1');
+        \core\task\manager::queue_adhoc_task($task1);
+
+        $task2 = new \core\task\adhoc_test_task();
+        $task2->set_next_run_time(1520000000);
+        $task2->set_custom_data_as_string('Task 2');
+        \core\task\manager::queue_adhoc_task($task2);
+
+        $task3 = new \core\task\adhoc_test_task();
+        $task3->set_next_run_time(1520000000);
+        $task3->set_custom_data_as_string('Task 3');
+        \core\task\manager::queue_adhoc_task($task3);
+
+        // Shuffle tasks.
+        $task1->set_next_run_time(1540000000);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task1);
+
+        $task3->set_next_run_time(1530000000);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task3);
+
+        $task2->set_next_run_time(1530000000);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task2);
+
+        // Confirm, that tasks are sorted by nextruntime and then by id (ascending).
+        $task = \core\task\manager::get_next_adhoc_task(time());
+        $this->assertEquals('Task 2', $task->get_custom_data_as_string());
+        \core\task\manager::adhoc_task_complete($task);
+
+        $task = \core\task\manager::get_next_adhoc_task(time());
+        $this->assertEquals('Task 3', $task->get_custom_data_as_string());
+        \core\task\manager::adhoc_task_complete($task);
+
+        $task = \core\task\manager::get_next_adhoc_task(time());
+        $this->assertEquals('Task 1', $task->get_custom_data_as_string());
+        \core\task\manager::adhoc_task_complete($task);
+    }
 }
index 888a157..0112157 100644 (file)
@@ -72,7 +72,7 @@
         if (window.M.util.pending_js.length === 1) {
             runAfterEverything(function() {
                 // Check there isn't a spinner...
-                updateSpinner();
+                checkUIBlocked();
 
                 // Only remove it if the pending array is STILL empty after all that.
                 if (window.M.util.pending_js.length === 1) {
         return realOpen.apply(this, arguments);
     };
 
-    var waitingSpinner = false;
+    var waitingBlocked = false;
 
     /**
      * Checks if a loading spinner is present and visible; if so, adds it to the pending array
      * (and if not, removes it).
      */
-    var updateSpinner = function() {
-        var spinner = document.querySelector('span.core-loading-spinner');
-        if (spinner && spinner.offsetParent) {
-            if (!waitingSpinner) {
-                addPending('spinner');
-                waitingSpinner = true;
+    var checkUIBlocked = function() {
+        var blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active');
+        if (blocked && blocked.offsetParent) {
+            if (!waitingBlocked) {
+                addPending('blocked');
+                waitingBlocked = true;
             }
         } else {
-            if (waitingSpinner) {
-                removePending('spinner');
-                waitingSpinner = false;
+            if (waitingBlocked) {
+                removePending('blocked');
+                waitingBlocked = false;
             }
         }
     };
             setTimeout(pollRecentMutation, 500);
         }
         // Also update the spinner presence if needed.
-        updateSpinner();
+        checkUIBlocked();
     };
 
     // Set listener using the mutation callback.
      * @param {function} process Callback function that handles each matched node
      */
     var findPossibleMatches = function(xpath, process) {
-        var matches = document.evaluate(xpath, document);
+        var select = 'ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html';
+        var parent = document.querySelector(select);
+        var matches = document.evaluate(xpath, parent || document);
         while (true) {
             var match = matches.iterateNext();
             if (!match) {
index 4996b27..f82ce5d 100644 (file)
@@ -294,7 +294,7 @@ class behat_app extends behat_base {
                         }
                     }
                     throw new DriverException('Moodle app not found in browser');
-                }, false, 30);
+                }, false, 60);
 
         // Run the scripts to install Moodle 'pending' checks.
         $this->getSession()->executeScript(
@@ -312,7 +312,7 @@ class behat_app extends behat_base {
                         return 'mainpage';
                     }
                     throw new DriverException('Moodle app login URL prompt not found');
-                }, behat_base::get_extended_timeout(), 30);
+                }, behat_base::get_extended_timeout(), 60);
 
         // If it's the login page, we automatically fill in the URL and leave it on the user/pass
         // page. If it's the main page, we just leave it there.
index 5276079..002ab61 100644 (file)
@@ -861,7 +861,7 @@ class behat_navigation extends behat_base {
         $linkname = behat_context_helper::escape($lastnode);
         $xpath .= '//a[contains(normalize-space(.), ' . $linkname . ')]';
         if (!$node = $this->getSession()->getPage()->find('xpath', $xpath)) {
-            throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"" not found on the page');
+            throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"');
         }
         $node->click();
         $this->wait_for_pending_js();
@@ -876,7 +876,7 @@ class behat_navigation extends behat_base {
     protected function find_header_administration_menu($mustexist = false) {
         $menuxpath = '//header[@id=\'page-header\']//div[contains(@class,\'moodle-actionmenu\')]';
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page header administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page header administration menu');
             $this->find('xpath', $menuxpath, $exception);
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
             return null;
@@ -893,7 +893,7 @@ class behat_navigation extends behat_base {
     protected function find_page_administration_menu($mustexist = false) {
         $menuxpath = '//div[@id=\'region-main-settings-menu\']';
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu');
             $this->find('xpath', $menuxpath, $exception);
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
             return null;
@@ -960,6 +960,6 @@ class behat_navigation extends behat_base {
         }
 
         throw new ElementNotFoundException($this->getSession(),
-                'Link "' . join(' > ', $nodelist) . '" not found in the current page edit menu"');
+                'Link "' . join(' > ', $nodelist) . '" in the current page edit menu"');
     }
 }
index 85945c4..35f3df7 100644 (file)
@@ -175,4 +175,42 @@ class core_theme_config_testcase extends advanced_testcase {
 
         $this->assertRegExp("/{$themerevision}_{$themesubrevision}/", $url->out(false));
     }
+
+    /**
+     * Confirm that editor_scss_to_css is correctly compiling for themes with no parent.
+     */
+    public function test_editor_scss_to_css_root_theme() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $theme = theme_config::load('boost');
+        $editorscss = $CFG->dirroot.'/theme/boost/scss/editor.scss';
+
+        $this->assertTrue(file_exists($editorscss));
+        $compiler = new core_scss();
+        $compiler->set_file($editorscss);
+        $cssexpected = $compiler->to_css();
+        $cssactual = $theme->editor_scss_to_css();
+
+        $this->assertEquals($cssexpected, $cssactual);
+    }
+
+    /**
+     * Confirm that editor_scss_to_css is compiling for a child theme not overriding its parent's editor SCSS.
+     */
+    public function test_editor_scss_to_css_child_theme() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $theme = theme_config::load('classic');
+        $editorscss = $CFG->dirroot.'/theme/boost/scss/editor.scss';
+
+        $this->assertTrue(file_exists($editorscss));
+        $compiler = new core_scss();
+        $compiler->set_file($editorscss);
+        $cssexpected = $compiler->to_css();
+        $cssactual = $theme->editor_scss_to_css();
+
+        $this->assertEquals($cssexpected, $cssactual);
+    }
 }
index 58a2ef1..3ae720c 100644 (file)
@@ -32,6 +32,8 @@ information provided here is intended especially for developers.
 * The database drivers (moodle_database and subclasses) don't need to implement get_columns() anymore.
   They have to implement fetch_columns instead.
 * Added function cleanup_after_drop to the database_manager class to take care of all the cleanups that need to be done after a table is dropped.
+* The 'xxxx_check_password_policy' callback now only fires if $CFG->passwordpolicy is true
+* grade_item::update_final_grade() can now take an optional parameter to set the grade->timemodified. If not present the current time will carry on being used.
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index 2157c18..b603cf2 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_constants.min.js and b/message/amd/build/message_drawer_view_conversation_constants.min.js differ
index c63609d..3a66da7 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_constants.min.js.map and b/message/amd/build/message_drawer_view_conversation_constants.min.js.map differ
index b24dd69..6d35cae 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js and b/message/amd/build/message_drawer_view_conversation_renderer.min.js differ
index aeb811f..6bd9db1 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js.map and b/message/amd/build/message_drawer_view_conversation_renderer.min.js.map differ
index e22d008..d1023f8 100644 (file)
@@ -47,6 +47,7 @@ define([], function() {
         ACTION_VIEW_CONTACT: '[data-action="view-contact"]',
         ACTION_VIEW_GROUP_INFO: '[data-action="view-group-info"]',
         CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
+        CONFIRM_DIALOGUE: '[data-region="confirm-dialogue"]',
         CONFIRM_DIALOGUE_BUTTON_TEXT: '[data-region="dialogue-button-text"]',
         CONFIRM_DIALOGUE_CANCEL_BUTTON: '[data-action="cancel-confirm"]',
         CONFIRM_DIALOGUE_CONTAINER: '[data-region="confirm-dialogue-container"]',
index dbbb871..3f24f26 100644 (file)
@@ -1079,11 +1079,15 @@ function(
         }
 
         if (headerText) {
-            dialogueHeader.removeClass('hidden');
+            // Create the dialogue header.
+            dialogueHeader = $('<h3 class="h6" data-region="dialogue-header"></h3>');
             dialogueHeader.text(headerText);
-        } else {
-            dialogueHeader.addClass('hidden');
-            dialogueHeader.text('');
+            // Prepend it to the confirmation body.
+            var confirmDialogue = dialogue.find(SELECTORS.CONFIRM_DIALOGUE);
+            confirmDialogue.prepend(dialogueHeader);
+        } else if (dialogueHeader.length) {
+            // Header text is empty but dialogue header is present, so remove it.
+            dialogueHeader.remove();
         }
 
         buttons.forEach(function(button) {
@@ -1123,8 +1127,11 @@ function(
         cancelButton.removeClass('hidden');
         okayButton.removeClass('hidden');
         text.text('');
-        dialogueHeader.addClass('hidden');
-        dialogueHeader.text('');
+
+        // Remove dialogue header if present.
+        if (dialogueHeader.length) {
+            dialogueHeader.remove();
+        }
 
         header.find(SELECTORS.CAN_RECEIVE_FOCUS).first().focus();
         return true;
index 004185a..df9dfc1 100644 (file)
@@ -102,6 +102,9 @@ class message_output_airnotifier extends message_output {
             $extra->fullmessagehtml = clean_param($extra->fullmessagehtml, PARAM_NOTAGS);
         }
 
+        // Send wwwroot to airnotifier.
+        $extra->wwwroot = $CFG->wwwroot;
+
         // We are sending to message to all devices.
         $airnotifiermanager = new message_airnotifier_manager();
         $devicetokens = $airnotifiermanager->get_user_devices($CFG->airnotifiermobileappname, $eventdata->userto->id);
index 085ba14..f591b5e 100644 (file)
@@ -36,7 +36,6 @@
 }}
 
 <div class="p-3 bg-white" data-region="confirm-dialogue" role="alert">
-    <h3 class="h6 hidden" data-region="dialogue-header"></h3>
     <p class="text-muted" data-region="dialogue-text"></p>
     <div class="mb-2 custom-control custom-checkbox hidden" data-region="delete-messages-for-all-users-toggle-container">
         <input type="checkbox" class="custom-control-input" id="delete-messages-for-all-users" data-region="delete-messages-for-all-users-toggle">
index 6c477b0..a3fa5d0 100644 (file)
@@ -59,9 +59,7 @@ $string['privacy:metadata'] = 'The Folder resource plugin does not store any per
 $string['pluginadministration'] = 'Folder administration';
 $string['pluginname'] = 'Folder';
 $string['display'] = 'Display folder contents';
-$string['display_help'] = 'If you choose to display the folder contents on a course page, there  will be no link to a separate page.
-The description will be displayed only if "Display description on course page" is checked.<br />
-Also note that participants view actions can not be logged in this case.';
+$string['display_help'] = 'If you choose to display the folder contents on a course page, there will be no link to a separate page. The description will be displayed only if \'Display description on course page\' is ticked. Note that participants view actions cannot be logged in this case.';
 $string['displaypage'] = 'On a separate page';
 $string['displayinline'] = 'Inline on a course page';
 $string['noautocompletioninline'] = 'Automatic completion on viewing of activity can not be selected together with "Display inline" option';
index 7a018af..9ae7eb2 100644 (file)
@@ -54,9 +54,12 @@ class refresh_forum_post_counts extends \core\task\adhoc_task {
     protected function update_null_forum_post_counts(): bool {
         global $CFG, $DB;
 
-        // Default to chunks of 5000 records per run, unless overridden in config.php
+        // Default to chunks of 5000 records per run, unless overridden in config.php.
         $chunksize = $CFG->forumpostcountchunksize ?? 5000;
 
+        // Initialize counter.
+        $recordscount = 0;
+
         $select = 'wordcount IS NULL OR charcount IS NULL';
         $recordset = $DB->get_recordset_select('forum_posts', $select, null, 'discussion', 'id, message', 0, $chunksize);
 
@@ -68,9 +71,9 @@ class refresh_forum_post_counts extends \core\task\adhoc_task {
         foreach ($recordset as $record) {
             \mod_forum\local\entities\post::add_message_counts($record);
             $DB->update_record('forum_posts', $record);
+            $recordscount++;
         }
 
-        $recordscount = count($recordset);
         $recordset->close();
 
         return ($recordscount == $chunksize);
index 725c1ab..360c1d5 100644 (file)
@@ -117,9 +117,11 @@ if ($form->is_cancelled()) {
     $striphtml = !empty($data->striphtml);
     $humandates = !empty($data->humandates);
 
-    $fields = ['id', 'discussion', 'parent', 'userid', 'created', 'modified', 'mailed', 'subject', 'message',
+    $fields = ['id', 'discussion', 'parent', 'userid', 'userfullname', 'created', 'modified', 'mailed', 'subject', 'message',
                 'messageformat', 'messagetrust', 'attachment', 'totalscore', 'mailnow', 'deleted', 'privatereplyto',
-                'wordcount', 'charcount'];
+                'privatereplytofullname', 'wordcount', 'charcount'];
+
+    $canviewfullname = has_capability('moodle/site:viewfullnames', $forum->get_context());
 
     $datamapper = $legacydatamapperfactory->get_post_data_mapper();
     $exportdata = new ArrayObject($datamapper->to_legacy_objects($posts));
@@ -132,8 +134,29 @@ if ($form->is_cancelled()) {
         $dataformat,
         $fields,
         $iterator,
-        function($exportdata) use ($fields, $striphtml, $humandates) {
-            $data = $exportdata;
+        function($exportdata) use ($fields, $striphtml, $humandates, $canviewfullname) {
+            $data = new stdClass();
+
+            foreach ($fields as $field) {
+                // Set data field's value from the export data's equivalent field by default.
+                $data->$field = $exportdata->$field ?? null;
+
+                if ($field == 'userfullname') {
+                    $user = \core_user::get_user($data->userid);
+                    $data->userfullname = fullname($user, $canviewfullname);
+                }
+
+                if ($field == 'privatereplytofullname' && !empty($data->privatereplyto)) {
+                    $user = \core_user::get_user($data->privatereplyto);
+                    $data->privatereplytofullname = fullname($user, $canviewfullname);
+                }
+
+                // Convert any boolean fields to their integer equivalent for output.
+                if (is_bool($data->$field)) {
+                    $data->$field = (int) $data->$field;
+                }
+            }
+
             if ($striphtml) {
                 // The following call to html_to_text uses the option that strips out
                 // all URLs, but format_text complains if it finds @@PLUGINFILE@@ tokens.
@@ -147,12 +170,6 @@ if ($form->is_cancelled()) {
                 $data->created = userdate($data->created);
                 $data->modified = userdate($data->modified);
             }
-            foreach ($fields as $field) {
-                // Convert any boolean fields to their integer equivalent for output.
-                if (is_bool($data->$field)) {
-                    $data->$field = (int) $data->$field;
-                }
-            }
             return $data;
         });
     die;
index efa63f3..ae4692a 100644 (file)
@@ -434,7 +434,7 @@ $string['more'] = 'more';
 $string['movedmarker'] = '(Moved)';
 $string['movethisdiscussionlabel'] = 'Move the current discussion to the specified forum';
 $string['movethisdiscussionto'] = 'Move this discussion to ...';
-$string['mustprovidediscussionorpost'] = 'You must provide either a discussion id or post id to export';
+$string['mustprovidediscussionorpost'] = 'You must provide either a discussion ID or post ID to export.';
 $string['myprofileownpost'] = 'My forum posts';
 $string['myprofileowndis'] = 'My forum discussions';
 $string['myprofileotherdis'] = 'Forum discussions';
index 4618aa1..7afbbe6 100644 (file)
@@ -37,8 +37,8 @@ $jwk = array();
 $jwk['kty'] = 'RSA';
 $jwk['alg'] = 'RS256';
 $jwk['kid'] = get_config('mod_lti', 'kid');
-$jwk['e'] = strtr(base64_encode($details['rsa']['e']), '+/', '-_');
-$jwk['n'] = strtr(base64_encode($details['rsa']['n']), '+/', '-_');
+$jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
+$jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
 $jwk['use'] = 'sig';
 
 $jwks['keys'][] = $jwk;
index 2a7c001..c73df89 100644 (file)
@@ -381,25 +381,20 @@ class gradebookservices extends service_base {
             $feedbackformat = FORMAT_PLAIN;
         }
 
-        if (!$grade = \grade_grade::fetch(array('itemid' => $gradeitem->id, 'userid' => $userid))) {
-            $grade = new \grade_grade();
-            $grade->userid = $userid;
-            $grade->itemid = $gradeitem->id;
-        }
-        $grade->rawgrademax = $score->scoreMaximum;
-        $grade->timemodified = $timemodified;
-        $grade->feedbackformat = $feedbackformat;
-        $grade->feedback = $feedback;
         if ($gradeitem->is_manual_item()) {
-            $grade->finalgrade = $finalgrade;
-            if (empty($grade->id)) {
-                $result = (bool)$grade->insert($source);
-            } else {
-                $result = $grade->update($source);
-            }
+            $result = $gradeitem->update_final_grade($userid, $finalgrade, null, $feedback, FORMAT_PLAIN, null, $timemodified);
         } else {
+            if (!$grade = \grade_grade::fetch(array('itemid' => $gradeitem->id, 'userid' => $userid))) {
+                $grade = new \grade_grade();
+                $grade->userid = $userid;
+                $grade->itemid = $gradeitem->id;
+            }
+            $grade->rawgrademax = $score->scoreMaximum;
+            $grade->timemodified = $timemodified;
+            $grade->feedbackformat = $feedbackformat;
+            $grade->feedback = $feedback;
             $grade->rawgrade = $finalgrade;
-            $status = \grade_update($source, $gradeitem->courseid,
+            $status = grade_update($source, $gradeitem->courseid,
                          $gradeitem->itemtype, $gradeitem->itemmodule,
                          $gradeitem->iteminstance, $gradeitem->itemnumber,
                          $grade);
index 5efa8eb..5b164e1 100644 (file)
@@ -432,7 +432,7 @@ $string['importingquestions'] = 'Importing {$a} questions from file';
 $string['importmaxerror'] = 'There is an error in the question. There are too many answers.';
 $string['importmax10error'] = 'There is an error in the question. You may not have more than ten answers';
 $string['importquestions'] = 'Import questions from file';
-$string['inactiveoverridehelp'] = '* Student does not have the correct group or role to attempt the quiz';
+$string['inactiveoverridehelp'] = '* User doesn\'t have the correct group or role to attempt the quiz, or the quiz is hidden.';
 $string['incorrect'] = 'Incorrect';
 $string['indicator:cognitivedepth'] = 'Quiz cognitive';
 $string['indicator:cognitivedepth_help'] = 'This indicator is based on the cognitive depth reached by the student in a Quiz activity.';
index e83a214..cca3992 100644 (file)
@@ -37,7 +37,8 @@ $string['no_right_issuers'] = 'None of the existing issuers implement all requir
 $string['chooseissuer'] = 'Issuer';
 $string['chooseissuer_help'] = 'To add a new issuer, go to Site administration / Server / OAuth 2 services.';
 $string['foldername'] = 'Name of folder created in Nextcloud users\' private space that holds all access-controlled links.';
-$string['foldername_help'] = 'To ensure that users find files shared with them, shares are saved into a specific folder. <br>
+$string['foldername_help'] = 'To ensure that users find files shared with them, shares are saved into a specific folder.
+
 This setting determines the name of the folder. It is recommended to choose a name associated with your Moodle instance.';
 $string['oauth2serviceslink'] = '<a href="{$a}" title="Link to OAuth 2 services configuration">OAuth 2 services configuration</a>';
 $string['privacy:metadata'] = 'The Nextcloud repository plugin neither stores any personal data nor transmits user data to the remote system.';
index a2faa9d..f7469f2 100644 (file)
Binary files a/theme/boost/amd/build/drawer.min.js and b/theme/boost/amd/build/drawer.min.js differ
index 52c8504..287b576 100644 (file)
Binary files a/theme/boost/amd/build/drawer.min.js.map and b/theme/boost/amd/build/drawer.min.js.map differ
index 55b1db1..45b0391 100644 (file)
@@ -128,8 +128,9 @@ define(['jquery', 'core/custom_interaction_events', 'core/log', 'core/pubsub'],
             // Close.
             body.removeClass('drawer-open-' + side);
             trigger.attr('aria-expanded', 'false');
-            drawer.attr('aria-hidden', 'true');
-            drawer.addClass('closed');
+            drawer.addClass('closed').delay(500).queue(function() {
+                $(this).attr('aria-hidden', 'true').dequeue();
+            });
             if (!small) {
                 M.util.set_user_preference(preference, 'false');
             }
index 57c0b19..54f5e9d 100644 (file)
@@ -564,7 +564,7 @@ span.editinstructions {
 
 #coursesearch {
     margin-top: 1em;
-    text-align: center;
+    text-align: left;
 }
 
 #page-course-pending .pendingcourserequests {
index c261e05..c75703d 100644 (file)
@@ -24,6 +24,10 @@ $drawer-offscreen-gutter: 20px !default;
     left: -($drawer-width + $drawer-offscreen-gutter);
 }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+    display: none;
+}
+
 /* Use a variable for the drawer background colors. */
 $drawer-bg: darken($body-bg, 5%) !default;
 
index 330360e..b2be790 100644 (file)
@@ -12921,7 +12921,7 @@ span.editinstructions {
 
 #coursesearch {
   margin-top: 1em;
-  text-align: center; }
+  text-align: left; }
 
 #page-course-pending .pendingcourserequests {
   margin-bottom: 1em; }
@@ -13389,6 +13389,9 @@ span.editinstructions {
 #nav-drawer.closed {
   left: -305px; }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+  display: none; }
+
 /* Use a variable for the drawer background colors. */
 [data-region="drawer"] {
   position: fixed;
index 28a50ad..b109c93 100644 (file)
@@ -58,6 +58,7 @@
     {{{ output.standard_top_of_body_html }}}
 
     {{> theme_boost/navbar }}
+    {{> theme_boost/nav-drawer }}
 
     <div id="page" class="container-fluid d-print-block">
         {{{ output.full_header }}}
@@ -89,7 +90,6 @@
         </div>
     </div>
     {{{ output.standard_after_main_region_html }}}
-    {{> theme_boost/nav-drawer }}
     {{> theme_boost/footer }}
 </div>
 
index 4802c14..0820ba3 100644 (file)
@@ -13134,7 +13134,7 @@ span.editinstructions {
 
 #coursesearch {
   margin-top: 1em;
-  text-align: center; }
+  text-align: left; }
 
 #page-course-pending .pendingcourserequests {
   margin-bottom: 1em; }
@@ -13603,6 +13603,9 @@ span.editinstructions {
 #nav-drawer.closed {
   left: -305px; }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+  display: none; }
+
 /* Use a variable for the drawer background colors. */
 [data-region="drawer"] {
   position: fixed;
index 4183bba..d975b8b 100644 (file)
@@ -217,7 +217,7 @@ class behat_theme_classic_behat_navigation extends behat_navigation {
         $menuxpath = "//section[contains(@class,'block_settings')]//div[@id='settingsnav']/ul[1]/li[1]";
 
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu');
             $this->find('xpath', $menuxpath, $exception);
 
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
index 2ad7497..0224f52 100644 (file)
@@ -220,8 +220,8 @@ class participants_table extends \table_sql {
         $this->define_columns($columns);
         $this->define_headers($headers);
 
-        // Make this table sorted by first name by default.
-        $this->sortable(true, 'firstname');
+        // Make this table sorted by last name by default.
+        $this->sortable(true, 'lastname');
 
         $this->no_sorting('select');
         $this->no_sorting('roles');
index 6f433b1..20838ca 100644 (file)
@@ -113,12 +113,12 @@ Feature: View course participants
     And I am on "Course 1" course homepage
     And I navigate to course participants
     And I follow "Email address"
-    When I follow "2"
+    When I click on "2" "link" in the "//nav[@aria-label='Page']" "xpath_element"
     Then I should not see "student0x@example.com"
     And I should not see "student19x@example.com"
     And I should see "teacher1x@example.com"
     And I follow "Email address"
-    And I follow "2"
+    And I click on "2" "link" in the "//nav[@aria-label='Page']" "xpath_element"
     And I should not see "teacher1x@example.com"
     And I should not see "student19x@example.com"
     And I should not see "student1x@example.com"
@@ -132,7 +132,6 @@ Feature: View course participants
     When I log in as "teacher1x"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I follow "Surname"
     And I click on "Select all" "checkbox"
     Then I should not see "Student 9x"
     And the field "Select 'Teacher 1x'" matches value "1"
index e7f1c98..5d5f3ee 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020022100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020022800.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9dev (Build: 20200221)'; // Human-friendly version name
+$release  = '3.9dev (Build: 20200228)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.
index abb2ed1..fe6c061 100644 (file)
@@ -370,7 +370,8 @@ class webservice {
                     $newtoken->contextid = context_system::instance()->id;
                     $newtoken->creatorid = $userid;
                     $newtoken->timecreated = time();
-                    $newtoken->privatetoken = null;
+                    // Generate the private token, it must be transmitted only via https.
+                    $newtoken->privatetoken = random_string(64);
 
                     $DB->insert_record('external_tokens', $newtoken);
                 }