Merge branch 'master_MDL-67980' of https://github.com/golenkovm/moodle
authorJun Pataleta <jun@moodle.com>
Wed, 26 Feb 2020 06:30:49 +0000 (14:30 +0800)
committerJun Pataleta <jun@moodle.com>
Wed, 26 Feb 2020 06:30:49 +0000 (14:30 +0800)
45 files changed:
admin/settings/courses.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.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
h5p/classes/form/uploadlibraries_form.php
h5p/classes/player.php
h5p/libraries.php
h5p/tests/behat/h5p_libraries.feature
h5p/tests/external_test.php
lang/en/cache.php
lang/en/course.php
lang/en/role.php
lib/db/access.php
lib/db/caches.php
lib/db/services.php
lib/moodlelib.php
lib/outputlib.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/templates/message_drawer_view_conversation_body_confirm_dialogue.mustache
mod/forum/export.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 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..51d1c22 100644 (file)
@@ -51,6 +51,7 @@ 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
 </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 +63,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 +88,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 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 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 6ef87ab..f9d32ab 100644 (file)
@@ -348,12 +348,15 @@ class player {
         $url->remove_params(array_keys($url->params()));
         $path = $url->out_as_local_url();
 
+        // We only need the slasharguments.
+        $path = substr($path, strpos($path, '.php/') + 5);
         $parts = explode('/', $path);
         $filename = array_pop($parts);
-        // First is an empty row and then the pluginfile.php part. Both can be ignored.
-        array_shift($parts);
-        array_shift($parts);
 
+        // If the request is made by tokenpluginfile.php we need to avoid userprivateaccesskey.
+        if (strpos($this->url, '/tokenpluginfile.php')) {
+            array_shift($parts);
+        }
         // Get the contextid, component and filearea.
         $contextid = array_shift($parts);
         $component = array_shift($parts);
@@ -515,20 +518,40 @@ class player {
      */
     private function get_export_settings(bool $downloadenabled): ?\moodle_url {
 
-        if ( ! $downloadenabled) {
+        if (!$downloadenabled) {
             return null;
         }
 
         $systemcontext = \context_system::instance();
         $slug = $this->content['slug'] ? $this->content['slug'] . '-' : '';
-        $url  = \moodle_url::make_pluginfile_url(
-            $systemcontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            "{$slug}{$this->content['id']}.h5p"
-        );
+        // We have to build the right URL.
+        // Depending the request was made through webservice/pluginfile.php or pluginfile.php.
+        if (strpos($this->url, '/webservice/pluginfile.php')) {
+            $url  = \moodle_url::make_webservice_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p"
+            );
+        } else {
+            // If the request is made by tokenpluginfile.php we need to indicates to generate a token for current user.
+            $includetoken = false;
+            if (strpos($this->url, '/tokenpluginfile.php')) {
+                $includetoken = true;
+            }
+            $url  = \moodle_url::make_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p",
+                false,
+                $includetoken
+            );
+        }
 
         return $url;
     }
index bffce14..af04b98 100644 (file)
@@ -34,9 +34,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);
 
 echo $OUTPUT->header();
 echo $OUTPUT->heading($pagetitle);
index 103c057..7814fc6 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 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..6a9c3b1 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,6 +47,7 @@ $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';
@@ -65,6 +67,8 @@ $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},
 
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 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 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 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..d1de569 100644 (file)
@@ -32,6 +32,7 @@ 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
 
 === 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 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 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 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