Merge branch 'MDL-67062-master' of git://github.com/sarjona/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 26 Feb 2020 21:44:47 +0000 (22:44 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 26 Feb 2020 21:44:47 +0000 (22:44 +0100)
65 files changed:
admin/settings/courses.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
cache/stores/file/lang/en/cachestore_file.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.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/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/tests/behat/activity_chooser.feature
course/tests/behat/recommend_activities.feature [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/services_content_item_service_test.php
grade/grading/form/rubric/lang/en/gradingform_rubric.php
h5p/classes/form/uploadlibraries_form.php
h5p/classes/framework.php
h5p/classes/player.php
h5p/libraries.php
h5p/tests/behat/h5p_libraries.feature
h5p/tests/external_test.php
h5p/tests/framework_test.php
lang/en/admin.php
lang/en/badges.php
lang/en/cache.php
lang/en/course.php
lang/en/moodle.php
lang/en/role.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/classes/task/manager.php
lib/db/access.php
lib/db/caches.php
lib/db/services.php
lib/grade/grade_item.php
lib/moodlelib.php
lib/outputlib.php
lib/tests/adhoc_task_test.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/export.php
mod/forum/lang/en/forum.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/quiz/lang/en/quiz.php
repository/nextcloud/lang/en/repository_nextcloud.php
version.php

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 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 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..8d1704d 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index c5fc3df..45c977b 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
diff --git a/course/amd/build/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..709cd91 100644 (file)
@@ -125,7 +125,7 @@ const modalBuilder = data => buildModal(templateDataBuilder(data));
 const templateDataBuilder = (data) => {
     // Filter the incoming data to find favourite & recommended modules.
     const favourites = [];
-    const recommended = [];
+    const recommended = data.filter(mod => mod.recommended === true);
 
     // Given the results of the above filters lets figure out what tab to set active.
 
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..2386446 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,6 +115,16 @@ 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(),
@@ -123,7 +135,8 @@ class course_content_item_exporter extends exporter {
             '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 b8be7a9..9fdf2af 100644 (file)
@@ -54,8 +54,17 @@ 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
-    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"
+  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"
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 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 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 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 b30c69d..e6a13ed 100644 (file)
@@ -1252,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');
     }
 
     /**
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 80ad089..8d784f6 100644 (file)
@@ -37,9 +37,9 @@ $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) {
index 66b5323..779f9e4 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"
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 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..76eeb14 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';
@@ -46,29 +47,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 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 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 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 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 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 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 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 e7f1c98..844e310 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020022100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020022100.01;              // 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