Merge branch 'MDL-67865-master' of git://github.com/rezaies/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 26 Feb 2020 22:36:26 +0000 (23:36 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 26 Feb 2020 22:36:26 +0000 (23:36 +0100)
139 files changed:
Gruntfile.js
GruntfileComponents.js
admin/settings/courses.php
admin/settings/security.php
admin/tool/capability/renderer.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/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/repository.min.js
course/amd/build/local/activitychooser/repository.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/build/recommendations.min.js [new file with mode: 0644]
course/amd/build/recommendations.min.js.map [new file with mode: 0644]
course/amd/src/activitychooser.js
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/repository.js
course/amd/src/local/activitychooser/selectors.js
course/amd/src/recommendations.js [new file with mode: 0644]
course/classes/external/course_module_chooser_exporter.php [deleted file]
course/classes/local/entity/content_item.php [new file with mode: 0644]
course/classes/local/entity/lang_string_title.php [new file with mode: 0644]
course/classes/local/entity/string_title.php [new file with mode: 0644]
course/classes/local/entity/title.php [new file with mode: 0644]
course/classes/local/exporters/course_content_item_exporter.php [new file with mode: 0644]
course/classes/local/exporters/course_content_items_exporter.php [new file with mode: 0644]
course/classes/local/factory/content_item_service_factory.php [new file with mode: 0644]
course/classes/local/repository/caching_content_item_readonly_repository.php [new file with mode: 0644]
course/classes/local/repository/content_item_readonly_repository.php [new file with mode: 0644]
course/classes/local/repository/content_item_readonly_repository_interface.php [new file with mode: 0644]
course/classes/local/service/content_item_service.php [new file with mode: 0644]
course/classes/output/recommendations/activity_list.php [new file with mode: 0644]
course/classes/output/recommendations/renderer.php [new file with mode: 0644]
course/classes/privacy/provider.php
course/externallib.php
course/format/singleactivity/lib.php
course/lib.php
course/recommendations.php [new file with mode: 0644]
course/renderer.php
course/templates/activity_list.mustache [new file with mode: 0644]
course/templates/chooser.mustache
course/templates/chooser_help.mustache
course/templates/chooser_item.mustache
course/templates/course_search_form.mustache
course/tests/behat/activity_chooser.feature
course/tests/behat/behat_course.php
course/tests/behat/recommend_activities.feature [new file with mode: 0644]
course/tests/caching_content_item_readonly_repository_test.php [new file with mode: 0644]
course/tests/content_item_readonly_repository_test.php [new file with mode: 0644]
course/tests/content_item_test.php [new file with mode: 0644]
course/tests/courselib_test.php
course/tests/exporters_content_item_test.php [new file with mode: 0644]
course/tests/exporters_content_items_test.php [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/services_content_item_service_test.php [new file with mode: 0644]
course/upgrade.txt
grade/grading/form/rubric/lang/en/gradingform_rubric.php
h5p/classes/api.php [new file with mode: 0644]
h5p/classes/form/uploadlibraries_form.php
h5p/classes/framework.php
h5p/classes/output/libraries.php [new file with mode: 0644]
h5p/classes/player.php
h5p/libraries.php
h5p/templates/h5plibraries.mustache
h5p/tests/api_test.php [new file with mode: 0644]
h5p/tests/behat/h5p_libraries.feature
h5p/tests/external_test.php
h5p/tests/framework_test.php
install/lang/he_kids/langconfig.php
lang/en/admin.php
lang/en/badges.php
lang/en/cache.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/h5p.php
lang/en/moodle.php
lang/en/question.php
lang/en/role.php
lang/en/timezones.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/classes/event/user_password_policy_failed.php [new file with mode: 0644]
lib/classes/task/manager.php
lib/db/access.php
lib/db/caches.php
lib/db/services.php
lib/deprecatedlib.php
lib/grade/grade_item.php
lib/moodlelib.php
lib/outputlib.php
lib/questionlib.php
lib/tests/adhoc_task_test.php
lib/tests/authlib_test.php
lib/tests/behat/behat_app.php
lib/tests/behat/behat_hooks.php
lib/tests/date_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/externallib.php
message/output/airnotifier/message_output_airnotifier.php
message/templates/message_drawer_view_conversation_body_confirm_dialogue.mustache
message/templates/message_drawer_view_conversation_footer_content.mustache
mod/folder/lang/en/folder.php
mod/forum/classes/local/factories/renderer.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_post_subject_with_context_links.mustache [new file with mode: 0644]
mod/forum/templates/forum_posts_with_context_links.mustache
mod/forum/templates/forum_search_results.mustache [new file with mode: 0644]
mod/forum/templates/social_discussion_list.mustache
mod/lti/lib.php
mod/lti/locallib.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/lti/source/upgrade.txt
mod/lti/tests/lib_test.php
mod/quiz/lang/en/quiz.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/overview/tests/helpers.php [new file with mode: 0644]
mod/quiz/report/overview/tests/report_test.php
mod/upgrade.txt
question/upgrade.txt
repository/nextcloud/lang/en/repository_nextcloud.php
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index 118c2f5..bd39185 100644 (file)
@@ -172,19 +172,34 @@ module.exports = function(grunt) {
     const inComponent = !!componentDirectory;
     const runDir = inComponent ? componentDirectory : relativeCwd;
     const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
-    grunt.log.debug(`The cwd was detected as ${cwd} with a fullRunDir of ${fullRunDir}`);
+    grunt.log.debug('============================================================================');
+    grunt.log.debug(`= Node version:        ${process.versions.node}`);
+    grunt.log.debug(`= grunt version:       ${grunt.package.version}`);
+    grunt.log.debug(`= process.cwd:         '` + process.cwd() + `'`);
+    grunt.log.debug(`= process.env.PWD:     '${process.env.PWD}'`);
+    grunt.log.debug(`= path.sep             '${path.sep}'`);
+    grunt.log.debug('============================================================================');
+    grunt.log.debug(`= gruntFilePath:       '${gruntFilePath}'`);
+    grunt.log.debug(`= relativeCwd:         '${relativeCwd}'`);
+    grunt.log.debug(`= componentDirectory:  '${componentDirectory}'`);
+    grunt.log.debug(`= inComponent:         '${inComponent}'`);
+    grunt.log.debug(`= runDir:              '${runDir}'`);
+    grunt.log.debug(`= fullRunDir:          '${fullRunDir}'`);
+    grunt.log.debug('============================================================================');
 
     if (inComponent) {
         grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
     }
 
-    var files = null;
+    let files = null;
     if (grunt.option('files')) {
         // Accept a comma separated list of files to process.
         files = grunt.option('files').split(',');
     }
 
-    const inAMD = path.basename(cwd) == 'amd';
+    // If the cwd is the amd directory in the current component then it will be empty.
+    // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
+    const inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
 
     // Globbing pattern for matching all AMD JS source files.
     let amdSrc = [];
@@ -235,7 +250,7 @@ module.exports = function(grunt) {
             const nodes = xpath.select("/libraries/library/location/text()", doc);
 
             nodes.forEach(function(node) {
-                let lib = path.join(dirname, node.toString());
+                let lib = path.posix.join(dirname, node.toString());
                 if (grunt.file.isDir(lib)) {
                     // Ensure trailing slash on dirs.
                     lib = lib.replace(/\/?$/, '/');
@@ -251,6 +266,24 @@ module.exports = function(grunt) {
         return libs;
     };
 
+    /**
+     * Get the list of feature files to pass to the gherkin linter.
+     *
+     * @returns {Array}
+     */
+    const getGherkinLintTargets = () => {
+        if (files) {
+            // Specific files were requested. Only check these.
+            return files;
+        }
+
+        if (inComponent) {
+            return [`${runDir}/tests/behat/*.feature`];
+        }
+
+        return ['**/tests/behat/*.feature'];
+    };
+
     // Project configuration.
     grunt.initConfig({
         eslint: {
@@ -366,7 +399,7 @@ module.exports = function(grunt) {
         },
         gherkinlint: {
             options: {
-                files: files ? files : ['**/tests/behat/*.feature'],
+                files: getGherkinLintTargets(),
             }
         },
     });
@@ -484,19 +517,31 @@ module.exports = function(grunt) {
     };
 
     tasks.gherkinlint = function() {
-        var done = this.async(),
-            options = grunt.config('gherkinlint.options');
-
-        var args = grunt.file.expand(options.files);
-        args.unshift(path.normalize(__dirname + '/node_modules/.bin/gherkin-lint'));
-        grunt.util.spawn({
-            cmd: 'node',
-            args: args,
-            opts: {stdio: 'inherit', env: process.env}
-        }, function(error, result, code) {
-            // Propagate the exit code.
-            done(code === 0);
-        });
+        const done = this.async();
+        const options = grunt.config('gherkinlint.options');
+
+        // Grab the gherkin-lint linter and required scaffolding.
+        const linter = require('gherkin-lint/src/linter.js');
+        const featureFinder = require('gherkin-lint/src/feature-finder.js');
+        const configParser = require('gherkin-lint/src/config-parser.js');
+        const formatter = require('gherkin-lint/src/formatters/stylish.js');
+
+        // Run the linter.
+        const results = linter.lint(
+            featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
+            configParser.getConfiguration(configParser.defaultConfigFileName)
+        );
+
+        // Print the results out uncondtionally.
+        formatter.printResults(results);
+
+        // Report on the results.
+        // We exit 1 if there is at least one error, otherwise we exit cleanly.
+        if (results.some(result => result.errors.length > 0)) {
+            done(1);
+        } else {
+            done(0);
+        }
     };
 
     tasks.startup = function() {
index 06ed999..74bd9de 100644 (file)
@@ -131,9 +131,11 @@ const getYuiSrcGlobList = relativeTo => {
  */
 const getThirdPartyLibsList = relativeTo => {
     const fs = require('fs');
+    const path = require('path');
 
     return fetchComponentData().pathList
-        .map(componentPath => componentPath.replace(relativeTo, '') + '/thirdpartylibs.xml')
+        .map(componentPath => path.relative(relativeTo, componentPath) + '/thirdpartylibs.xml')
+        .map(componentPath => componentPath.replace(/\\/g, '/'))
         .filter(path => fs.existsSync(path))
         .sort();
 };
@@ -157,18 +159,19 @@ const getComponentFromPath = path => {
 /**
  * Check whether the supplied path, relative to the Gruntfile.js, is in a known component.
  *
- * @param {String} checkPath The path to check
+ * @param {String} checkPath The path to check. This can be with either Windows, or Linux directory separators.
  * @returns {String|null}
  */
 const getOwningComponentDirectory = checkPath => {
     const path = require('path');
 
-    const pathList = fetchComponentData().components;
-    for (const componentPath of Object.keys(pathList)) {
-        if (checkPath === componentPath) {
-            return componentPath;
-        }
-        if (checkPath.startsWith(componentPath + path.sep)) {
+    // Fetch all components into a reverse sorted array.
+    // This ensures that components which are within the directory of another component match first.
+    const pathList = Object.keys(fetchComponentData().components).sort().reverse();
+    for (const componentPath of pathList) {
+        // If the componentPath is the directory being checked, it will be empty.
+        // If the componentPath is a parent of the directory being checked, the relative directory will not start with ..
+        if (!path.relative(componentPath, checkPath).startsWith('..')) {
             return componentPath;
         }
     }
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 1b91d6a..d8dc48a 100644 (file)
@@ -100,6 +100,9 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configtext('minpasswordupper', new lang_string('minpasswordupper', 'admin'), new lang_string('configminpasswordupper', 'admin'), 1, PARAM_INT));
     $temp->add(new admin_setting_configtext('minpasswordnonalphanum', new lang_string('minpasswordnonalphanum', 'admin'), new lang_string('configminpasswordnonalphanum', 'admin'), 1, PARAM_INT));
     $temp->add(new admin_setting_configtext('maxconsecutiveidentchars', new lang_string('maxconsecutiveidentchars', 'admin'), new lang_string('configmaxconsecutiveidentchars', 'admin'), 0, PARAM_INT));
+    $temp->add(new admin_setting_configcheckbox('passwordpolicycheckonlogin',
+        new lang_string('passwordpolicycheckonlogin', 'admin'),
+        new lang_string('configpasswordpolicycheckonlogin', 'admin'), 0));
 
     $temp->add(new admin_setting_configtext('passwordreuselimit',
         new lang_string('passwordreuselimit', 'admin'),
index d083f28..64f8762 100644 (file)
@@ -76,6 +76,7 @@ class tool_capability_renderer extends plugin_renderer_base {
      * @return string
      */
     public function capability_comparison_table(array $capabilities, $contextid, array $roles, $onlydiff=false) {
+        static $capabilitycontexts = array();
 
         $strpermissions = $this->get_permission_strings();
         $permissionclasses = $this->get_permission_classes();
@@ -94,7 +95,11 @@ class tool_capability_renderer extends plugin_renderer_base {
         $table->data = array();
 
         foreach ($capabilities as $capability) {
-            $contexts = tool_capability_calculate_role_data($capability, $roles);
+            if (empty($capabilitycontexts[$capability])) {
+                $capabilitycontexts[$capability] = tool_capability_calculate_role_data($capability, $roles);
+            }
+            $contexts = $capabilitycontexts[$capability];
+
             $captitle = new html_table_cell(get_capability_string($capability) . html_writer::span($capability));
             $captitle->header = true;
 
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 39ab5cf..8d1704d 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 77cf4e6..45c977b 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index e1d3ec5..b28b3db 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index d2c6bcd..5ca8140 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index da5b3c7..a821358 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js and b/course/amd/build/local/activitychooser/repository.min.js differ
index 67b1590..0d35705 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js.map and b/course/amd/build/local/activitychooser/repository.min.js.map differ
index d709bb4..d826dbf 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js and b/course/amd/build/local/activitychooser/selectors.min.js differ
index 98e3367..229f9dd 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js.map and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
diff --git a/course/amd/build/recommendations.min.js b/course/amd/build/recommendations.min.js
new file mode 100644 (file)
index 0000000..59b3a55
Binary files /dev/null and b/course/amd/build/recommendations.min.js differ
diff --git a/course/amd/build/recommendations.min.js.map b/course/amd/build/recommendations.min.js.map
new file mode 100644 (file)
index 0000000..aea6e7f
Binary files /dev/null and b/course/amd/build/recommendations.min.js.map differ
index 6f6613b..709cd91 100644 (file)
@@ -100,10 +100,10 @@ const registerListenerEvents = (courseId) => {
 const sectionIdMapper = (webServiceData, id) => {
     // We need to take a fresh deep copy of the original data as an object is a reference type.
     const newData = JSON.parse(JSON.stringify(webServiceData));
-    newData.allmodules.forEach((module) => {
-        module.urls.addoption += '&section=' + id;
+    newData.content_items.forEach((module) => {
+        module.link += '&section=' + id;
     });
-    return newData.allmodules;
+    return newData.content_items;
 };
 
 /**
@@ -123,8 +123,26 @@ const modalBuilder = data => buildModal(templateDataBuilder(data));
  * @return {Object} Our built object ready to render out
  */
 const templateDataBuilder = (data) => {
+    // Filter the incoming data to find favourite & recommended modules.
+    const favourites = [];
+    const recommended = data.filter(mod => mod.recommended === true);
+
+    // Given the results of the above filters lets figure out what tab to set active.
+
+    // We have some favourites.
+    const favouritesFirst = !!favourites.length;
+    // Check if we have no favourites but have some recommended.
+    const recommendedFirst = !!(recommended.length && favouritesFirst === false);
+    // We have nothing fallback to show all modules.
+    const fallback = favouritesFirst === false && recommendedFirst === false;
+
     return {
         'default': data,
+        favourites: favourites,
+        recommended: recommended,
+        favouritesFirst: favouritesFirst,
+        recommendedFirst: recommendedFirst,
+        fallback: fallback,
     };
 };
 
index 4be4341..f443d9a 100644 (file)
@@ -137,11 +137,71 @@ const registerListenerEvents = (modal, mappedModules) => {
  * Initialise the keyboard navigation controls for the chooser.
  *
  * @method initKeyboardNavigation
- * @param {NodeElement} body Our modal that we are working with
+ * @param {HTMLElement} body Our modal that we are working with
  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
  */
 const initKeyboardNavigation = (body, mappedModules) => {
 
+    // Set up the tab handlers.
+    const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
+    const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
+    const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
+    const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav];
+    tabNavArray.forEach((element) => {
+        return element.addEventListener('keyup', (e) => {
+            const firstLink = e.target.parentElement.parentElement.firstElementChild.firstElementChild;
+            const lastLink = e.target.parentElement.parentElement.lastElementChild.firstElementChild;
+
+            if (e.keyCode === arrowRight) {
+                const nextLink = e.target.parentElement.nextElementSibling;
+                if (nextLink === null) {
+                    e.srcElement.tabIndex = -1;
+                    firstLink.tabIndex = 0;
+                    firstLink.focus();
+                } else if (nextLink.firstElementChild.classList.contains('d-none')) {
+                    e.srcElement.tabIndex = -1;
+                    lastLink.tabIndex = 0;
+                    lastLink.focus();
+                } else {
+                    e.srcElement.tabIndex = -1;
+                    nextLink.firstElementChild.tabIndex = 0;
+                    nextLink.firstElementChild.focus();
+                }
+            }
+            if (e.keyCode === arrowLeft) {
+                const previousLink = e.target.parentElement.previousElementSibling;
+                if (previousLink === null) {
+                    e.srcElement.tabIndex = -1;
+                    lastLink.tabIndex = 0;
+                    lastLink.focus();
+                } else if (previousLink.firstElementChild.classList.contains('d-none')) {
+                    e.srcElement.tabIndex = -1;
+                    firstLink.tabIndex = 0;
+                    firstLink.focus();
+                } else {
+                    e.srcElement.tabIndex = -1;
+                    previousLink.firstElementChild.tabIndex = 0;
+                    previousLink.firstElementChild.focus();
+                }
+            }
+            if (e.keyCode === home) {
+                e.srcElement.tabIndex = -1;
+                firstLink.tabIndex = 0;
+                firstLink.focus();
+            }
+            if (e.keyCode === end) {
+                e.srcElement.tabIndex = -1;
+                lastLink.tabIndex = 0;
+                lastLink.focus();
+            }
+            if (e.keyCode === space) {
+                e.preventDefault();
+                e.target.click();
+            }
+        });
+    });
+
+    // Set up the handlers for the modules.
     const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
 
     Array.from(chooserOptions).forEach((element) => {
@@ -258,7 +318,7 @@ export const displayChooser = (origin, modal, sectionModules) => {
     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
     const mappedModules = new Map();
     sectionModules.forEach((module) => {
-        mappedModules.set(module.modulename, module);
+        mappedModules.set(module.componentname + '_' + module.link, module);
     });
 
     // Register event listeners.
index 1e6f1a5..e6a09c5 100644 (file)
@@ -31,7 +31,7 @@ import ajax from 'core/ajax';
  */
 export const activityModules = (courseid) => {
     const request = {
-        methodname: 'core_course_get_activity_picker_info',
+        methodname: 'core_course_get_course_content_items',
         args: {
             courseid: courseid,
         },
index adeb07f..f00b620 100644 (file)
@@ -51,6 +51,12 @@ export default {
         carousel: getDataSelector('region', 'carousel'),
         help: getDataSelector('region', 'help'),
         modules: getDataSelector('region', 'modules'),
+        favouriteTabNav: getDataSelector('region', 'favourite-tab-nav'),
+        recommendedTabNav: getDataSelector('region', 'recommended-tab-nav'),
+        defaultTabNav: getDataSelector('region', 'default-tab-nav'),
+        favouriteTab: getDataSelector('region', 'favourites'),
+        recommendedTab: getDataSelector('region', 'recommended'),
+        defaultTab: getDataSelector('region', 'default'),
         getModuleSelector: modname => `[role="menuitem"][data-modname="${modname}"]`
     },
     actions: {
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);
+    });
+};
diff --git a/course/classes/external/course_module_chooser_exporter.php b/course/classes/external/course_module_chooser_exporter.php
deleted file mode 100644 (file)
index 01715e6..0000000
+++ /dev/null
@@ -1,151 +0,0 @@
-<?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/>.
-
-/**
- * Author exporter.
- *
- * @package    core_course
- * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-namespace core_course\external;
-
-defined('MOODLE_INTERNAL') || die();
-
-use core\external\exporter;
-use renderer_base;
-
-/**
- * Course module chooser exporter.
- *
- * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class course_module_chooser_exporter extends exporter {
-
-    /** @var array $modules Array containing the available modules */
-    private $modules;
-
-    /**
-     * Constructor.
-     *
-     * @param array $modules The available course modules
-     * @param array $related The related data for the export
-     */
-    public function __construct(array $modules, array $related = []) {
-        $this->modules = $modules;
-        return parent::__construct([], $related);
-    }
-
-    /**
-     * Return the list of additional properties.
-     *
-     * @return array
-     */
-    protected static function define_other_properties() {
-        return [
-            'options' => [
-                'multiple' => true,
-                'optional' => true,
-                'type' => [
-                    'label' => ['type' => PARAM_TEXT],
-                    'modulename' => ['type' => PARAM_TEXT],
-                    'description' => ['type' => PARAM_TEXT],
-                    'urls' => [
-                        'type' => [
-                            'addoption' => [
-                                'type' => PARAM_URL
-                            ]
-                        ]
-                    ],
-                    'icon' => [
-                        'type' => PARAM_RAW,
-                        'optional' => true,
-                        'default' => null,
-                        'null' => NULL_ALLOWED
-                    ]
-                ]
-            ]
-        ];
-    }
-
-    /**
-     * Get the additional values to inject while exporting.
-     *
-     * @param renderer_base $output The renderer.
-     * @return array Keys are the property names, values are their values.
-     */
-    protected function get_other_values(renderer_base $output) {
-
-        $options = new \stdClass();
-        $options->trusted = false;
-        $options->noclean = false;
-        $options->smiley = false;
-        $options->filter = false;
-        $options->para = true;
-        $options->newlines = false;
-        $options->overflowdiv = false;
-
-        $context = $this->related['context'];
-
-        $modulesdata = [];
-        foreach ($this->modules as $module) {
-            $customiconurl = null;
-
-            // The property 'name' may contain more than just the module, in which case we need to extract the true module name.
-            $modulename = $module->name;
-            if ($colon = strpos($modulename, ':')) {
-                $modulename = substr($modulename, 0, $colon);
-            }
-
-            if (isset($module->help) || !empty($module->help)) {
-                list($description) = external_format_text((string) $module->help, FORMAT_MARKDOWN,
-                    $context->id, null, null, null, $options);
-            } else {
-                $description = get_string('nohelpforactivityorresource', 'moodle');
-            }
-
-            $icon = new \pix_icon('icon', '', $modulename);
-
-            // When exporting check if the title is an object, we assume it's a lang string object otherwise we send the raw string.
-            $modulesdata[] = [
-                'label' => $module->title instanceof \lang_string ? $module->title->out() : $module->title,
-                'modulename' => $modulename,
-                'description' => $description,
-                'urls' => [
-                    'addoption' => $module->link->out(false),
-                ],
-                'icon' => $icon->export_for_template($output)
-            ];
-        }
-
-        return [
-            'options' => $modulesdata
-        ];
-    }
-
-    /**
-     * Returns a list of objects that are related.
-     *
-     * @return array
-     */
-    protected static function define_related() {
-        return [
-            'context' => 'context'
-        ];
-    }
-}
diff --git a/course/classes/local/entity/content_item.php b/course/classes/local/entity/content_item.php
new file mode 100644 (file)
index 0000000..cd7c264
--- /dev/null
@@ -0,0 +1,154 @@
+<?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 the content_item class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The content_item class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item {
+    /** @var int $id the id. */
+    private $id;
+
+    /** @var string $name the name. */
+    private $name;
+
+    /** @var title $title the title. */
+    private $title;
+
+    /** @var \moodle_url $link the url for the content item's setup page (usually mod/edit.php). */
+    private $link;
+
+    /** @var string $icon an html string containing the icon for this item. */
+    private $icon;
+
+    /** @var string $help the description/help text for this content item. */
+    private $help;
+
+    /** @var int $achetype a module archetype, e.g. MOD_ARCHETYPE_RESOURCE, MOD_ARCHETYPE_OTHER. */
+    private $archetype;
+
+    /** @var string $componentname the name of the component from which this content item originates. */
+    private $componentname;
+
+    /**
+     * The content_item constructor.
+     *
+     * @param int $id Id number.
+     * @param string $name Name of the item, not human readable.
+     * @param title $title Human readable title for the item.
+     * @param \moodle_url $link The URL to the creation page, with any item specific params
+     * @param string $icon HTML containing the icon for the item
+     * @param string $help The description of the item.
+     * @param int $archetype the archetype for the content item (see MOD_ARCHETYPE_X definitions in lib/moodlelib.php).
+     * @param string $componentname the name of the component/plugin with which this content item is associated.
+     */
+    public function __construct(int $id, string $name, title $title, \moodle_url $link, string $icon, string $help,
+            int $archetype, string $componentname) {
+        $this->id = $id;
+        $this->name = $name;
+        $this->title = $title;
+        $this->link = $link;
+        $this->icon = $icon;
+        $this->help = $help;
+        $this->archetype = $archetype;
+        $this->componentname = $componentname;
+    }
+
+    /**
+     * Get the name of the component with which this content item is associated.
+     *
+     * @return string
+     */
+    public function get_component_name(): string {
+        return $this->componentname;
+    }
+
+    /**
+     * Get the help description of this item.
+     *
+     * @return string
+     */
+    public function get_help(): string {
+        return $this->help;
+    }
+
+    /**
+     * Get the archetype of this item.
+     *
+     * @return int
+     */
+    public function get_archetype(): int {
+        return $this->archetype;
+    }
+
+    /**
+     * Get the id of this item.
+     * @return int
+     */
+    public function get_id(): int {
+        return $this->id;
+    }
+
+    /**
+     * Get the name of this item.
+     *
+     * @return string
+     */
+    public function get_name(): string {
+        return $this->name;
+    }
+
+    /**
+     * Get the human readable title of this item.
+     *
+     * @return title
+     */
+    public function get_title(): title {
+        return $this->title;
+    }
+
+    /**
+     * Get the link to the creation page of this item.
+     *
+     * @return \moodle_url
+     */
+    public function get_link(): \moodle_url {
+        return $this->link;
+    }
+
+    /**
+     * Get the icon html for this item.
+     *
+     * @return string
+     */
+    public function get_icon(): string {
+        return $this->icon;
+    }
+}
diff --git a/course/classes/local/entity/lang_string_title.php b/course/classes/local/entity/lang_string_title.php
new file mode 100644 (file)
index 0000000..17b3de6
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the lang_string_title class of value object, providing access to the value of a lang string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The lang_string_title class of value object, providing access to the value of a lang string.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class lang_string_title implements title {
+
+    /** @var string $component the component name. */
+    private $component;
+
+    /** @var string $identifier the string identifier. */
+    private $identifier;
+
+    /**
+     * The lang_string_title constructor.
+     *
+     * @param string $identifier the component name.
+     * @param string $component the string identifier.
+     */
+    public function __construct(string $identifier, string $component) {
+        $this->identifier = $identifier;
+        $this->component = $component;
+    }
+
+    /**
+     * Returns the value of the wrapped string.
+     *
+     * @return string the value of the string.
+     */
+    public function get_value(): string {
+        return get_string($this->identifier, $this->component);
+    }
+}
diff --git a/course/classes/local/entity/string_title.php b/course/classes/local/entity/string_title.php
new file mode 100644 (file)
index 0000000..e7da781
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the string_title class of value object, which provides access to a simple string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The string_title class of value object, which provides access to a simple string.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class string_title implements title {
+
+    /** @var string $title the title string. */
+    private $title;
+
+    /**
+     * The string_title constructor.
+     *
+     * @param string $title a string.
+     */
+    public function __construct(string $title) {
+        $this->title = $title;
+    }
+
+    /**
+     * Return the value of the wrapped string.
+     *
+     * @return string
+     */
+    public function get_value(): string {
+        return $this->title;
+    }
+}
diff --git a/course/classes/local/entity/title.php b/course/classes/local/entity/title.php
new file mode 100644 (file)
index 0000000..2bcc4a2
--- /dev/null
@@ -0,0 +1,35 @@
+<?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 the title value object interface, which provides a basic interface to a string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface title {
+
+    /**
+     * Get the value of this title.
+     */
+    public function get_value(): string;
+}
diff --git a/course/classes/local/exporters/course_content_item_exporter.php b/course/classes/local/exporters/course_content_item_exporter.php
new file mode 100644 (file)
index 0000000..2386446
--- /dev/null
@@ -0,0 +1,157 @@
+<?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 the course_content_item_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\exporters;
+
+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.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_content_item_exporter extends exporter {
+
+    /** @var content_item $contentitem the content_item to export. */
+    private $contentitem;
+
+    /**
+     * The course_content_item_exporter constructor.
+     *
+     * @param content_item $contentitem the content item to export.
+     * @param array $related the array of related objects used during export.
+     */
+    public function __construct(content_item $contentitem, array $related = []) {
+        $this->contentitem = $contentitem;
+
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Definition of all properties originating in the export target, \core_course\local\entity\content_item.
+     *
+     * @return array The array of property values, indexed by name.
+     */
+    protected static function define_properties() {
+        return [
+            'id' => ['type' => PARAM_INT, 'description' => 'The id of the content item'],
+            'name' => ['type' => PARAM_TEXT, 'description' => 'Name of the content item'],
+            'title' => ['type' => PARAM_TEXT, 'description' => 'The string title of the content item, human readable'],
+            'link' => ['type' => PARAM_URL, 'description' => 'The link to the content item creation page'],
+            'icon' => ['type' => PARAM_RAW, 'description' => 'Html containing the icon for the content item'],
+            'help' => ['type' => PARAM_RAW, 'description' => 'Html description / help for the content item'],
+            'archetype' => ['type' => PARAM_RAW, 'description' => 'The archetype of the module exposing the content item'],
+            'componentname' => ['type' => PARAM_TEXT, 'description' => 'The name of the component exposing the content item'],
+        ];
+    }
+
+    /**
+     * Definition of all properties which are either calculated or originate in a related domain object.
+     *
+     * @return array The array of property values, indexed by name.
+     */
+    protected static function define_other_properties() {
+        // This will hold user-dependant properties such as whether the item is starred or recommended.
+        return [
+            'favourite' => ['type' => PARAM_BOOL, 'description' => 'Has the user favourited the content item'],
+            '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'],
+        ];
+    }
+
+    /**
+     * Get ALL properties for the content_item DTO being exported.
+     *
+     * These properties are a mix of:
+     * - readonly properties of the primary object (content_item) being exported.
+     * - calculated values
+     * - properties originating from the related domain objects.
+     *
+     * Normally, those properties defined in get_properties() are added to the export automatically as part of the superclass code,
+     * provided they are public properties on the export target. In this case, the export target is content_item, which doesn't
+     * provide public access to its properties, so those are fetched via their respective getters here.
+     *
+     * @param \renderer_base $output
+     * @return array The array of property values, indexed by name.
+     */
+    protected function get_other_values(\renderer_base $output) {
+
+        $favourite = false;
+        $itemtype = 'contentitem_' . $this->contentitem->get_component_name();
+        if (isset($this->related['favouriteitems'])) {
+            foreach ($this->related['favouriteitems'] as $favobj) {
+                if ($favobj->itemtype === $itemtype && in_array($this->contentitem->get_id(), $favobj->ids)) {
+                    $favourite = true;
+                }
+            }
+        }
+
+        $recommended = false;
+        $itemtype = content_item_service::RECOMMENDATION_PREFIX . $this->contentitem->get_component_name();
+        if (isset($this->related['recommended'])) {
+            foreach ($this->related['recommended'] as $favobj) {
+                if ($favobj->itemtype === $itemtype && in_array($this->contentitem->get_id(), $favobj->ids)) {
+                    $recommended = true;
+                }
+            }
+        }
+
+        $properties = [
+            'id' => $this->contentitem->get_id(),
+            'name' => $this->contentitem->get_name(),
+            'title' => $this->contentitem->get_title()->get_value(),
+            'link' => $this->contentitem->get_link()->out(false),
+            'icon' => $this->contentitem->get_icon(),
+            'help' => $this->contentitem->get_help(),
+            'archetype' => $this->contentitem->get_archetype(),
+            'componentname' => $this->contentitem->get_component_name(),
+            'favourite' => $favourite,
+            'legacyitem' => ($this->contentitem->get_id() == -1),
+            'recommended' => $recommended
+        ];
+
+        return $properties;
+    }
+
+    /**
+     * Define the list of related objects, used by this exporter.
+     *
+     * @return array the list of related objects.
+     */
+    protected static function define_related(): array {
+        return [
+            'context' => '\context',
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
+        ];
+    }
+}
diff --git a/course/classes/local/exporters/course_content_items_exporter.php b/course/classes/local/exporters/course_content_items_exporter.php
new file mode 100644 (file)
index 0000000..9b1c694
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the course_content_items_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use core_course\local\entity\content_item;
+
+/**
+ * The course_content_items_exporter class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_content_items_exporter extends exporter {
+
+    /** @var content_item[] the array of content items. */
+    private $contentitems;
+
+    /**
+     * The course_content_items_exporter constructor.
+     *
+     * @param array $contentitems the array of \core_course\local\entity\content_item objects to export.
+     * @param array $related any related objects, see define_related for what's expected.
+     */
+    public function __construct(array $contentitems, array $related) {
+        $this->contentitems = $contentitems;
+
+        parent::__construct([], $related);
+    }
+
+    /**
+     * Return the properties defining this export.
+     *
+     * @return array the array of properties.
+     */
+    public static function define_properties() {
+        return [
+            'content_items' => [
+                'type' => course_content_item_exporter::read_properties_definition(),
+                'multiple' => true
+            ]
+        ];
+    }
+
+    /**
+     * Generate and return the data for this export.
+     *
+     * @param \renderer_base $output
+     * @return array the array of course content_items
+     */
+    protected function get_other_values(\renderer_base $output) {
+
+        $contentitemexport = function(content_item $contentitem) use ($output) {
+            $exporter = new course_content_item_exporter(
+                $contentitem,
+                [
+                    'context' => $this->related['context'],
+                    'favouriteitems' => $this->related['favouriteitems'],
+                    'recommended' => $this->related['recommended']
+                ]
+            );
+            return $exporter->export($output);
+        };
+
+        $exportedcontentitems = array_map($contentitemexport, $this->contentitems);
+
+        return [
+            'content_items' => $exportedcontentitems
+        ];
+    }
+
+    /**
+     * Define the list of related objects, used by this exporter.
+     *
+     * @return array the list of related objects.
+     */
+    protected static function define_related() {
+        return [
+            'context' => '\context',
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
+        ];
+    }
+}
diff --git a/course/classes/local/factory/content_item_service_factory.php b/course/classes/local/factory/content_item_service_factory.php
new file mode 100644 (file)
index 0000000..bc58ed7
--- /dev/null
@@ -0,0 +1,56 @@
+<?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 the service_factory, a locator for services for course content items.
+ *
+ * Services encapsulate the business logic, and any data manipulation code, and are what clients should interact with.
+ *
+ * @package   core_course
+ * @copyright 2020 Adrian Greeve <adrian@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\factory;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\repository\caching_content_item_readonly_repository;
+use core_course\local\repository\content_item_readonly_repository;
+use core_course\local\service\content_item_service;
+
+/**
+ * Class service_factory, providing functions for location of service objects for course content items.
+ *
+ * This class is responsible for providing service objects to clients only.
+ *
+ * @copyright 2020 Adrian Greeve <adrian@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_service_factory {
+
+    /**
+     * Returns a basic service object providing operations for course content items.
+     *
+     * @return content_item_service
+     */
+    public static function get_content_item_service(): content_item_service {
+        return new content_item_service(
+            new caching_content_item_readonly_repository(
+                \cache::make('core', 'user_course_content_items'),
+                new content_item_readonly_repository()
+            )
+        );
+    }
+}
diff --git a/course/classes/local/repository/caching_content_item_readonly_repository.php b/course/classes/local/repository/caching_content_item_readonly_repository.php
new file mode 100644 (file)
index 0000000..51c93fb
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class caching_content_item_repository, for fetching content_items, with additional caching.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The class caching_content_item_repository, for fetching content_items, with additional caching.
+ *
+ * This class decorates the content_item_repository and uses the supplied cache to store content items for user and course
+ * combinations. The content items for subsequent calls are returned from the cache if present, else are retrieved from the wrapped
+ * content_item_repository.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class caching_content_item_readonly_repository implements content_item_readonly_repository_interface {
+
+    /** @var \cache $cachestore the cache to use. */
+    private $cachestore;
+
+    /** @var content_item_readonly_repository $contentitemrepository a content item repository. */
+    private $contentitemrepository;
+
+    /**
+     * The caching_content_item_readonly_repository constructor.
+     *
+     * @param \cache $cachestore a cache to use.
+     * @param content_item_readonly_repository $contentitemrepository the repository to use as a fallback, after a cache miss.
+     */
+    public function __construct(\cache $cachestore, content_item_readonly_repository $contentitemrepository) {
+        $this->cachestore = $cachestore;
+        $this->contentitemrepository = $contentitemrepository;
+    }
+
+    /**
+     * Find all the content items for a given course and user.
+     *
+     * @param \stdClass $course The course to find content items for.
+     * @param \stdClass $user the user to pass to plugins.
+     * @return array the array of content items.
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array {
+        global $USER;
+        // Try to find this data in the cache first.
+        $key = $USER->id . '_' . $course->id;
+        $contentitems = $this->cachestore->get($key);
+        if ($contentitems !== false) {
+            return $contentitems;
+        }
+
+        // If we can't find it there, we must get it from the slow data store, updating the cache in the process.
+        $contentitems = $this->contentitemrepository->find_all_for_course($course, $user);
+        $this->cachestore->set($key, $contentitems);
+        return $contentitems;
+    }
+
+    /**
+     * Find all the content items made available by core and plugins.
+     *
+     * @return array
+     */
+    public function find_all(): array {
+        return $this->contentitemrepository->find_all();
+    }
+}
diff --git a/course/classes/local/repository/content_item_readonly_repository.php b/course/classes/local/repository/content_item_readonly_repository.php
new file mode 100644 (file)
index 0000000..b92e4fe
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class content_item_repository, for fetching content_items.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\entity\content_item;
+use core_course\local\entity\lang_string_title;
+use core_course\local\entity\string_title;
+
+/**
+ * The class content_item_repository, for reading content_items.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_readonly_repository implements content_item_readonly_repository_interface {
+    /**
+     * Get the help string for content items representing core modules.
+     *
+     * @param string $modname the module name.
+     * @return string the help string, including help link.
+     */
+    private function get_core_module_help_string(string $modname): string {
+        global $OUTPUT;
+
+        $help = '';
+        $sm = get_string_manager();
+        if ($sm->string_exists('modulename_help', $modname)) {
+            $help = get_string('modulename_help', $modname);
+            if ($sm->string_exists('modulename_link', $modname)) { // Link to further info in Moodle docs.
+                $link = get_string('modulename_link', $modname);
+                $linktext = get_string('morehelp');
+                $help .= \html_writer::tag('div', $OUTPUT->doc_link($link, $linktext, true), ['class' => 'helpdoclink']);
+            }
+        }
+        return $help;
+    }
+
+    /**
+     * Create a content_item object based on legacy data returned from the get_shortcuts hook implementations.
+     *
+     * @param \stdClass $item the stdClass of legacy data.
+     * @return content_item a content item object.
+     */
+    private function content_item_from_legacy_data(\stdClass $item): content_item {
+        global $OUTPUT;
+
+        // Make sure the legacy data results in a content_item with id = 0.
+        // Even with an id, we can't uniquely identify the item, because we can't guarantee what component it came from.
+        // An id of -1, signifies this.
+        $item->id = -1;
+
+        // If the module provides the helplink property, append it to the help text to match the look and feel
+        // of the default course modules.
+        if (isset($item->help) && isset($item->helplink)) {
+            $linktext = get_string('morehelp');
+            $item->help .= \html_writer::tag('div',
+                $OUTPUT->doc_link($item->helplink, $linktext, true), ['class' => 'helpdoclink']);
+        }
+
+        if (is_string($item->title)) {
+            $item->title = new string_title($item->title);
+        } else if ($item->title instanceof \lang_string) {
+            $item->title = new lang_string_title($item->title->get_identifier(), $item->title->get_component());
+        }
+
+        // Legacy items had names which are in one of 2 forms:
+        // modname, i.e. 'assign' or
+        // modname:link, i.e. lti:http://etc...
+        // We need to grab the module name out to create the componentname.
+        $modname = (strpos($item->name, ':') !== false) ? explode(':', $item->name)[0] : $item->name;
+
+        return new content_item($item->id, $item->name, $item->title, $item->link, $item->icon, $item->help ?? '',
+            $item->archetype, 'mod_' . $modname);
+    }
+
+    /**
+     * Create a stdClass type object based on a content_item instance.
+     *
+     * @param content_item $contentitem
+     * @return \stdClass the legacy data.
+     */
+    private function content_item_to_legacy_data(content_item $contentitem): \stdClass {
+        $item = new \stdClass();
+        $item->id = $contentitem->get_id();
+        $item->name = $contentitem->get_name();
+        $item->title = $contentitem->get_title();
+        $item->link = $contentitem->get_link();
+        $item->icon = $contentitem->get_icon();
+        $item->help = $contentitem->get_help();
+        $item->archetype = $contentitem->get_archetype();
+        $item->componentname = $contentitem->get_component_name();
+        return $item;
+    }
+
+    /**
+     * Helper to get the contentitems from all subplugin hooks for a given module plugin.
+     *
+     * @param string $parentpluginname the name of the module plugin to check subplugins for.
+     * @param content_item $modulecontentitem the content item of the module plugin, to pass to the hooks.
+     * @param \stdClass $user the user object to pass to subplugins.
+     * @return array the array of content items.
+     */
+    private function get_subplugin_course_content_items(string $parentpluginname, content_item $modulecontentitem,
+            \stdClass $user): array {
+
+        $contentitems = [];
+        $pluginmanager = \core_plugin_manager::instance();
+        foreach ($pluginmanager->get_subplugins_of_plugin($parentpluginname) as $subpluginname => $subplugin) {
+            // Call the hook, but with a copy of the module content item data.
+            $spcontentitems = component_callback($subpluginname, 'get_course_content_items', [$modulecontentitem, $user], null);
+            if (!is_null($spcontentitems)) {
+                foreach ($spcontentitems as $spcontentitem) {
+                    $contentitems[] = $spcontentitem;
+                }
+            }
+        }
+        return $contentitems;
+    }
+
+    /**
+     * Get all the content items for a subplugin.
+     *
+     * @param string $parentpluginname
+     * @param content_item $modulecontentitem
+     * @return array
+     */
+    private function get_subplugin_all_content_items(string $parentpluginname, content_item $modulecontentitem): array {
+        $contentitems = [];
+        $pluginmanager = \core_plugin_manager::instance();
+        foreach ($pluginmanager->get_subplugins_of_plugin($parentpluginname) as $subpluginname => $subplugin) {
+            // Call the hook, but with a copy of the module content item data.
+            $spcontentitems = component_callback($subpluginname, 'get_all_content_items', [$modulecontentitem], null);
+            if (!is_null($spcontentitems)) {
+                foreach ($spcontentitems as $spcontentitem) {
+                    $contentitems[] = $spcontentitem;
+                }
+            }
+        }
+        return $contentitems;
+    }
+
+    /**
+     * Helper to make sure any legacy items have certain properties, which, if missing are inherited from the parent module item.
+     *
+     * @param \stdClass $legacyitem the legacy information, a stdClass coming from get_shortcuts() hook.
+     * @param content_item $modulecontentitem The module's content item information, to inherit if needed.
+     * @return \stdClass the updated legacy item stdClass
+     */
+    private function legacy_item_inherit_missing(\stdClass $legacyitem, content_item $modulecontentitem): \stdClass {
+        // Fall back to the plugin parent value if the subtype didn't provide anything.
+        $legacyitem->archetype = $legacyitem->archetype ?? $modulecontentitem->get_archetype();
+        $legacyitem->icon = $legacyitem->icon ?? $modulecontentitem->get_icon();
+        return $legacyitem;
+    }
+
+    /**
+     * Find all the available content items, not restricted to course or user.
+     *
+     * @return array the array of content items.
+     */
+    public function find_all(): array {
+        global $OUTPUT, $DB;
+
+        // Get all modules so we know which plugins are enabled and able to add content.
+        // Only module plugins may add content items.
+        $modules = $DB->get_records('modules', ['visible' => 1]);
+        $return = [];
+
+        // Now, generate the content_items.
+        foreach ($modules as $modid => $mod) {
+            // Create the content item for the module itself.
+            // If the module chooses to implement the hook, this may be thrown away.
+            $help = $this->get_core_module_help_string($mod->name);
+            $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
+
+            $contentitem = new content_item(
+                $mod->id,
+                $mod->name,
+                new lang_string_title("modulename", $mod->name),
+                new \moodle_url(''), // No course scope, so just an empty link.
+                $OUTPUT->pix_icon('icon', '', $mod->name, ['class' => 'icon']),
+                $help,
+                $archetype,
+                'mod_' . $mod->name
+            );
+
+            $modcontentitemreference = clone($contentitem);
+
+            if (component_callback_exists('mod_' . $mod->name, 'get_all_content_items')) {
+                // Call the module hooks for this module.
+                $plugincontentitems = component_callback('mod_' . $mod->name, 'get_all_content_items',
+                    [$modcontentitemreference], []);
+                if (!empty($plugincontentitems)) {
+                    array_push($return, ...$plugincontentitems);
+                }
+
+                // Now, get those for subplugins of the module.
+                $subplugincontentitems = $this->get_subplugin_all_content_items('mod_' . $mod->name, $modcontentitemreference);
+                if (!empty($subplugincontentitems)) {
+                    array_push($return, ...$subplugincontentitems);
+                }
+            } else {
+                // Neither callback was found, so just use the default module content item.
+                $return[] = $contentitem;
+            }
+        }
+        return $return;
+    }
+
+    /**
+     * Get the list of potential content items for the given course.
+     *
+     * @param \stdClass $course the course
+     * @param \stdClass $user the user, to pass to plugins implementing callbacks.
+     * @return array the array of content_item objects
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array {
+        global $OUTPUT, $DB;
+
+        // Get all modules so we know which plugins are enabled and able to add content.
+        // Only module plugins may add content items.
+        $modules = $DB->get_records('modules', ['visible' => 1]);
+        $return = [];
+
+        // A moodle_url is expected and required by modules in their implementation of the hook 'get_shortcuts'.
+        $urlbase = new \moodle_url('/course/mod.php', ['id' => $course->id]);
+
+        // Now, generate the content_items.
+        foreach ($modules as $modid => $mod) {
+
+            // Create the content item for the module itself.
+            // If the module chooses to implement the hook, this may be thrown away.
+            $help = $this->get_core_module_help_string($mod->name);
+            $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
+
+            $contentitem = new content_item(
+                $mod->id,
+                $mod->name,
+                new lang_string_title("modulename", $mod->name),
+                new \moodle_url($urlbase, ['add' => $mod->name]),
+                $OUTPUT->pix_icon('icon', '', $mod->name, ['class' => 'icon']),
+                $help,
+                $archetype,
+                'mod_' . $mod->name
+            );
+
+            // Legacy vs new hooks.
+            // If the new hook is found for a module plugin, use that path (calling mod plugins and their subplugins directly)
+            // If not, check the legacy hook. This won't provide us with enough information to identify items uniquely within their
+            // component (lti + lti source being an example), but we can still list these items.
+            $modcontentitemreference = clone($contentitem);
+
+            if (component_callback_exists('mod_' . $mod->name, 'get_course_content_items')) {
+                // Call the module hooks for this module.
+                $plugincontentitems = component_callback('mod_' . $mod->name, 'get_course_content_items',
+                    [$modcontentitemreference, $user, $course], []);
+                if (!empty($plugincontentitems)) {
+                    array_push($return, ...$plugincontentitems);
+                }
+
+                // Now, get those for subplugins of the module.
+                $subpluginitems = $this->get_subplugin_course_content_items('mod_' . $mod->name, $modcontentitemreference, $user);
+                if (!empty($subpluginitems)) {
+                    array_push($return, ...$subpluginitems);
+                }
+
+            } else if (component_callback_exists('mod_' . $mod->name, 'get_shortcuts')) {
+                // TODO: MDL-68011 this block needs to be removed in 4.3.
+                debugging('The callback get_shortcuts has been deprecated. Please use get_course_content_items and
+                    get_all_content_items instead. Some features of the activity chooser, such as favourites and recommendations
+                    are not supported when providing content items via the deprecated callback.');
+
+                // If get_shortcuts() callback is defined, the default module action is not added.
+                // It is a responsibility of the callback to add it to the return value unless it is not needed.
+                // The legacy hook, get_shortcuts, expects a stdClass representation of the core module content_item entry.
+                $modcontentitemreference = $this->content_item_to_legacy_data($contentitem);
+
+                $legacyitems = component_callback($mod->name, 'get_shortcuts', [$modcontentitemreference], null);
+                if (!is_null($legacyitems)) {
+                    foreach ($legacyitems as $legacyitem) {
+
+                        $legacyitem = $this->legacy_item_inherit_missing($legacyitem, $contentitem);
+
+                        // All items must have different links, use them as a key in the return array.
+                        // If plugin returned the only one item with the same link as default item - keep $modname,
+                        // otherwise append the link url to the module name.
+                        $legacyitem->name = (count($legacyitems) == 1 &&
+                            $legacyitem->link->out() === $contentitem->get_link()->out()) ? $mod->name : $mod->name . ':' .
+                                $legacyitem->link;
+
+                        $plugincontentitem = $this->content_item_from_legacy_data($legacyitem);
+
+                        $return[] = $plugincontentitem;
+                    }
+                }
+            } else {
+                // Neither callback was found, so just use the default module content item.
+                $return[] = $contentitem;
+            }
+        }
+
+        return $return;
+    }
+}
diff --git a/course/classes/local/repository/content_item_readonly_repository_interface.php b/course/classes/local/repository/content_item_readonly_repository_interface.php
new file mode 100644 (file)
index 0000000..d33e85b
--- /dev/null
@@ -0,0 +1,48 @@
+<?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 the interface content_item_readonly_repository_interface, defining operations for readonly content item repositories.
+ *
+ * This interface is not considered a published interface and serves to govern internal, local repository objects only.
+ * All calling code should use instances of the service classes, and should not interact with repositories directly.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface content_item_readonly_repository_interface {
+    /**
+     * Find all content items for a given course and user.
+     *
+     * @param \stdClass $course the course object.
+     * @param \stdClass $user the user object.
+     * @return array the array of content items.
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array;
+
+    /**
+     * Find all content items that can be presented, irrespective of course.
+     *
+     * @return array the array of content items.
+     */
+    public function find_all(): array;
+}
diff --git a/course/classes/local/service/content_item_service.php b/course/classes/local/service/content_item_service.php
new file mode 100644 (file)
index 0000000..3c2a605
--- /dev/null
@@ -0,0 +1,340 @@
+<?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 the content_item_service class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\service;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\exporters\course_content_items_exporter;
+use core_course\local\repository\content_item_readonly_repository_interface;
+
+/**
+ * The content_item_service class, providing the api for interacting with content items.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+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.
+     *
+     * @param content_item_readonly_repository_interface $repository a content item repository.
+     */
+    public function __construct(content_item_readonly_repository_interface $repository) {
+        $this->repository = $repository;
+    }
+
+    /**
+     * Returns an array of objects representing favourited 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.
+     *
+     * @param \stdClass $user
+     * @return array
+     */
+    private function get_favourite_content_items_for_user(\stdClass $user): array {
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $key = $user->id;
+        $favmods = $favcache->get($key);
+        if ($favmods !== false) {
+            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
+        // each content item for favouriting.
+        $pluginmanager = \core_plugin_manager::instance();
+        $plugins = $pluginmanager->get_plugins_of_type('mod');
+        $itemtypes = [];
+        foreach ($plugins as $plugin) {
+            // Add the mod itself.
+            $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[] = $prefix . $subpluginname;
+                }
+            }
+        }
+
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        $favourites = [];
+        foreach ($itemtypes as $itemtype) {
+            $favs = $ufservice->find_favourites_by_type(self::COMPONENT, $itemtype);
+            $favobj = (object) ['itemtype' => $itemtype, 'ids' => array_column($favs, 'itemid')];
+            $favourites[] = $favobj;
+        }
+        return $favourites;
+    }
+
+    /**
+     * Get all content items which may be added to courses, irrespective of course caps, for site admin views, etc.
+     *
+     * @param \stdClass $user the user object.
+     * @return array the array of exported content items.
+     */
+    public function get_all_content_items(\stdClass $user): array {
+        global $PAGE;
+        $allcontentitems = $this->repository->find_all();
+
+        // 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,
+                'recommended' => $recommendations
+            ]
+        );
+        $exported = $ciexporter->export($PAGE->get_renderer('core'));
+
+        // Sort by title for return.
+        usort($exported->content_items, function($a, $b) {
+            return $a->title > $b->title;
+        });
+
+        return $exported->content_items;
+    }
+
+    /**
+     * Return a representation of the available content items, for a user in a course.
+     *
+     * @param \stdClass $user the user to check access for.
+     * @param \stdClass $course the course to scope the content items to.
+     * @param array $linkparams the desired section to return to.
+     * @return \stdClass[] the content items, scoped to a course.
+     */
+    public function get_content_items_for_user_in_course(\stdClass $user, \stdClass $course, array $linkparams = []): array {
+        global $PAGE;
+
+        if (!has_capability('moodle/course:manageactivities', \context_course::instance($course->id), $user)) {
+            return [];
+        }
+
+        // Get all the visible content items.
+        $allcontentitems = $this->repository->find_all_for_course($course, $user);
+
+        // Content items can only originate from modules or submodules.
+        $pluginmanager = \core_plugin_manager::instance();
+        $components = \core_component::get_component_list();
+        $parents = [];
+        foreach ($allcontentitems as $contentitem) {
+            if (!in_array($contentitem->get_component_name(), array_keys($components['mod']))) {
+                // It could be a subplugin.
+                $info = $pluginmanager->get_plugin_info($contentitem->get_component_name());
+                if (!is_null($info)) {
+                    $parent = $info->get_parent_plugin();
+                    if ($parent != false) {
+                        if (in_array($parent, array_keys($components['mod']))) {
+                            $parents[$contentitem->get_component_name()] = $parent;
+                            continue;
+                        }
+                    }
+                }
+                throw new \moodle_exception('Only modules and submodules can generate content items. \''
+                    . $contentitem->get_component_name() . '\' is neither.');
+            }
+            $parents[$contentitem->get_component_name()] = $contentitem->get_component_name();
+        }
+
+        // Now, check access to these items for the user.
+        $availablecontentitems = array_filter($allcontentitems, function($contentitem) use ($course, $user, $parents) {
+            // Check the parent module access for the user.
+            return course_allowed_module($course, explode('_', $parents[$contentitem->get_component_name()])[1], $user);
+        });
+
+        // Add the link params to the link, if any have been provided.
+        if (!empty($linkparams)) {
+            $availablecontentitems = array_map(function ($item) use ($linkparams) {
+                $item->get_link()->params($linkparams);
+                return $item;
+            }, $availablecontentitems);
+        }
+
+        // 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,
+                'recommended' => $recommended
+            ]
+        );
+        $exported = $ciexporter->export($PAGE->get_renderer('course'));
+
+        // Sort by title for return.
+        usort($exported->content_items, function($a, $b) {
+            return $a->title > $b->title;
+        });
+
+        return $exported->content_items;
+    }
+
+    /**
+     * Add a content item to a user's favourites.
+     *
+     * @param \stdClass $user the user whose favourite this is.
+     * @param string $componentname the name of the component from which the content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return \stdClass the exported content item.
+     */
+    public function add_to_user_favourites(\stdClass $user, string $componentname, int $contentitemid): \stdClass {
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // 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 = self::FAVOURITE_PREFIX . $componentname;
+
+        $ufservice->create_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
+
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $favcache->delete($user->id);
+
+        $items = $this->get_all_content_items($user);
+        return $items[array_search($contentitemid, array_column($items, 'id'))];
+    }
+
+    /**
+     * Remove the content item from a user's favourites.
+     *
+     * @param \stdClass $user the user whose favourite this is.
+     * @param string $componentname the name of the component from which the content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return \stdClass the exported content item.
+     */
+    public function remove_from_user_favourites(\stdClass $user, string $componentname, int $contentitemid): \stdClass {
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // 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 = self::FAVOURITE_PREFIX . $componentname;
+
+        $ufservice->delete_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
+
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $favcache->delete($user->id);
+
+        $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 edeb50c..fab27bc 100644 (file)
@@ -56,6 +56,7 @@ class provider implements
     public static function get_metadata(collection $collection) : collection {
         $collection->add_subsystem_link('core_completion', [], 'privacy:metadata:completionsummary');
         $collection->add_subsystem_link('core_favourites', [], 'privacy:metadata:favouritessummary');
+        $collection->add_subsystem_link('core_favourites', [], 'privacy:metadata:activityfavouritessummary');
         $collection->add_user_preference('coursecat_management_perpage', 'privacy:perpage');
         return $collection;
     }
index c92ad97..5ddf89e 100644 (file)
@@ -4141,33 +4141,114 @@ class core_course_external extends external_api {
         return new external_single_structure($userfields);
     }
 
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function add_content_item_to_user_favourites_parameters() {
+        return new external_function_parameters([
+            'componentname' => new external_value(PARAM_TEXT,
+                'frankenstyle name of the component to which the content item belongs', VALUE_REQUIRED),
+            'contentitemid' => new external_value(PARAM_INT, 'id of the content item', VALUE_REQUIRED, '', NULL_NOT_ALLOWED)
+        ]);
+    }
+
+    /**
+     * Add a content item to a user's favourites.
+     *
+     * @param string $componentname the name of the component from which this content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return stdClass the exporter content item.
+     */
+    public static function add_content_item_to_user_favourites(string $componentname, int $contentitemid) {
+        global $USER;
+
+        [
+            'componentname' => $componentname,
+            'contentitemid' => $contentitemid,
+        ] = self::validate_parameters(self::add_content_item_to_user_favourites_parameters(),
+            [
+                'componentname' => $componentname,
+                'contentitemid' => $contentitemid,
+            ]
+        );
+
+        self::validate_context(context_user::instance($USER->id));
+
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+
+        return $contentitemservice->add_to_user_favourites($USER, $componentname, $contentitemid);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function add_content_item_to_user_favourites_returns() {
+        return \core_course\local\exporters\course_content_item_exporter::get_read_structure();
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function remove_content_item_from_user_favourites_parameters() {
+        return new external_function_parameters([
+            'componentname' => new external_value(PARAM_TEXT,
+                'frankenstyle name of the component to which the content item belongs', VALUE_REQUIRED),
+            'contentitemid' => new external_value(PARAM_INT, 'id of the content item', VALUE_REQUIRED, '', NULL_NOT_ALLOWED),
+        ]);
+    }
+
+    /**
+     * Remove a content item from a user's favourites.
+     *
+     * @param string $componentname the name of the component from which this content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return stdClass the exported content item.
+     */
+    public static function remove_content_item_from_user_favourites(string $componentname, int $contentitemid) {
+        global $USER;
+
+        [
+            'componentname' => $componentname,
+            'contentitemid' => $contentitemid,
+        ] = self::validate_parameters(self::remove_content_item_from_user_favourites_parameters(),
+            [
+                'componentname' => $componentname,
+                'contentitemid' => $contentitemid,
+            ]
+        );
+
+        self::validate_context(context_user::instance($USER->id));
+
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+
+        return $contentitemservice->remove_from_user_favourites($USER, $componentname, $contentitemid);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function remove_content_item_from_user_favourites_returns() {
+        return \core_course\local\exporters\course_content_item_exporter::get_read_structure();
+    }
+
     /**
      * Returns description of method result value
      *
      * @return external_description
      */
-    public static function fetch_modules_activity_chooser_returns() {
+    public static function get_course_content_items_returns() {
         return new external_single_structure([
-            'allmodules' => new external_multiple_structure(
-                new external_single_structure([
-                    'label' => new external_value(PARAM_TEXT, 'Human readable module name', VALUE_OPTIONAL),
-                    'modulename' => new external_value(PARAM_TEXT, 'Module name', VALUE_OPTIONAL),
-                    'description' => new external_value(PARAM_RAW, 'Help panel information', VALUE_OPTIONAL),
-                    'urls' => new external_single_structure([
-                        'addoption' => new external_value(PARAM_URL, 'The edit link for the module', VALUE_OPTIONAL),
-                    ]),
-                    'icon' => new external_single_structure([
-                        'attributes' => new external_multiple_structure(
-                            new external_single_structure([
-                                'name' => new external_value(PARAM_RAW, 'HTML attr', VALUE_OPTIONAL),
-                                'value' => new external_value(PARAM_RAW, 'Value of the HTML attr', VALUE_OPTIONAL),
-                            ])
-                        ),
-                        'extraclasses' => new external_value(PARAM_RAW, 'Anything extra the module defines', VALUE_OPTIONAL),
-                    ]),
-                ])
+            'content_items' => new external_multiple_structure(
+                \core_course\local\exporters\course_content_item_exporter::get_read_structure()
             ),
-            'warnings' => new external_warnings()
         ]);
     }
 
@@ -4176,7 +4257,7 @@ class core_course_external extends external_api {
      *
      * @return external_function_parameters
      */
-    public static function fetch_modules_activity_chooser_parameters() {
+    public static function get_course_content_items_parameters() {
         return new external_function_parameters([
             'courseid' => new external_value(PARAM_INT, 'ID of the course', VALUE_REQUIRED),
         ]);
@@ -4187,39 +4268,72 @@ class core_course_external extends external_api {
      *
      * @param int $courseid The course we want to fetch the modules for
      * @return array Contains array of modules and their metadata
-     * @throws moodle_exception
      */
-    public static function fetch_modules_activity_chooser(int $courseid) {
-        global $DB, $OUTPUT;
+    public static function get_course_content_items(int $courseid) {
+        global $USER;
+
         [
             'courseid' => $courseid,
-        ] = self::validate_parameters(self::fetch_modules_activity_chooser_parameters(), [
+        ] = self::validate_parameters(self::get_course_content_items_parameters(), [
             'courseid' => $courseid,
         ]);
-        $warnings = array();
 
-        // Validate the course context.
         $coursecontext = context_course::instance($courseid);
         self::validate_context($coursecontext);
-        // Check to see if user can add menus and there are modules to add.
-        if (!has_capability('moodle/course:manageactivities', $coursecontext)
-            || !($modnames = get_module_types_names()) || empty($modnames)) {
-            return '';
-        }
+        $course = get_course($courseid);
 
-        $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
-        // Retrieve all modules with associated metadata.
-        $modules = get_module_metadata($course, $modnames, null);
-        $related = [
-            'context' => $coursecontext
-        ];
-        // Export the module chooser data.
-        $modchooserdata = new \core_course\external\course_module_chooser_exporter($modules, $related);
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
 
-        $result = [];
-        $result['allmodules'] = $modchooserdata->export($OUTPUT)->options;
-        $result['warnings'] = $warnings;
-        return $result;
+        $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'),
+            ]
+        );
+    }
 }
index 5db9dea..d3af501 100644 (file)
@@ -383,12 +383,30 @@ class format_singleactivity extends format_base {
      * @return bool|null (null if the check is not possible)
      */
     public function activity_has_subtypes() {
+        global $USER;
         if (!($modname = $this->get_activitytype())) {
             return null;
         }
-        $metadata = get_module_metadata($this->get_course(), self::get_supported_activities());
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+        $metadata = $contentitemservice->get_content_items_for_user_in_course($USER, $this->get_course());
+
+        // If there are multiple items originating from this mod_xxx component, then it's deemed to have subtypes.
+        // If there is only 1 item, but it's not a reference to the core content item for the module, then it's also deemed to
+        // have subtypes.
+        $count = 0;
         foreach ($metadata as $key => $moduledata) {
-            if (preg_match('/^'.$modname.':/', $key)) {
+            if ('mod_'.$modname === $moduledata->componentname) {
+                $count ++;
+            }
+        }
+        if ($count > 1) {
+            return true;
+        } else {
+            // Get the single item.
+            $itemmetadata = $metadata[array_search('mod_' . $modname, array_column($metadata, 'componentname'))];
+            $urlbase = new \moodle_url('/course/mod.php', ['id' => $this->get_course()->id]);
+            $referenceurl = new \moodle_url($urlbase, ['add' => $modname]);
+            if ($referenceurl->out(false) != $itemmetadata->link) {
                 return true;
             }
         }
index 1c6dd3a..18bcfe9 100644 (file)
@@ -621,102 +621,6 @@ function set_section_visible($courseid, $sectionnumber, $visibility) {
     return $resourcestotoggle;
 }
 
-/**
- * Retrieve all metadata for the requested modules
- *
- * @param object $course The Course
- * @param array $modnames An array containing the list of modules and their
- * names
- * @param int $sectionreturn The section to return to
- * @return array A list of stdClass objects containing metadata about each
- * module
- */
-function get_module_metadata($course, $modnames, $sectionreturn = null) {
-    global $OUTPUT;
-
-    // get_module_metadata will be called once per section on the page and courses may show
-    // different modules to one another
-    static $modlist = array();
-    if (!isset($modlist[$course->id])) {
-        $modlist[$course->id] = array();
-    }
-
-    $return = array();
-    $urlbase = new moodle_url('/course/mod.php', array('id' => $course->id, 'sesskey' => sesskey()));
-    if ($sectionreturn !== null) {
-        $urlbase->param('sr', $sectionreturn);
-    }
-    foreach($modnames as $modname => $modnamestr) {
-        if (!course_allowed_module($course, $modname)) {
-            continue;
-        }
-        if (isset($modlist[$course->id][$modname])) {
-            // This module is already cached
-            $return += $modlist[$course->id][$modname];
-            continue;
-        }
-        $modlist[$course->id][$modname] = array();
-
-        // Create an object for a default representation of this module type in the activity chooser. It will be used
-        // if module does not implement callback get_shortcuts() and it will also be passed to the callback if it exists.
-        $defaultmodule = new stdClass();
-        $defaultmodule->title = $modnamestr;
-        $defaultmodule->name = $modname;
-        $defaultmodule->link = new moodle_url($urlbase, array('add' => $modname));
-        $defaultmodule->icon = $OUTPUT->pix_icon('icon', '', $defaultmodule->name, array('class' => 'icon'));
-        $sm = get_string_manager();
-        if ($sm->string_exists('modulename_help', $modname)) {
-            $defaultmodule->help = get_string('modulename_help', $modname);
-            if ($sm->string_exists('modulename_link', $modname)) {  // Link to further info in Moodle docs.
-                $link = get_string('modulename_link', $modname);
-                $linktext = get_string('morehelp');
-                $defaultmodule->help .= html_writer::tag('div',
-                    $OUTPUT->doc_link($link, $linktext, true), array('class' => 'helpdoclink'));
-            }
-        }
-        $defaultmodule->archetype = plugin_supports('mod', $modname, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
-
-        // Each module can implement callback modulename_get_shortcuts() in its lib.php and return the list
-        // of elements to be added to activity chooser.
-        $items = component_callback($modname, 'get_shortcuts', array($defaultmodule), null);
-        if ($items !== null) {
-            foreach ($items as $item) {
-                // Add all items to the return array. All items must have different links, use them as a key in the return array.
-                if (!isset($item->archetype)) {
-                    $item->archetype = $defaultmodule->archetype;
-                }
-                if (!isset($item->icon)) {
-                    $item->icon = $defaultmodule->icon;
-                }
-                // If plugin returned the only one item with the same link as default item - cache it as $modname,
-                // otherwise append the link url to the module name.
-                $item->name = (count($items) == 1 &&
-                    $item->link->out() === $defaultmodule->link->out()) ? $modname : $modname . ':' . $item->link;
-
-                // If the module provides the helptext property, append it to the help text to match the look and feel
-                // of the default course modules.
-                if (isset($item->help) && isset($item->helplink)) {
-                    $linktext = get_string('morehelp');
-                    $item->help .= html_writer::tag('div',
-                        $OUTPUT->doc_link($item->helplink, $linktext, true), array('class' => 'helpdoclink'));
-                }
-                $modlist[$course->id][$modname][$item->name] = $item;
-            }
-            $return += $modlist[$course->id][$modname];
-            // If get_shortcuts() callback is defined, the default module action is not added.
-            // It is a responsibility of the callback to add it to the return value unless it is not needed.
-            continue;
-        }
-
-        // The callback get_shortcuts() was not found, use the default item for the activity chooser.
-        $modlist[$course->id][$modname][$modname] = $defaultmodule;
-        $return[$modname] = $defaultmodule;
-    }
-
-    core_collator::asort_objects_by_property($return, 'title');
-    return $return;
-}
-
 /**
  * Return the course category context for the category with id $categoryid, except
  * that if $categoryid is 0, return the system context.
@@ -2155,9 +2059,12 @@ function course_format_name ($course,$max=100) {
  * Is the user allowed to add this type of module to this course?
  * @param object $course the course settings. Only $course->id is used.
  * @param string $modname the module name. E.g. 'forum' or 'quiz'.
+ * @param \stdClass $user the user to check, defaults to the global user if not provided.
  * @return bool whether the current user is allowed to add this type of module to this course.
  */
-function course_allowed_module($course, $modname) {
+function course_allowed_module($course, $modname, \stdClass $user = null) {
+    global $USER;
+    $user = $user ?? $USER;
     if (is_numeric($modname)) {
         throw new coding_exception('Function course_allowed_module no longer
                 supports numeric module ids. Please update your code to pass the module name.');
@@ -2179,7 +2086,7 @@ function course_allowed_module($course, $modname) {
     }
 
     $coursecontext = context_course::instance($course->id);
-    return has_capability($capability, $coursecontext);
+    return has_capability($capability, $coursecontext, $user);
 }
 
 /**
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();
index 83a329a..53cfd08 100644 (file)
@@ -268,20 +268,28 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     function course_section_add_cm_control($course, $section, $sectionreturn = null, $displayoptions = array()) {
-        global $CFG;
+        global $CFG, $PAGE, $USER;
 
         $vertical = !empty($displayoptions['inblock']);
 
-        // check to see if user can add menus and there are modules to add
+        // Check to see if user can add menus.
         if (!has_capability('moodle/course:manageactivities', context_course::instance($course->id))
-                || !$this->page->user_is_editing()
-                || !($modnames = get_module_types_names()) || empty($modnames)) {
+                || !$this->page->user_is_editing()) {
             return '';
         }
 
         // Retrieve all modules with associated metadata
-        $modules = get_module_metadata($course, $modnames, $sectionreturn);
-        $urlparams = array('section' => $section);
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+        $urlparams = ['section' => $section];
+        if (!is_null($sectionreturn)) {
+            $urlparams['sr'] = $sectionreturn;
+        }
+        $modules = $contentitemservice->get_content_items_for_user_in_course($USER, $course, $urlparams);
+
+        // Return if there are no content items to add.
+        if (empty($modules)) {
+            return '';
+        }
 
         // We'll sort resources and activities into two lists
         $activities = array(MOD_CLASS_ACTIVITY => array(), MOD_CLASS_RESOURCE => array());
@@ -294,7 +302,7 @@ class core_course_renderer extends plugin_renderer_base {
                 // System modules cannot be added by user, do not add to dropdown.
                 continue;
             }
-            $link = $module->link->out(true, $urlparams);
+            $link = $module->link;
             $activities[$activityclass][$link] = $module->title;
         }
 
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 c503c9b..46c0762 100644 (file)
 }}
 <div data-region="carousel" class="carousel slide">
     <div class="carousel-inner" aria-live="polite">
-        <div class="carousel-item active" data-region="modules">
-            <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
-                <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container">
-                    {{#default}}
-                        {{>core_course/chooser_item}}
-                    {{/default}}
+        <div class="carousel-item p-4 active" data-region="modules">
+            <ul class="nav nav-tabs mb-2" id="activities-{{uniqid}}" role="tablist">
+                <li class="nav-item">
+                    <a class="nav-link {{#favouritesFirst}}active{{/favouritesFirst}} {{^favourites}}d-none{{/favourites}}"
+                       id="starred-tab-{{uniqid}}"
+                       data-toggle="tab"
+                       data-region="favourite-tab-nav"
+                       href="#starred-{{uniqid}}"
+                       role="tab"
+                       aria-label="{{#str}} aria:favouritestab, core_course {{/str}}"
+                       aria-controls="starred-{{uniqid}}"
+                       aria-selected="{{#favouritesFirst}}true{{/favouritesFirst}}{{^favouritesFirst}}false{{/favouritesFirst}}"
+                       tabindex="{{#favouritesFirst}}0{{/favouritesFirst}}{{^favouritesFirst}}-1{{/favouritesFirst}}"
+                    >
+                        {{#str}}favourites{{/str}}
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link {{#recommendedFirst}}active{{/recommendedFirst}} {{^recommended}}d-none{{/recommended}}"
+                       id="recommended-tab-{{uniqid}}"
+                       data-region="recommended-tab-nav"
+                       data-toggle="tab"
+                       href="#recommended-{{uniqid}}"
+                       role="tab"
+                       aria-label="{{#str}} aria:recommendedtab, core_course {{/str}}"
+                       aria-controls="recommended-{{uniqid}}"
+                       aria-selected="{{#recommendedFirst}}true{{/recommendedFirst}}{{^recommendedFirst}}false{{/recommendedFirst}}"
+                       tabindex="{{#recommendedFirst}}0{{/recommendedFirst}}{{^recommendedFirst}}-1{{/recommendedFirst}}">
+                        {{#str}}recommended{{/str}}
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link {{#fallback}}active{{/fallback}}"
+                       id="all-tab-{{uniqid}}"
+                       data-toggle="tab"
+                       data-region="default-tab-nav"
+                       href="#all-{{uniqid}}"
+                       role="tab"
+                       aria-label="{{#str}} aria:defaulttab, core_course {{/str}}"
+                       aria-controls="all-{{uniqid}}"
+                       aria-selected="{{#fallback}}true{{/fallback}}{{^fallback}}false{{/fallback}}"
+                       tabindex="{{#fallback}}0{{/fallback}}{{^fallback}}-1{{/fallback}}"
+                    >
+                        {{#str}}activities{{/str}}
+                    </a>
+                </li>
+            </ul>
+            <div class="tab-content" id="tabbed-activities-{{uniqid}}">
+                <div class="tab-pane {{#favouritesFirst}}active{{/favouritesFirst}}" id="starred-{{uniqid}}" data-region="favourites" role="tabpanel" aria-labelledby="starred-tab-{{uniqid}}">
+                    <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
+                        <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container" data-render="favourites-area">
+                            {{#favourites}}
+                                {{>core_course/chooser_item}}
+                            {{/favourites}}
+                        </div>
+                    </div>
+                </div>
+                <div class="tab-pane {{#recommendedFirst}}active{{/recommendedFirst}}" id="recommended-{{uniqid}}" data-region="recommended" role="tabpanel" aria-labelledby="recommended-tab-{{uniqid}}">
+                    <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
+                        <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container">
+                            {{#recommended}}
+                                {{>core_course/chooser_item}}
+                            {{/recommended}}
+                        </div>
+                    </div>
+                </div>
+                <div class="tab-pane {{#fallback}}active{{/fallback}}" id="all-{{uniqid}}" data-region="default" role="tabpanel" aria-labelledby="all-tab-{{uniqid}}">
+                    <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
+                        <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container">
+                            {{#default}}
+                                {{>core_course/chooser_item}}
+                            {{/default}}
+                        </div>
+                    </div>
                 </div>
             </div>
         </div>
index ad4dda2..aaf4190 100644 (file)
 
     Example context (json):
     {
-        "label": "Option name",
-        "description": "Option description",
-        "urls": {
-            "addoption": "http://addoptionurl.com"
-        },
-        "icon": "<img class='icon' src='http://urltooptionicon'>"
+        "id": 125,
+        "name": "assign",
+        "title": "Assignment",
+        "link": "http://yourmoodle/modedit.php?id=x&itemtype=y",
+        "icon": "<img class='icon' src='http://urltooptionicon'>",
+        "help": "This is a description of the assignment activity",
+        "archetype": 0,
+        "componentname": "mod_assign",
+        "favourite": 1
     }
 }}
 <div class="optionsummary" tabindex="-1" data-region="chooser-option-summary-container" aria-labelledby="optionsummary_label" aria-describedby="optionsumary_desc">
     <div class="content text-left mb-5 px-5 py-4" data-region="chooser-option-summary-content-container">
         <div class="heading mb-4">
             <h5 id="optionsummary_label">
-                {{#icon}}
-                    {{>core/pix_icon}}
-                {{/icon}}
-                {{label}}
+                {{{icon}}}
+                {{title}}
             </h5>
         </div>
         <div id="optionsumary_desc" class="description" data-region="summary-description" tabindex="0">
-            {{{description}}}
+            {{{help}}}
         </div>
     </div>
     <div class="actions fixed-bottom w-100 d-flex justify-content-between position-absolute py-3 px-4" data-region="chooser-option-summary-actions-container">
-        <button data-action="close-chooser-option-summary" class="closeoptionsummary btn btn-secondary" tabindex="0" data-modname="{{modulename}}">
+        <button data-action="close-chooser-option-summary" class="closeoptionsummary btn btn-secondary" tabindex="0" data-modname="{{componentname}}_{{link}}">
             {{#str}} back {{/str}}
         </button>
-        <a href="{{urls.addoption}}" title="{{#str}} addnew, moodle, {{label}} {{/str}}" data-action="add-chooser-option" class="addoption btn btn-primary" tabindex="0">
+        <a href="{{link}}" title="{{#str}} addnew, moodle, {{title}} {{/str}}" data-action="add-chooser-option" class="addoption btn btn-primary" tabindex="0">
             {{#str}} add {{/str}}
         </a>
     </div>
index 92b930f..dd75b6a 100644 (file)
         "icon": "<img class='icon' src='http://urltooptionicon'>"
     }
 }}
-<div role="menuitem" tabindex="-1" aria-label="{{label}}" class="option d-block text-center py-3 px-2" data-region="chooser-option-container" data-modname="{{modulename}}">
+<div role="menuitem" tabindex="-1" aria-label="{{label}}" class="option d-block text-center py-3 px-2" data-region="chooser-option-container" data-modname="{{componentname}}_{{link}}">
     <div class="optioninfo w-100" data-region="chooser-option-info-container">
-        <a class="d-block" href="{{urls.addoption}}" title="{{#str}} addnew, moodle, {{label}} {{/str}}" tabindex="-1" data-action="add-chooser-option">
+        <a class="d-block" href="{{link}}" title="{{#str}} addnew, moodle, {{title}} {{/str}}" tabindex="-1" data-action="add-chooser-option">
             <span class="optionicon d-block">
-                {{#icon}}
-                    {{>core/pix_icon}}
-                {{/icon}}
+                {{{icon}}}
             </span>
-            <span class="optionname d-block">{{label}}</span>
+            <span class="optionname d-block">{{title}}</span>
         </a>
         <div class="optionactions btn-group" role="group" data-region="chooser-option-actions-container">
             <button class="btn btn-icon icon-no-margin icon-size-3 m-0 optionaction" data-action="show-option-summary" tabindex="-1">
                 <span aria-hidden="true">{{#pix}} docs, core {{/pix}}</span>
-                <span class="sr-only">{{#str}} informationformodule, core_course, {{label}} {{/str}}</span>
+                <span class="sr-only">{{#str}} informationformodule, core_course, {{title}} {{/str}}</span>
             </button>
         </div>
     </div>
index f8ec186..c48f250 100644 (file)
@@ -36,7 +36,7 @@
 <form action="{{searchurl}}" id="{{id}}" method="get" class="form-inline">
     <fieldset class="coursesearchbox invisiblefieldset">
         <label for="{{inputid}}">{{#str}}searchcourses{{/str}}</label>
-        <input id="{{inputid}}" name="q" type="text" size="{{inputsize}}" value="{{value}}" class="form-control">
+        <input id="{{inputid}}" name="q" type="text" size="{{inputsize}}" value="{{value}}" class="form-control mb-1 mb-sm-0">
         <input name="areaids" type="hidden" value="{{areaids}}">
         <button class="btn btn-secondary" type="submit">{{#str}}go{{/str}}</button>
         {{#helpicon}}
index 9d3f2b9..9fdf2af 100644 (file)
@@ -53,3 +53,18 @@ Feature: Display and choose from the available activities in course
     And "help" "core_course > Activity chooser screen" should not exist
     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"
+
+  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"
index 8453529..ed41a46 100644 (file)
@@ -54,6 +54,11 @@ class behat_course extends behat_base {
                     "%core_course/activityChooser%//*[@data-region=%locator%][contains(concat(' ', @class, ' '), ' carousel-item ')]"
                 ]
             ),
+            new behat_component_named_selector(
+                'Activity chooser tab', [
+                    "%core_course/activityChooser%//*[@data-region=%locator%][contains(concat(' ', @class, ' '), ' tab-pane ')]"
+                ]
+            ),
         ];
     }
 
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
diff --git a/course/tests/caching_content_item_readonly_repository_test.php b/course/tests/caching_content_item_readonly_repository_test.php
new file mode 100644 (file)
index 0000000..6ad37f1
--- /dev/null
@@ -0,0 +1,81 @@
+<?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 the test class for the caching_content_item_readonly_repository class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\repository\content_item_readonly_repository;
+use core_course\local\repository\caching_content_item_readonly_repository;
+
+/**
+ * The test class for the caching_content_item_readonly_repository class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class caching_content_item_readonly_repository_testcase extends \advanced_testcase {
+    /**
+     * Test verifying that content items are cached and returned from the cache in subsequent same-request calls.
+     */
+    public function test_find_all_for_course() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cir = new content_item_readonly_repository();
+        $ccir = new caching_content_item_readonly_repository(\cache::make('core', 'user_course_content_items'), $cir);
+
+        // Get the content items using both the live and the caching repos.
+        $items = $cir->find_all_for_course($course, $user);
+        $cacheditems = $ccir->find_all_for_course($course, $user);
+        $itemsfiltered = array_filter($items, function($item) {
+            return $item->get_component_name() == 'mod_assign';
+        });
+        $cacheditemsfiltered = array_filter($cacheditems, function($item) {
+            return $item->get_component_name() == 'mod_assign';
+        });
+
+        // Verify the assign module is in both result sets.
+        $module = $DB->get_record('modules', ['name' => 'assign']);
+        $this->assertEquals($module->name, $itemsfiltered[0]->get_name());
+        $this->assertEquals($module->name, $cacheditemsfiltered[0]->get_name());
+
+        // Hide a module and get the content items again.
+        $DB->set_field("modules", "visible", "0", ["id" => $module->id]);
+        $items = $cir->find_all_for_course($course, $user);
+        $cacheditems = $ccir->find_all_for_course($course, $user);
+        $itemsfiltered = array_filter($items, function($item) {
+            return $item->get_component_name() == 'mod_assign';
+        });
+        $cacheditemsfiltered = array_filter($cacheditems, function($item) {
+            return $item->get_component_name() == 'mod_assign';
+        });
+
+        // The caching repo should return the same list, while the live repo will return the updated list.
+        $this->assertEquals($module->name, $cacheditemsfiltered[0]->get_name());
+        $this->assertEmpty($itemsfiltered);
+    }
+}
diff --git a/course/tests/content_item_readonly_repository_test.php b/course/tests/content_item_readonly_repository_test.php
new file mode 100644 (file)
index 0000000..d8e7998
--- /dev/null
@@ -0,0 +1,104 @@
+<?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 the test class for the content_item_readonly_repository class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\entity\content_item;
+use core_course\local\repository\content_item_readonly_repository;
+
+/**
+ * The test class for the content_item_readonly_repository class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_readonly_repository_testcase extends \advanced_testcase {
+    /**
+     * Test the repository method, find_all_for_course().
+     */
+    public function test_find_all_for_course() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cir = new content_item_readonly_repository();
+
+        $items = $cir->find_all_for_course($course, $user);
+        foreach ($items as $key => $item) {
+            $this->assertInstanceOf(content_item::class, $item);
+            $this->assertEquals($course->id, $item->get_link()->param('id'));
+            $this->assertNotNull($item->get_link()->param('add'));
+        }
+    }
+
+    /**
+     * Test verifying that content items for hidden modules are not returned.
+     */
+    public function test_find_all_for_course_hidden_module() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cir = new content_item_readonly_repository();
+
+        // Hide a module.
+        $module = $DB->get_record('modules', ['id' => 1]);
+        $DB->set_field("modules", "visible", "0", ["id" => $module->id]);
+
+        $items = $cir->find_all_for_course($course, $user);
+        $this->assertArrayNotHasKey($module->name, $items);
+    }
+
+    /**
+     * Test confirming that all content items can be fetched, even those which require certain caps when in a course.
+     */
+    public function test_find_all() {
+        $this->resetAfterTest();
+        global $DB;
+
+        // We'll compare our results to those which are course-specific.
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        assign_capability('mod/lti:addmanualinstance', CAP_PROHIBIT, $teacherrole->id, \context_course::instance($course->id));
+        $cir = new content_item_readonly_repository();
+
+        // Course specific - lti won't be returned as the user doesn't have the required cap.
+        $forcourse = $cir->find_all_for_course($course, $user);
+        $forcourse = array_filter($forcourse, function($contentitem) {
+            return $contentitem->get_name() === 'lti';
+        });
+        $this->assertEmpty($forcourse);
+
+        // All - all items are returned, including lti.
+        $all = $cir->find_all();
+        $all = array_filter($all, function($contentitem) {
+            return $contentitem->get_name() === 'lti';
+        });
+        $this->assertCount(1, $all);
+    }
+}
diff --git a/course/tests/content_item_test.php b/course/tests/content_item_test.php
new file mode 100644 (file)
index 0000000..174529d
--- /dev/null
@@ -0,0 +1,73 @@
+<?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 tests for the \core_course\local\entity\content_item class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\entity\content_item;
+use core_course\local\entity\lang_string_title;
+use core_course\local\entity\string_title;
+
+/**
+ * Tests for the \core_course\local\entity\content_item class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_testcase extends \advanced_testcase {
+
+    /**
+     * Test the content_item class.
+     */
+    public function test_content_item() {
+        $this->resetAfterTest();
+
+        $contentitem = new content_item(22, 'Item name', new lang_string_title('modulename', 'mod_assign'),
+            new \moodle_url('mod_edit.php'), '<img src="test">', 'Description of the module', MOD_ARCHETYPE_RESOURCE, 'mod_page');
+
+        $this->assertEquals(22, $contentitem->get_id());
+        $this->assertEquals('Item name', $contentitem->get_name());
+        $this->assertEquals('Assignment', $contentitem->get_title()->get_value());
+        $this->assertEquals(new \moodle_url('mod_edit.php'), $contentitem->get_link());
+        $this->assertEquals('<img src="test">', $contentitem->get_icon());
+        $this->assertEquals('Description of the module', $contentitem->get_help());
+        $this->assertEquals(MOD_ARCHETYPE_RESOURCE, $contentitem->get_archetype());
+        $this->assertEquals('mod_page', $contentitem->get_component_name());
+    }
+
+    /**
+     * Test confirming that plugins can return custom titles for a content item.
+     */
+    public function test_content_item_custom_string_title() {
+        $this->resetAfterTest();
+
+        $contentitem = new content_item(22, 'Item name', new string_title('My custom string'),
+            new \moodle_url('mod_edit.php'), '<img src="test">', 'Description of the module', MOD_ARCHETYPE_RESOURCE, 'mod_page');
+
+        $this->assertEquals('My custom string', $contentitem->get_title()->get_value());
+    }
+}
index 3a39c14..2fcc9be 100644 (file)
@@ -6923,4 +6923,26 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->setAdminUser();
         $this->assertTrue($request2->can_approve());
     }
+
+    /**
+     * Test the course allowed module method.
+     */
+    public function test_course_allowed_module() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $manager = $this->getDataGenerator()->create_and_enrol($course, 'manager');
+
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        assign_capability('mod/assign:addinstance', CAP_PROHIBIT, $teacherrole->id, \context_course::instance($course->id));
+
+        // Global user (teacher) has no permissions in this course.
+        $this->setUser($teacher);
+        $this->assertFalse(course_allowed_module($course, 'assign'));
+
+        // Manager has permissions.
+        $this->assertTrue(course_allowed_module($course, 'assign', $manager));
+    }
 }
diff --git a/course/tests/exporters_content_item_test.php b/course/tests/exporters_content_item_test.php
new file mode 100644 (file)
index 0000000..872ece6
--- /dev/null
@@ -0,0 +1,121 @@
+<?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 the tests for the course_content_item_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\exporters\course_content_item_exporter;
+use core_course\local\repository\content_item_readonly_repository;
+
+/**
+ * The tests for the course_content_item_exporter class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporters_course_content_item_testcase extends \advanced_testcase {
+
+    /**
+     * Test confirming a content_item can be exported for a course.
+     */
+    public function test_export_course_content_item() {
+        $this->resetAfterTest();
+        global $PAGE;
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cir = new content_item_readonly_repository();
+        $contentitems = $cir->find_all_for_course($course, $user);
+        $contentitem = array_shift($contentitems);
+
+        $ciexporter = new course_content_item_exporter($contentitem, ['context' => \context_course::instance($course->id)]);
+        $renderer = $PAGE->get_renderer('core');
+        $exporteditem = $ciexporter->export($renderer);
+
+        $this->assertObjectHasAttribute('id', $exporteditem);
+        $this->assertEquals($exporteditem->id, $contentitem->get_id());
+        $this->assertObjectHasAttribute('name', $exporteditem);
+        $this->assertEquals($exporteditem->name, $contentitem->get_name());
+        $this->assertObjectHasAttribute('title', $exporteditem);
+        $this->assertEquals($exporteditem->title, $contentitem->get_title()->get_value());
+        $this->assertObjectHasAttribute('link', $exporteditem);
+        $this->assertEquals($exporteditem->link, $contentitem->get_link()->out(false));
+        $this->assertObjectHasAttribute('icon', $exporteditem);
+        $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
+        $this->assertObjectHasAttribute('help', $exporteditem);
+        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertObjectHasAttribute('archetype', $exporteditem);
+        $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
+        $this->assertObjectHasAttribute('componentname', $exporteditem);
+        $this->assertEquals($exporteditem->componentname, $contentitem->get_component_name());
+        $this->assertObjectHasAttribute('legacyitem', $exporteditem);
+        $this->assertFalse($exporteditem->legacyitem);
+    }
+
+    /**
+     * Test that legacy items (with id of -1) are exported correctly.
+     */
+    public function test_export_course_content_item_legacy() {
+        $this->resetAfterTest();
+        global $PAGE;
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $contentitem = new \core_course\local\entity\content_item(
+            -1,
+            'test_name',
+            new \core_course\local\entity\string_title('test_title'),
+            new \moodle_url(''),
+            '',
+            '',
+            MOD_ARCHETYPE_OTHER,
+            'core_test'
+        );
+
+        $ciexporter = new course_content_item_exporter($contentitem, ['context' => \context_course::instance($course->id)]);
+        $renderer = $PAGE->get_renderer('core');
+        $exporteditem = $ciexporter->export($renderer);
+
+        $this->assertObjectHasAttribute('id', $exporteditem);
+        $this->assertEquals($exporteditem->id, $contentitem->get_id());
+        $this->assertObjectHasAttribute('name', $exporteditem);
+        $this->assertEquals($exporteditem->name, $contentitem->get_name());
+        $this->assertObjectHasAttribute('title', $exporteditem);
+        $this->assertEquals($exporteditem->title, $contentitem->get_title()->get_value());
+        $this->assertObjectHasAttribute('link', $exporteditem);
+        $this->assertEquals($exporteditem->link, $contentitem->get_link()->out(false));
+        $this->assertObjectHasAttribute('icon', $exporteditem);
+        $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
+        $this->assertObjectHasAttribute('help', $exporteditem);
+        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertObjectHasAttribute('archetype', $exporteditem);
+        $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
+        $this->assertObjectHasAttribute('componentname', $exporteditem);
+        $this->assertEquals($exporteditem->componentname, $contentitem->get_component_name());
+        // Most important, is this a legacy item?
+        $this->assertObjectHasAttribute('legacyitem', $exporteditem);
+        $this->assertTrue($exporteditem->legacyitem);
+    }
+}
diff --git a/course/tests/exporters_content_items_test.php b/course/tests/exporters_content_items_test.php
new file mode 100644 (file)
index 0000000..0e6065a
--- /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 the tests for the course_content_items_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\exporters\course_content_items_exporter;
+use core_course\local\repository\content_item_readonly_repository;
+
+/**
+ * The tests for the course_content_items_exporter class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporters_course_content_items_testcase extends \advanced_testcase {
+
+    /**
+     * Test confirming the collection of content_items can be exported for a course.
+     */
+    public function test_export_course_content_items() {
+        $this->resetAfterTest();
+        global $PAGE;
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $cir = new content_item_readonly_repository();
+        $contentitems = $cir->find_all_for_course($course, $user);
+
+        $ciexporter = new course_content_items_exporter($contentitems, ['context' => \context_course::instance($course->id)]);
+        $renderer = $PAGE->get_renderer('core');
+        $exportedcontentitems = $ciexporter->export($renderer);
+
+        $this->assertObjectHasAttribute('content_items', $exportedcontentitems);
+        foreach ($exportedcontentitems->content_items as $key => $dto) {
+            $this->assertObjectHasAttribute('id', $dto);
+            $this->assertObjectHasAttribute('name', $dto);
+            $this->assertObjectHasAttribute('title', $dto);
+            $this->assertObjectHasAttribute('link', $dto);
+            $this->assertObjectHasAttribute('icon', $dto);
+            $this->assertObjectHasAttribute('help', $dto);
+            $this->assertObjectHasAttribute('archetype', $dto);
+            $this->assertObjectHasAttribute('componentname', $dto);
+        }
+    }
+}
index b2f1671..c1877c1 100644 (file)
@@ -3051,37 +3051,151 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
     }
 
     /**
-     * Test fetch_modules_activity_chooser
+     * Verify that content items can be added to user favourites.
      */
-    public function test_fetch_modules_activity_chooser() {
-        global $OUTPUT;
+    public function test_add_content_item_to_user_favourites() {
+        $this->resetAfterTest();
 
-        $this->resetAfterTest(true);
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($user);
 
-        // Log in as Admin.
-        $this->setAdminUser();
+        // Using the internal API, confirm that no items are set as favourites for the user.
+        $contentitemservice = new \core_course\local\service\content_item_service(
+            new \core_course\local\repository\content_item_readonly_repository()
+        );
+        $contentitems = $contentitemservice->get_all_content_items($user);
+        $favourited = array_filter($contentitems, function($contentitem) {
+            return $contentitem->favourite == true;
+        });
+        $this->assertCount(0, $favourited);
+
+        // Using the external API, favourite a content item for the user.
+        $assign = $contentitems[array_search('assign', array_column($contentitems, 'name'))];
+        $contentitem = core_course_external::add_content_item_to_user_favourites('mod_assign', $assign->id, $user->id);
+        $contentitem = external_api::clean_returnvalue(core_course_external::add_content_item_to_user_favourites_returns(),
+            $contentitem);
+
+        // Verify the returned item is a favourite.
+        $this->assertTrue($contentitem['favourite']);
+
+        // Using the internal API, confirm we see a single favourite item.
+        $contentitems = $contentitemservice->get_all_content_items($user);
+        $favourited = array_values(array_filter($contentitems, function($contentitem) {
+            return $contentitem->favourite == true;
+        }));
+        $this->assertCount(1, $favourited);
+        $this->assertEquals('assign', $favourited[0]->name);
+    }
 
-        $course1  = self::getDataGenerator()->create_course();
+    /**
+     * Verify that content items can be removed from user favourites.
+     */
+    public function test_remove_content_item_from_user_favourites() {
+        $this->resetAfterTest();
 
-        // Fetch course modules.
-        $result = core_course_external::fetch_modules_activity_chooser($course1->id);
-        $result = external_api::clean_returnvalue(core_course_external::fetch_modules_activity_chooser_returns(), $result);
-        // Check for 0 warnings.
-        $this->assertEquals(0, count($result['warnings']));
-        // Check we have the right number of standard modules.
-        $this->assertEquals(21, count($result['allmodules']));
-
-        $coursecontext = context_course::instance($course1->id);
-        $modnames = get_module_types_names();
-        $modules = get_module_metadata($course1, $modnames, null);
-        $related = [
-            'context' => $coursecontext
-        ];
-        // Export the module chooser data.
-        $modchooserdata = new \core_course\external\course_module_chooser_exporter($modules, $related);
-        $formatteddata = $modchooserdata->export($OUTPUT)->options;
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($user);
+
+        // Using the internal API, set a favourite for the user.
+        $contentitemservice = new \core_course\local\service\content_item_service(
+            new \core_course\local\repository\content_item_readonly_repository()
+        );
+        $contentitems = $contentitemservice->get_all_content_items($user);
+        $assign = $contentitems[array_search('assign', array_column($contentitems, 'name'))];
+        $contentitemservice->add_to_user_favourites($user, $assign->componentname, $assign->id);
+
+        $contentitems = $contentitemservice->get_all_content_items($user);
+        $favourited = array_filter($contentitems, function($contentitem) {
+            return $contentitem->favourite == true;
+        });
+        $this->assertCount(1, $favourited);
+
+        // Now, verify the external API can remove the favourite.
+        $contentitem = core_course_external::remove_content_item_from_user_favourites('mod_assign', $assign->id);
+        $contentitem = external_api::clean_returnvalue(core_course_external::remove_content_item_from_user_favourites_returns(),
+            $contentitem);
+
+        // Verify the returned item is a favourite.
+        $this->assertFalse($contentitem['favourite']);
+
+        // Using the internal API, confirm we see no favourite items.
+        $contentitems = $contentitemservice->get_all_content_items($user);
+        $favourited = array_filter($contentitems, function($contentitem) {
+            return $contentitem->favourite == true;
+        });
+        $this->assertCount(0, $favourited);
+    }
+
+    /**
+     * Test the web service returning course content items for inclusion in activity choosers, etc.
+     */
+    public function test_get_course_content_items() {
+        $this->resetAfterTest();
+
+        $course  = self::getDataGenerator()->create_course();
+        $user = self::getDataGenerator()->create_and_enrol($course, 'editingteacher');
+
+        // Fetch available content items as the editing teacher.
+        $this->setUser($user);
+        $result = core_course_external::get_course_content_items($course->id);
+        $result = external_api::clean_returnvalue(core_course_external::get_course_content_items_returns(), $result);
 
-        // Check if the webservice returns exactly what the exporter defines.
-        $this->assertEquals($formatteddata, $result['allmodules']);
+        $contentitemservice = new \core_course\local\service\content_item_service(
+            new \core_course\local\repository\content_item_readonly_repository()
+        );
+
+        // Check if the webservice returns exactly what the service defines, albeit in array form.
+        $serviceitemsasarray = array_map(function($item) {
+            return (array) $item;
+        }, $contentitemservice->get_content_items_for_user_in_course($user, $course));
+
+        $this->assertEquals($serviceitemsasarray, $result['content_items']);
+    }
+
+    /**
+     * Test the web service returning course content items, specifically in case where the user can't manage activities.
+     */
+    public function test_get_course_content_items_no_permission_to_manage() {
+        $this->resetAfterTest();
+
+        $course  = self::getDataGenerator()->create_course();
+        $user = self::getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Fetch available content items as a student, who won't have the permission to manage activities.
+        $this->setUser($user);
+        $result = core_course_external::get_course_content_items($course->id);
+        $result = external_api::clean_returnvalue(core_course_external::get_course_content_items_returns(), $result);
+
+        $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']);
     }
 }
diff --git a/course/tests/services_content_item_service_test.php b/course/tests/services_content_item_service_test.php
new file mode 100644 (file)
index 0000000..32e76ce
--- /dev/null
@@ -0,0 +1,228 @@
+<?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 the tests for the content_item_service class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\course;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_course\local\service\content_item_service;
+use \core_course\local\repository\content_item_readonly_repository;
+
+/**
+ * The tests for the content_item_service class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class services_content_item_service_testcase extends \advanced_testcase {
+
+    /**
+     * Test confirming that content items are returned by the service.
+     */
+    public function test_get_content_items_for_user_in_course_basic() {
+        $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());
+        $contentitems = $cis->get_content_items_for_user_in_course($user, $course);
+
+        foreach ($contentitems as $key => $contentitem) {
+            $this->assertObjectHasAttribute('id', $contentitem);
+            $this->assertObjectHasAttribute('name', $contentitem);
+            $this->assertObjectHasAttribute('title', $contentitem);
+            $this->assertObjectHasAttribute('link', $contentitem);
+            $this->assertObjectHasAttribute('icon', $contentitem);
+            $this->assertObjectHasAttribute('help', $contentitem);
+            $this->assertObjectHasAttribute('archetype', $contentitem);
+            $this->assertObjectHasAttribute('componentname', $contentitem);
+        }
+    }
+
+    /**
+     * Test confirming that access control is performed when asking the service to return content items for a user in a course.
+     */
+    public function test_get_content_items_for_user_in_course_permissions() {
+        $this->resetAfterTest();
+        global $DB;
+
+        // Create a user in a course.
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+
+        // No cap override, so assign should be returned.
+        $cis = new content_item_service(new content_item_readonly_repository());
+        $contentitems = $cis->get_content_items_for_user_in_course($user, $course);
+        $this->assertContains('assign', array_column($contentitems, 'name'));
+
+        // Override the capability 'mod/assign:addinstance' for the 'editing teacher' role.
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        assign_capability('mod/assign:addinstance', CAP_PROHIBIT, $teacherrole->id, \context_course::instance($course->id));
+
+        $contentitems = $cis->get_content_items_for_user_in_course($user, $course);
+        $this->assertArrayNotHasKey('assign', $contentitems);
+    }
+
+    /**
+     * Test confirming that params can be added to the content item's link.
+     */
+    public function test_get_content_item_for_user_in_course_link_params() {
+        $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());
+        $contentitems = $cis->get_content_items_for_user_in_course($user, $course, ['sr' => 7]);
+
+        foreach ($contentitems as $item) {
+            $this->assertStringContainsString('sr=7', $item->link);
+        }
+    }
+
+    /**
+     * Test confirming that all content items can be fetched irrespective of permissions.
+     */
+    public function test_get_all_content_items() {
+        $this->resetAfterTest();
+        global $DB;
+
+        // 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());
+        $allcontentitems = $cis->get_all_content_items($user);
+        $coursecontentitems = $cis->get_content_items_for_user_in_course($user, $course);
+
+        // The call to get_all_content_items() should return the same items as for the course,
+        // given the user in an editing teacher and can add manual lti instances.
+        $this->assertEquals(array_column($allcontentitems, 'name'), array_column($coursecontentitems, 'name'));
+
+        // Now removing the cap 'mod/lti:addinstance'. This will restrict those items returned by the course-specific method.
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        assign_capability('mod/lti:addinstance', CAP_PROHIBIT, $teacherrole->id, \context_course::instance($course->id));
+
+        // Verify that all items, including lti, are still returned by the get_all_content_items() call.
+        $allcontentitems = $cis->get_all_content_items($user);
+        $coursecontentitems = $cis->get_content_items_for_user_in_course($user, $course);
+        $this->assertNotContains('lti', array_column($coursecontentitems, 'name'));
+        $this->assertContains('lti', array_column($allcontentitems, 'name'));
+    }
+
+    /**
+     * Test confirming that a content item can be added to a user's favourites.
+     */
+    public function test_add_to_user_favourites() {
+        $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 favourite for the user.
+        $items = $cis->get_all_content_items($user);
+        $assign = $items[array_search('assign', array_column($items, 'name'))];
+        $contentitem = $cis->add_to_user_favourites($user, 'mod_assign', $assign->id);
+
+        // Verify the exported result is marked as a favourite.
+        $this->assertEquals('assign', $contentitem->name);
+        $this->assertTrue($contentitem->favourite);
+
+        // Verify the item is marked as a favourite when returned from the other service methods.
+        $allitems = $cis->get_all_content_items($user);
+        $allitemsassign = $allitems[array_search('assign', array_column($allitems, 'name'))];
+
+        $courseitems = $cis->get_content_items_for_user_in_course($user, $course);
+        $courseitemsassign = $courseitems[array_search('assign', array_column($courseitems, 'name'))];
+        $this->assertTrue($allitemsassign->favourite);
+        $this->assertTrue($courseitemsassign->favourite);
+    }
+
+    /**
+     * Test verifying that content items can be removed from a user's favourites.
+     */
+    public function test_remove_from_user_favourites() {
+        $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 favourite for the user.
+        $items = $cis->get_all_content_items($user);
+        $assign = $items[array_search('assign', array_column($items, 'name'))];
+        $cis->add_to_user_favourites($user, 'mod_assign', $assign->id);
+
+        // Now, remove the favourite, and verify it.
+        $contentitem = $cis->remove_from_user_favourites($user, 'mod_assign', $assign->id);
+
+        // Verify the exported result is not marked as a favourite.
+        $this->assertEquals('assign', $contentitem->name);
+        $this->assertFalse($contentitem->favourite);
+
+        // Verify the item is not marked as a favourite when returned from the other service methods.
+        $allitems = $cis->get_all_content_items($user);
+        $allitemsassign = $allitems[array_search('assign', array_column($allitems, 'name'))];
+        $courseitems = $cis->get_content_items_for_user_in_course($user, $course);
+        $courseitemsassign = $courseitems[array_search('assign', array_column($courseitems, 'name'))];
+        $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 d48e163..f88a58b 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
+=== 3.9 ===
+
+* The function get_module_metadata is now deprecated. Please use \core_course\local\service\content_item_service instead.
+
 === 3.8 ===
 
 * The following functions have been finally deprecated and can not be used any more:
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';
diff --git a/h5p/classes/api.php b/h5p/classes/api.php
new file mode 100644 (file)
index 0000000..c1087d1
--- /dev/null
@@ -0,0 +1,106 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains API class for the H5P area.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Contains API class for the H5P area.
+ *
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api {
+
+    /**
+     * Delete a library and also all the libraries depending on it and the H5P contents using it. For the H5P content, only the
+     * database entries in {h5p} are removed (the .h5p files are not removed in order to let users to deploy them again).
+     *
+     * @param  factory   $factory The H5P factory.
+     * @param  \stdClass $library The library to delete.
+     */
+    public static function delete_library(factory $factory, \stdClass $library): void {
+        global $DB;
+
+        // Get the H5P contents using this library, to remove them from DB. The .h5p files won't be removed
+        // so they will be displayed by the player next time a user with the proper permissions accesses it.
+        $sql = 'SELECT DISTINCT hcl.h5pid
+                  FROM {h5p_contents_libraries} hcl
+                 WHERE hcl.libraryid = :libraryid';
+        $params = ['libraryid' => $library->id];
+        $h5pcontents = $DB->get_records_sql($sql, $params);
+        foreach ($h5pcontents as $h5pcontent) {
+            $factory->get_framework()->deleteContentData($h5pcontent->h5pid);
+        }
+
+        $fs = $factory->get_core()->fs;
+        $framework = $factory->get_framework();
+        // Delete the library from the file system.
+        $fs->delete_library(array('libraryId' => $library->id));
+        // Delete also the cache assets to rebuild them next time.
+        $framework->deleteCachedAssets($library->id);
+
+        // Remove library data from database.
+        $DB->delete_records('h5p_library_dependencies', array('libraryid' => $library->id));
+        $DB->delete_records('h5p_libraries', array('id' => $library->id));
+
+        // Remove the libraries using this library.
+        $requiredlibraries = self::get_dependent_libraries($library->id);
+        foreach ($requiredlibraries as $requiredlibrary) {
+            self::delete_library($factory, $requiredlibrary);
+        }
+    }
+
+    /**
+     * Get all the libraries using a defined library.
+     *
+     * @param  int    $libraryid The library to get its dependencies.
+     * @return array  List of libraryid with all the libraries required by a defined library.
+     */
+    public static function get_dependent_libraries(int $libraryid): array {
+        global $DB;
+
+        $sql = 'SELECT DISTINCT hl.*
+                  FROM {h5p_library_dependencies} hld
+                  JOIN {h5p_libraries} hl ON hl.id = hld.libraryid
+                 WHERE hld.requiredlibraryid = :libraryid';
+        $params = ['libraryid' => $libraryid];
+
+        return $DB->get_records_sql($sql, $params);
+    }
+
+    /**
+     * Get a library from an identifier.
+     *
+     * @param  int    $libraryid The library identifier.
+     * @return \stdClass The library object having the library identifier defined.
+     * @throws dml_exception A DML specific exception is thrown if the libraryid doesn't exist.
+     */
+    public static function get_library(int $libraryid): \stdClass {
+        global $DB;
+
+        return $DB->get_record('h5p_libraries', ['id' => $libraryid], '*', MUST_EXIST);
+    }
+}
index a3091b7..829b474 100644 (file)
@@ -52,6 +52,6 @@ class uploadlibraries_form extends \moodleform {
         $mform->addHelpButton('h5ppackage', 'h5ppackage', 'core_h5p');
         $mform->addRule('h5ppackage', null, 'required');
 
-        $this->add_action_buttons(false, get_string('uploadlibraries', 'core_h5p'));
+        $mform->addElement('submit', 'uploadlibraries', get_string('uploadlibraries', 'core_h5p'));
     }
 }
index 4818d86..e6a13ed 100644 (file)
@@ -1133,15 +1133,8 @@ class framework implements \H5PFrameworkInterface {
      * @param stdClass $library Library object with id, name, major version and minor version
      */
     public function deleteLibrary($library) {
-        global $DB;
-
-        $fs = new \core_h5p\file_storage();
-        // Delete the library from the file system.
-        $fs->delete_library(array('libraryId' => $library->id));
-
-        // Remove library data from database.
-        $DB->delete_records('h5p_library_dependencies', array('libraryid' => $library->id));
-        $DB->delete_records('h5p_libraries', array('id' => $library->id));
+        $factory = new \core_h5p\factory();
+        \core_h5p\api::delete_library($factory, $library);
     }
 
     /**
@@ -1259,28 +1252,38 @@ class framework implements \H5PFrameworkInterface {
     }
 
     /**
-     * Get the default behaviour for the display option defined.
+     * Get stored setting.
      * Implements getOption.
      *
      * @param string $name Identifier for the setting
      * @param string $default Optional default value if settings is not set
-     * @return mixed Return The default \H5PDisplayOptionBehaviour for this display option
+     * @return mixed Return  Whatever has been stored as the setting
      */
     public function getOption($name, $default = false) {
-        // TODO: Define the default behaviour for each display option.
-        // For now, all them are disabled by default, so only will be rendered when defined in the displayoptions DB field.
-        return \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        if ($name == core::DISPLAY_OPTION_DOWNLOAD || $name == core::DISPLAY_OPTION_EMBED) {
+            // For now, the download and the embed displayoptions are disabled by default, so only will be rendered when
+            // defined in the displayoptions DB field.
+            // This check should be removed if they are added as new H5P settings, to let admins to define the default value.
+            return \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        }
+
+        $value = get_config('core_h5p', $name);
+        if ($value === false) {
+            return $default;
+        }
+        return $value;
     }
 
     /**
      * Stores the given setting.
+     * For example when did we last check h5p.org for updates to our libraries.
      * Implements setOption.
      *
      * @param string $name Identifier for the setting
      * @param mixed $value Data Whatever we want to store as the setting
      */
     public function setOption($name, $value) {
-        // Currently not storing settings.
+        set_config($name, $value, 'core_h5p');
     }
 
     /**
@@ -1451,6 +1454,10 @@ class framework implements \H5PFrameworkInterface {
             list($sql, $params) = $DB->get_in_or_equal($hashes, SQL_PARAMS_NAMED);
             // Remove all invalid keys.
             $DB->delete_records_select('h5p_libraries_cachedassets', 'hash ' . $sql, $params);
+
+            // Remove also the cachedassets files.
+            $fs = new file_storage();
+            $fs->deleteCachedAssets($hashes);
         }
 
         return $hashes;
diff --git a/h5p/classes/output/libraries.php b/h5p/classes/output/libraries.php
new file mode 100644 (file)
index 0000000..07a8fe9
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class core_h5p\output\libraries
+ *
+ * @package   core_h5p
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+use moodle_url;
+use action_menu;
+use action_menu_link;
+use pix_icon;
+
+/**
+ * Class to help display H5P library management table.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class libraries implements renderable, templatable {
+
+    /** @var H5P factory */
+    protected $factory;
+
+    /** @var H5P library list */
+    protected $libraries;
+
+    /**
+     * Constructor.
+     *
+     * @param factory $factory The H5P factory
+     * @param array $libraries array of h5p libraries records
+     */
+    public function __construct(\core_h5p\factory $factory, array $libraries) {
+        $this->factory = $factory;
+        $this->libraries = $libraries;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        $installed = [];
+        $filestorage = $this->factory->get_core()->fs;
+        foreach ($this->libraries as $libraryname => $versions) {
+            foreach ($versions as $version) {
+                // Get the icon URL.
+                $version->icon = $filestorage->get_icon_url(
+                    $version->id,
+                    $version->machine_name,
+                    $version->major_version,
+                    $version->minor_version
+                );
+                // Get the action menu options.
+                $actionmenu = new action_menu();
+                $actionmenu->set_menu_trigger(get_string('actions', 'core_h5p'));
+                $actionmenu->set_alignment(action_menu::TL, action_menu::BL);
+                $actionmenu->prioritise = true;
+                $actionmenu->add_primary_action(new action_menu_link(
+                    new moodle_url('/h5p/libraries.php', ['deletelibrary' => $version->id]),
+                    new pix_icon('t/delete', get_string('deletelibraryversion', 'core_h5p')),
+                    get_string('deletelibraryversion', 'core_h5p')
+                ));
+                $version->actionmenu = $actionmenu->export_for_template($output);
+                $installed[] = $version;
+            }
+        }
+        $r = new stdClass();
+        $r->contenttypes = $installed;
+        return $r;
+    }
+}
index 6ef87ab..f9d32ab 100644 (file)
@@ -348,12 +348,15 @@ class player {
         $url->remove_params(array_keys($url->params()));
         $path = $url->out_as_local_url();
 
+        // We only need the slasharguments.
+        $path = substr($path, strpos($path, '.php/') + 5);
         $parts = explode('/', $path);
         $filename = array_pop($parts);
-        // First is an empty row and then the pluginfile.php part. Both can be ignored.
-        array_shift($parts);
-        array_shift($parts);
 
+        // If the request is made by tokenpluginfile.php we need to avoid userprivateaccesskey.
+        if (strpos($this->url, '/tokenpluginfile.php')) {
+            array_shift($parts);
+        }
         // Get the contextid, component and filearea.
         $contextid = array_shift($parts);
         $component = array_shift($parts);
@@ -515,20 +518,40 @@ class player {
      */
     private function get_export_settings(bool $downloadenabled): ?\moodle_url {
 
-        if ( ! $downloadenabled) {
+        if (!$downloadenabled) {
             return null;
         }
 
         $systemcontext = \context_system::instance();
         $slug = $this->content['slug'] ? $this->content['slug'] . '-' : '';
-        $url  = \moodle_url::make_pluginfile_url(
-            $systemcontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            "{$slug}{$this->content['id']}.h5p"
-        );
+        // We have to build the right URL.
+        // Depending the request was made through webservice/pluginfile.php or pluginfile.php.
+        if (strpos($this->url, '/webservice/pluginfile.php')) {
+            $url  = \moodle_url::make_webservice_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p"
+            );
+        } else {
+            // If the request is made by tokenpluginfile.php we need to indicates to generate a token for current user.
+            $includetoken = false;
+            if (strpos($this->url, '/tokenpluginfile.php')) {
+                $includetoken = true;
+            }
+            $url  = \moodle_url::make_pluginfile_url(
+                $systemcontext->id,
+                \core_h5p\file_storage::COMPONENT,
+                \core_h5p\file_storage::EXPORT_FILEAREA,
+                '',
+                '',
+                "{$slug}{$this->content['id']}.h5p",
+                false,
+                $includetoken
+            );
+        }
 
         return $url;
     }
index bffce14..8d784f6 100644 (file)
@@ -26,6 +26,9 @@ require_once(__DIR__ . '/../config.php');
 
 require_login(null, false);
 
+$deletelibrary = optional_param('deletelibrary', null, PARAM_INT);
+$confirm = optional_param('confirm', false, PARAM_BOOL);
+
 $context = context_system::instance();
 require_capability('moodle/h5p:updatelibraries', $context);
 
@@ -34,16 +37,38 @@ $url = new \moodle_url("/h5p/libraries.php");
 
 $PAGE->set_context($context);
 $PAGE->set_url($url);
-$PAGE->set_title($pagetitle);
 $PAGE->set_pagelayout('admin');
-$PAGE->set_heading($pagetitle);
+$PAGE->set_title("$SITE->shortname: " . $pagetitle);
+$PAGE->set_heading($SITE->fullname);
+
+$h5pfactory = new \core_h5p\factory();
+if ($deletelibrary) {
+    $library = \core_h5p\api::get_library($deletelibrary);
+    if ($confirm) {
+        require_sesskey();
+        \core_h5p\api::delete_library($h5pfactory, $library);
+        redirect(new moodle_url('/h5p/libraries.php'));
+    }
+
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('deleting', 'core_h5p'));
+    echo $OUTPUT->confirm(
+        get_string('deletelibraryconfirm', 'core_h5p', [
+            'name' => format_string($library->title),
+            'version' => format_string($library->majorversion . '.' . $library->minorversion . '.' . $library->patchversion),
+        ]),
+        new moodle_url($PAGE->url, ['deletelibrary' => $deletelibrary, 'confirm' => 1]),
+        new moodle_url('/h5p/libraries.php')
+    );
+    echo $OUTPUT->footer();
+    die();
+}
 
 echo $OUTPUT->header();
 echo $OUTPUT->heading($pagetitle);
 echo $OUTPUT->box(get_string('librariesmanagerdescription', 'core_h5p'));
 
 $form = new \core_h5p\form\uploadlibraries_form();
-$h5pfactory = new \core_h5p\factory();
 if ($data = $form->get_data()) {
     require_sesskey();
 
@@ -55,7 +80,7 @@ if ($data = $form->get_data()) {
     $file = reset($files);
 
     // Validate and save the H5P package.
-    // Because we are passing skipcontent = true to save_h5p function, the returning value is false in an error
+    // Because we are passing skipcontent = true to save_h5p function, the returning value is false if an error
     // is encountered, null when successfully saving the package without creating the content.
     if (\core_h5p\helper::save_h5p($h5pfactory, $file, new stdClass(), false, true) === false) {
         echo $OUTPUT->notification(get_string('invalidpackage', 'core_h5p'), 'error');
@@ -67,23 +92,11 @@ $form->display();
 
 // Load installed Libraries.
 $framework = $h5pfactory->get_framework();
-$filestorage = $h5pfactory->get_core()->fs;
 $libraries = $framework->loadLibraries();
-$installed = [];
-foreach ($libraries as $libraryname => $versions) {
-    foreach ($versions as $version) {
-        $version->icon = $filestorage->get_icon_url(
-            $version->id,
-            $version->machine_name,
-            $version->major_version,
-            $version->minor_version
-        );
-        $installed[] = $version;
-    }
-}
 
-if (count($installed)) {
-    echo $OUTPUT->render_from_template('core_h5p/h5plibraries', (object)['contenttypes' => $installed]);
+if (!empty($libraries)) {
+    $libs = new \core_h5p\output\libraries($h5pfactory, $libraries);
+    echo $OUTPUT->render_from_template('core_h5p/h5plibraries', $libs->export_for_template($OUTPUT));
 }
 
 echo $OUTPUT->footer();
index a6125fb..cb6e7b0 100644 (file)
@@ -69,6 +69,7 @@
                     <tr>
                         <th>{{#str}}description, core{{/str}}</th>
                         <th>{{#str}}version, core{{/str}}</th>
+                        <th aria-label="{{#str}}actions, core_h5p{{/str}}"></th>
                     </tr>
                 </thead>
                 <tbody>
                                 {{{ title }}}
                             </td>
                             <td>{{{ major_version }}}.{{{ minor_version }}}.{{{ patch_version }}}</td>
+                            <td>
+                                {{#actionmenu}}
+                                    {{>core/action_menu}}
+                                {{/actionmenu}}
+                            </td>
                         </tr>
                         {{/runnable}}
                     {{/contenttypes}}
                     <tr>
                         <th>{{#str}}description, core{{/str}}</th>
                         <th>{{#str}}version, core{{/str}}</th>
+                        <th aria-label="{{#str}}actions, core_h5p{{/str}}"></th>
                     </tr>
                 </thead>
                 <tbody>
                                 {{{ title }}}
                             </td>
                             <td>{{{ major_version }}}.{{{ minor_version }}}.{{{ patch_version }}}</td>
+                            <td>
+                                {{#actionmenu}}
+                                    {{>core/action_menu}}
+                                {{/actionmenu}}
+                            </td>
                         </tr>
                         {{/runnable}}
                     {{/contenttypes}}
diff --git a/h5p/tests/api_test.php b/h5p/tests/api_test.php
new file mode 100644 (file)
index 0000000..d64e1eb
--- /dev/null
@@ -0,0 +1,276 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Testing the H5P API.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test class covering the H5P API.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api_testcase extends \advanced_testcase {
+
+    /**
+     * Test the behaviour of delete_library().
+     *
+     * @dataProvider  delete_library_provider
+     * @param  string $libraryname          Machine name of the library to delete.
+     * @param  int    $expectedh5p          Total of H5P contents expected after deleting the library.
+     * @param  int    $expectedlibraries    Total of H5P libraries expected after deleting the library.
+     * @param  int    $expectedcontents     Total of H5P content_libraries expected after deleting the library.
+     * @param  int    $expecteddependencies Total of H5P library dependencies expected after deleting the library.
+     */
+    public function test_delete_library(string $libraryname, int $expectedh5p, int $expectedlibraries,
+            int $expectedcontents, int $expecteddependencies): void {
+        global $DB;
+
+        $this->setRunTestInSeparateProcess(true);
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Check the current content in H5P tables is the expected.
+        $counth5p = $DB->count_records('h5p');
+        $counth5plibraries = $DB->count_records('h5p_libraries');
+        $counth5pcontents = $DB->count_records('h5p_contents_libraries');
+        $counth5pdependencies = $DB->count_records('h5p_library_dependencies');
+
+        $this->assertSame(1, $counth5p);
+        $this->assertSame(7, $counth5plibraries);
+        $this->assertSame(5, $counth5pcontents);
+        $this->assertSame(7, $counth5pdependencies);
+
+        // Delete this library.
+        $factory = new factory();
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname]);
+        if ($library) {
+            api::delete_library($factory, $library);
+        }
+
+        // Check the expected libraries and content have been removed.
+        $counth5p = $DB->count_records('h5p');
+        $counth5plibraries = $DB->count_records('h5p_libraries');
+        $counth5pcontents = $DB->count_records('h5p_contents_libraries');
+        $counth5pdependencies = $DB->count_records('h5p_library_dependencies');
+
+        $this->assertSame($expectedh5p, $counth5p);
+        $this->assertSame($expectedlibraries, $counth5plibraries);
+        $this->assertSame($expectedcontents, $counth5pcontents);
+        $this->assertSame($expecteddependencies, $counth5pdependencies);
+    }
+
+    /**
+     * Data provider for test_delete_library().
+     *
+     * @return array
+     */
+    public function delete_library_provider(): array {
+        return [
+            'Delete MainLibrary' => [
+                'MainLibrary',
+                0,
+                6,
+                0,
+                4,
+            ],
+            'Delete Library1' => [
+                'Library1',
+                0,
+                5,
+                0,
+                1,
+            ],
+            'Delete Library2' => [
+                'Library2',
+                0,
+                4,
+                0,
+                1,
+            ],
+            'Delete Library3' => [
+                'Library3',
+                0,
+                4,
+                0,
+                0,
+            ],
+            'Delete Library4' => [
+                'Library4',
+                0,
+                4,
+                0,
+                1,
+            ],
+            'Delete Library5' => [
+                'Library5',
+                0,
+                3,
+                0,
+                0,
+            ],
+            'Delete a library without dependencies' => [
+                'H5P.TestingLibrary',
+                1,
+                6,
+                5,
+                7,
+            ],
+            'Delete unexisting library' => [
+                'LibraryX',
+                1,
+                7,
+                5,
+                7,
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_dependent_libraries().
+     *
+     * @dataProvider  get_dependent_libraries_provider
+     * @param  string $libraryname     Machine name of the library to delete.
+     * @param  int    $expectedvalue   Total of H5P required libraries expected.
+     */
+    public function test_get_dependent_libraries(string $libraryname, int $expectedvalue): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Get required libraries.
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname], 'id');
+        if ($library) {
+            $libraries = api::get_dependent_libraries((int)$library->id);
+        } else {
+            $libraries = [];
+        }
+
+        $this->assertCount($expectedvalue, $libraries);
+    }
+
+    /**
+     * Data provider for test_get_dependent_libraries().
+     *
+     * @return array
+     */
+    public function get_dependent_libraries_provider(): array {
+        return [
+            'Main library of a content' => [
+                'MainLibrary',
+                0,
+            ],
+            'Library1' => [
+                'Library1',
+                1,
+            ],
+            'Library2' => [
+                'Library2',
+                2,
+            ],
+            'Library without dependencies' => [
+                'H5P.TestingLibrary',
+                0,
+            ],
+            'Unexisting library' => [
+                'LibraryX',
+                0,
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_library().
+     *
+     * @dataProvider  get_library_provider
+     * @param  string $libraryname     Machine name of the library to delete.
+     * @param  bool   $emptyexpected   Wether the expected result is empty or not.
+     */
+    public function test_get_library(string $libraryname, bool $emptyexpected): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Generate h5p related data.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $generator->generate_h5p_data();
+        $generator->create_library_record('H5P.TestingLibrary', 'TestingLibrary', 1, 0);
+
+        // Get the library identifier.
+        $library = $DB->get_record('h5p_libraries', ['machinename' => $libraryname], 'id');
+        if ($library) {
+            $result = api::get_library((int)$library->id);
+        } else {
+            $result = null;
+        }
+
+        if ($emptyexpected) {
+            $this->assertEmpty($result);
+        } else {
+            $this->assertEquals($library->id, $result->id);
+            $this->assertEquals($libraryname, $result->machinename);
+        }
+
+    }
+
+    /**
+     * Data provider for test_get_library().
+     *
+     * @return array
+     */
+    public function get_library_provider(): array {
+        return [
+            'Main library of a content' => [
+                'MainLibrary',
+                false,
+            ],
+            'Library1' => [
+                'Library1',
+                false,
+            ],
+            'Library without dependencies' => [
+                'H5P.TestingLibrary',
+                false,
+            ],
+            'Unexisting library' => [
+                'LibraryX',
+                true,
+            ],
+        ];
+    }
+}
index 103c057..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"
@@ -44,3 +44,20 @@ Feature: Upload and list H5P libraries and content types installed
     And I click on "Installed H5P libraries" "link"
     And I should see "1.3" in the "Question" "table_row"
     And I should see "1.4"
+
+  @javascript
+  Scenario: Delete H5P library.
+    Given I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_submitbutton" "css_element"
+    And I wait until the page is ready
+    And I click on "Installed H5P libraries" "link"
+    When I click on "Delete version" "link" in the "H5P.FontIcons" "table_row"
+    And I press "Continue"
+    And I click on "Installed H5P content types" "link"
+    Then I should not see "Fill in the Blanks"
+    And I click on "Installed H5P libraries" "link"
+    And I should not see "H5P.FontIcons"
+    And I should not see "Joubel UI"
+    And I should see "Transition"
index 9d02199..b9ae3ca 100644 (file)
@@ -165,6 +165,124 @@ class core_h5p_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(get_string('h5pfilenotfound', 'core_h5p'), $result['warnings'][0]['message']);
     }
 
+    /**
+     * Test the request to get_trusted_h5p_file
+     * using webservice/pluginfile.php as url param.
+     */
+    public function test_allow_webservice_pluginfile_in_url_param() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // This is a valid .H5P file.
+        $filename = 'find-the-words.h5p';
+        $path = __DIR__ . '/fixtures/'.$filename;
+        $syscontext = \context_system::instance();
+        $filerecord = [
+            'contextid' => $syscontext->id,
+            'component' => \core_h5p\file_storage::COMPONENT,
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $filename,
+        ];
+        // Load the h5p file into DB.
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_pathname($filerecord, $path);
+        // Make the URL to pass to the WS.
+        $url  = \moodle_url::make_webservice_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            'unittest',
+            0,
+            '/',
+            $filename
+        );
+        // Call the WS.
+        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
+        // Expected result: Just 1 record on files and none on warnings.
+        $this->assertCount(1, $result['files']);
+        $this->assertCount(0, $result['warnings']);
+        // Get the export file in the DB to compare with the ws's results.
+        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
+        $fileh5purl  = \moodle_url::make_webservice_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            '',
+            '',
+            $fileh5p->get_filename()
+        );
+        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
+        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
+        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
+        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
+        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
+        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+    }
+
+    /**
+     * Test the request to get_trusted_h5p_file
+     * using tokenpluginfile.php as url param.
+     */
+    public function test_allow_tokenluginfile_in_url_param() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // This is a valid .H5P file.
+        $filename = 'find-the-words.h5p';
+        $path = __DIR__ . '/fixtures/'.$filename;
+        $syscontext = \context_system::instance();
+        $filerecord = [
+            'contextid' => $syscontext->id,
+            'component' => \core_h5p\file_storage::COMPONENT,
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $filename,
+        ];
+        // Load the h5p file into DB.
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_pathname($filerecord, $path);
+        // Make the URL to pass to the WS.
+        $url  = \moodle_url::make_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            'unittest',
+            0,
+            '/',
+            $filename,
+            false,
+            true
+        );
+        // Call the WS.
+        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
+        // Expected result: Just 1 record on files and none on warnings.
+        $this->assertCount(1, $result['files']);
+        $this->assertCount(0, $result['warnings']);
+        // Get the export file in the DB to compare with the ws's results.
+        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
+        $fileh5purl  = \moodle_url::make_pluginfile_url(
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            '',
+            '',
+            $fileh5p->get_filename(),
+            false,
+            true
+        );
+        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
+        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
+        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
+        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
+        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
+        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+    }
+
     /**
      * Get the H5P export file.
      *
index 099844b..8f72d80 100644 (file)
@@ -1739,6 +1739,57 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEquals($expected, $dynamicdependencies);
     }
 
+    /**
+     * Test the behaviour of getOption().
+     */
+    public function test_getOption(): void {
+        $this->resetAfterTest();
+
+        // Get value for display_option_download.
+        $value = $this->framework->getOption(\H5PCore::DISPLAY_OPTION_DOWNLOAD);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+
+        // Get value for display_option_embed using default value (it should be ignored).
+        $value = $this->framework->getOption(\H5PCore::DISPLAY_OPTION_EMBED, \H5PDisplayOptionBehaviour::NEVER_SHOW);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+
+        // Get value for unexisting setting without default.
+        $value = $this->framework->getOption('unexistingsetting');
+        $expected = false;
+        $this->assertEquals($expected, $value);
+
+        // Get value for unexisting setting with default.
+        $value = $this->framework->getOption('unexistingsetting', 'defaultvalue');
+        $expected = 'defaultvalue';
+        $this->assertEquals($expected, $value);
+    }
+
+    /**
+     * Test the behaviour of setOption().
+     */
+    public function test_setOption(): void {
+        $this->resetAfterTest();
+
+        // Set value for 'newsetting' setting.
+        $name = 'newsetting';
+        $value = $this->framework->getOption($name);
+        $this->assertEquals(false, $value);
+        $newvalue = 'value1';
+        $this->framework->setOption($name, $newvalue);
+        $value = $this->framework->getOption($name);
+        $this->assertEquals($newvalue, $value);
+
+        // Set value for display_option_download and then get it again. Check it hasn't changed.
+        $name = \H5PCore::DISPLAY_OPTION_DOWNLOAD;
+        $newvalue = \H5PDisplayOptionBehaviour::NEVER_SHOW;
+        $this->framework->setOption($name, $newvalue);
+        $value = $this->framework->getOption($name);
+        $expected = \H5PDisplayOptionBehaviour::CONTROLLED_BY_AUTHOR_DEFAULT_OFF;
+        $this->assertEquals($expected, $value);
+    }
+
     /**
      * Test the behaviour of updateContentFields().
      */
index 361ecf2..5fd4b98 100644 (file)
@@ -31,4 +31,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['parentlanguage'] = 'he';
+$string['thisdirection'] = 'rtl';
 $string['thislanguage'] = 'עברית בתי־ספר';
index 473305b..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';
@@ -307,7 +307,9 @@ $string['confignotifyloginthreshold'] = 'If notifications about failed logins ar
 $string['confignotloggedinroleid'] = 'Users who are not logged in to the site will be treated as if they have this role granted to them at the site context.  Guest is almost always what you want here, but you might want to create roles that are less or more restrictive.  Things like creating posts still require the user to log in properly.';
 $string['configopentowebcrawlers'] = 'If you enable this setting, then search engines will be allowed to enter your site as a guest.  In addition, people coming in to your site via a search engine will automatically be logged in as a guest.  Note that this only provides transparent access to courses that already allow guest access.';
 $string['configoverride'] = 'Defined in config.php';
-$string['configpasswordpolicy'] = 'If enabled, user passwords will be checked against the password policy as specified in the settings below. Enabling the password policy will not affect existing users until they decide to, or are required to, change their password.';
+$string['configpasswordpolicy'] = 'If enabled, user passwords will be checked against the password policy as specified in the settings below. Enabling the password policy will not affect existing users until they decide to, or are required to, change their password, or the \'Check password on login\' setting is enabled.';
+$string['configpasswordpolicycheckonlogin'] = 'If enabled, user passwords will be checked against the password policy each time users log in. If the check fails, the user will be required to change their password before proceeding.
+It is useful to enable this setting after updating the password policy.';
 $string['configpasswordresettime'] = 'This specifies the amount of time people have to validate a password reset request before it expires. Usually 30 minutes is a good value.';
 $string['configpathtodu'] = 'Path to du. Probably something like /usr/bin/du. If you enter this, pages that display directory contents will run much faster for directories with a lot of files.';
 $string['configpathtophp'] = 'Path to PHP CLI. Probably something like /usr/bin/php. If you enter this, cron scripts can be executed from admin web interface.';
@@ -895,6 +897,7 @@ $string['passwordchangelogout_desc'] = 'If enabled, when a password is changed,
 $string['passwordchangetokendeletion'] = 'Remove web service access tokens after password change';
 $string['passwordchangetokendeletion_desc'] = 'If enabled, when a password is changed, all the user web service access tokens are deleted.';
 $string['passwordpolicy'] = 'Password policy';
+$string['passwordpolicycheckonlogin'] = 'Check password on login';
 $string['passwordresettime'] = 'Maximum time to validate password reset request';
 $string['passwordreuselimit'] = 'Password rotation limit';
 $string['passwordreuselimit_desc'] = 'Number of times a user must change their password before they are allowed to reuse a password. Hashes of previously used passwords are stored in local database table. This feature might not be compatible with some external authentication plugins.';
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 f8955b3..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';
@@ -78,7 +79,9 @@ $string['cachedef_string'] = 'Language string cache';
 $string['cachedef_tags'] = 'Tags collections and areas';
 $string['cachedef_temp_tables'] = 'Temporary tables cache';
 $string['cachedef_userselections'] = 'Data used to persist user selections throughout Moodle';
+$string['cachedef_user_favourite_course_content_items'] = 'User\'s favourite content items (activities, resources and their subtypes)';
 $string['cachedef_user_group_groupings'] = 'User\'s groupings and groups per course';
+$string['cachedef_user_course_content_items'] = 'User\'s content items (activities, resources and their subtypes) per course';
 $string['cachedef_yuimodules'] = 'YUI Module definitions';
 $string['cachelock_file_default'] = 'Default file locking';
 $string['cachestores'] = 'Cache stores';
index 2573793..76eeb14 100644 (file)
  * @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';
 $string['aria:coursename'] = 'Course name';
+$string['aria:defaulttab'] = 'The default modules';
 $string['aria:favourite'] = 'Course is starred';
+$string['aria:favouritestab'] = 'Your starred modules';
+$string['aria:recommendedtab'] = 'The recommended modules';
 $string['coursealreadyfinished'] = 'Course already finished';
 $string['coursenotyetstarted'] = 'The course has not yet started';
 $string['coursenotyetfinished'] = 'The course has not yet finished';
@@ -43,28 +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 7753147..4bd15ab 100644 (file)
@@ -121,3 +121,21 @@ globalevent,core_calendar
 globalevents,core_calendar
 eventtypeglobal,core_calendar
 documentation,core_webservice
+africa/asmera,core_timezones
+africa/timbuktu,core_timezones
+america/argentina/comodrivadavia,core_timezones
+america/indianapolis,core_timezones
+america/louisville,core_timezones
+america/montreal,core_timezones
+asia/calcutta,core_timezones
+asia/chongqing,core_timezones
+asia/harbin,core_timezones
+asia/kashgar,core_timezones
+asia/katmandu,core_timezones
+asia/rangoon,core_timezones
+asia/saigon,core_timezones
+atlantic/faeroe,core_timezones
+europe/belfast,core_timezones
+pacific/ponape,core_timezones
+pacific/truk,core_timezones
+pacific/yap,core_timezones
index febe040..355f523 100644 (file)
@@ -23,6 +23,7 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['actions'] = 'Actions';
 $string['addedandupdatedpp'] = 'Added {$a->%new} new H5P libraries and updated {$a->%old} old ones.';
 $string['addedandupdatedps'] = 'Added {$a->%new} new H5P libraries and updated {$a->%old} old one.';
 $string['addedandupdatedsp'] = 'Added {$a->%new} new H5P library and updated {$a->%old} old ones.';
@@ -67,6 +68,9 @@ $string['couldNotParseJSONFromZip'] = 'Unable to parse JSON from the package: {$
 $string['couldNotReadFileFromZip'] = 'Unable to read file from the package: {$a->%fileName}';
 $string['creativecommons'] = 'Creative Commons';
 $string['date'] = 'Date';
+$string['deletelibraryconfirm'] = '<p>Are you sure you want to delete version <em>\'{$a->version}\'</em> from library <em>\'{$a->name}\'</em>? It will remove the library and all its uses.</p><p>This operation can not be undone.</p>';
+$string['deletelibraryversion'] = 'Delete version';
+$string['deleting'] = 'Deleting a library';
 $string['description'] = 'Description';
 $string['disablefullscreen'] = 'Disable fullscreen';
 $string['download'] = 'Download';
index 5010450..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';
@@ -799,6 +799,7 @@ $string['eventusercreated'] = 'User created';
 $string['eventuserdeleted'] = 'User deleted';
 $string['eventuserlistviewed'] = 'User list viewed';
 $string['eventuserloggedout'] = 'User logged out';
+$string['eventuserpasswordpolicyfailed'] = 'User password failed password policy';
 $string['eventuserpasswordupdated'] = 'User password updated';
 $string['eventuserprofileviewed'] = 'User profile viewed';
 $string['eventuserupdated'] = 'User updated';
@@ -859,6 +860,10 @@ $string['forcepasswordchange_help'] = 'If this checkbox is ticked, the user will
 $string['forcepasswordchangecheckfull'] = 'Are you absolutely sure you want to force a password change to {$a} ?';
 $string['forcepasswordchangenot'] = 'Could not force a password change to {$a}';
 $string['forcepasswordchangenotice'] = 'You must change your password to proceed.';
+$string['forcepasswordresetfailurenotice'] = 'Your current password no longer passes the set password policy. Please contact your Moodle administrator for assistance.
+   {$a}';
+$string['forcepasswordresetnotice'] = 'Your current password no longer passes the set password policy, you must reset your password to login.
+   {$a}';
 $string['forcetheme'] = 'Force theme';
 $string['forgotaccount'] = 'Lost password?';
 $string['forgotten'] = 'Forgotten your username or password?';
@@ -1258,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';
@@ -1517,6 +1526,8 @@ $string['passwordforgotteninstructions'] = 'Your details must first be found in
 $string['passwordforgotteninstructions2'] = 'To reset your password, submit your username or your email address below. If we can find you in the database, an email will be sent to your email address, with instructions how to get access again.';
 $string['passwordchanged'] = 'Password has been changed';
 $string['passwordnohelp'] = 'No help is available to find your lost password. Please contact your Moodle administrator.';
+$string['passwordpolicynomatch'] = 'Your current password no longer matches the set password policy.
+   {$a}';
 $string['passwordrecovery'] = 'Yes, help me log in';
 $string['passwordsdiffer'] = 'These passwords do not match';
 $string['passwordsent'] = 'Password has been sent';
@@ -1637,6 +1648,7 @@ $string['readme'] = 'README';
 $string['recentactivity'] = 'Recent activity';
 $string['recentactivityreport'] = 'Full report of recent activity...';
 $string['recipientslist'] = 'Recipients list';
+$string['recommended'] = 'Recommended';
 $string['recreatedcategory'] = 'Recreated category {$a}';
 $string['redirect'] = 'Redirect';
 $string['reducesections'] = 'Reduce the number of sections';
@@ -1662,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';
@@ -2053,6 +2069,7 @@ $string['tocontent'] = 'To item "{$a}"';
 $string['today'] = 'Today';
 $string['todaylogs'] = 'Today\'s logs';
 $string['toeveryone'] = 'to everyone';
+$string['toggleemojipicker'] = 'Toggle emoji picker';
 $string['toomanybounces'] = 'That email address has had too many bounces. You <b>must</b> change it to continue.';
 $string['toomanytags'] = 'This search included too many tags; some will have been ignored.';
 $string['toomanytoshow'] = 'There are too many users to show.';
index 94cc9df..47788cb 100644 (file)
@@ -287,7 +287,7 @@ $string['questiondoesnotexist'] = 'This question does not exist';
 $string['questionname'] = 'Question name';
 $string['questionno'] = 'Question {$a}';
 $string['questionsaveerror'] = 'Errors occur during saving question - ({$a})';
-$string['questionsinuse'] = '(* Questions marked by an asterisk are already in use in some quizzes. These questions will not be deleted from these quizzes but only from the category list.)';
+$string['questionsinuse'] = '(* Questions marked with an asterisk are used somewhere, for example in a quiz. Therefore, if you proceed, these questions will not really be deleted, they will just be hidden.)';
 $string['questionsmovedto'] = 'Questions still in use moved to "{$a}" in the parent course category.';
 $string['questionsrescuedfrom'] = 'Questions saved from context {$a}.';
 $string['questionsrescuedfrominfo'] = 'These questions (some of which may be hidden) were saved when context {$a} was deleted because they are still used by some quizzes or other activities.';
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 01a1fea..417a4fb 100644 (file)
@@ -27,7 +27,7 @@ $string['africa/abidjan'] = 'Africa/Abidjan';
 $string['africa/accra'] = 'Africa/Accra';
 $string['africa/addis_ababa'] = 'Africa/Addis_Ababa';
 $string['africa/algiers'] = 'Africa/Algiers';
-$string['africa/asmera'] = 'Africa/Asmera';
+$string['africa/asmara'] = 'Africa/Asmara';
 $string['africa/bamako'] = 'Africa/Bamako';
 $string['africa/bangui'] = 'Africa/Bangui';
 $string['africa/banjul'] = 'Africa/Banjul';
@@ -48,6 +48,7 @@ $string['africa/freetown'] = 'Africa/Freetown';
 $string['africa/gaborone'] = 'Africa/Gaborone';
 $string['africa/harare'] = 'Africa/Harare';
 $string['africa/johannesburg'] = 'Africa/Johannesburg';
+$string['africa/juba'] = 'Africa/Juba';
 $string['africa/kampala'] = 'Africa/Kampala';
 $string['africa/khartoum'] = 'Africa/Khartoum';
 $string['africa/kigali'] = 'Africa/Kigali';
@@ -71,32 +72,35 @@ $string['africa/nouakchott'] = 'Africa/Nouakchott';
 $string['africa/ouagadougou'] = 'Africa/Ouagadougou';
 $string['africa/porto-novo'] = 'Africa/Porto-Novo';
 $string['africa/sao_tome'] = 'Africa/Sao_Tome';
-$string['africa/timbuktu'] = 'Africa/Timbuktu';
 $string['africa/tripoli'] = 'Africa/Tripoli';
 $string['africa/tunis'] = 'Africa/Tunis';
 $string['africa/windhoek'] = 'Africa/Windhoek';
 $string['america/adak'] = 'America/Adak';
-$string['america/anguilla'] = 'America/Anguilla';
 $string['america/anchorage'] = 'America/Anchorage';
+$string['america/anguilla'] = 'America/Anguilla';
 $string['america/antigua'] = 'America/Antigua';
 $string['america/araguaina'] = 'America/Araguaina';
 $string['america/argentina/buenos_aires'] = 'America/Argentina/Buenos_Aires';
 $string['america/argentina/catamarca'] = 'America/Argentina/Catamarca';
-$string['america/argentina/comodrivadavia'] = 'America/Argentina/ComodRivadavia';
 $string['america/argentina/cordoba'] = 'America/Argentina/Cordoba';
 $string['america/argentina/jujuy'] = 'America/Argentina/Jujuy';
 $string['america/argentina/la_rioja'] = 'America/Argentina/La_Rioja';
 $string['america/argentina/mendoza'] = 'America/Argentina/Mendoza';
 $string['america/argentina/rio_gallegos'] = 'America/Argentina/Rio_Gallegos';
+$string['america/argentina/salta'] = 'America/Argentina/Salta';
 $string['america/argentina/san_juan'] = 'America/Argentina/San_Juan';
+$string['america/argentina/san_luis'] = 'America/Argentina/San_Luis';
 $string['america/argentina/tucuman'] = 'America/Argentina/Tucuman';
 $string['america/argentina/ushuaia'] = 'America/Argentina/Ushuaia';
 $string['america/aruba'] = 'America/Aruba';
 $string['america/asuncion'] = 'America/Asuncion';
+$string['america/atikokan'] = 'America/Atikokan';
 $string['america/bahia'] = 'America/Bahia';
+$string['america/bahia_banderas'] = 'America/Bahia_Banderas';
 $string['america/barbados'] = 'America/Barbados';
 $string['america/belem'] = 'America/Belem';
 $string['america/belize'] = 'America/Belize';
+$string['america/blanc-sablon'] = 'America/Blanc-Sablon';
 $string['america/boa_vista'] = 'America/Boa_Vista';
 $string['america/bogota'] = 'America/Bogota';
 $string['america/boise'] = 'America/Boise';
@@ -106,7 +110,10 @@ $string['america/cancun'] = 'America/Cancun';
 $string['america/caracas'] = 'America/Caracas';
 $string['america/cayenne'] = 'America/Cayenne';
 $string['america/cayman'] = 'America/Cayman';
+$string['america/chicago'] = 'America/Chicago';
+$string['america/chihuahua'] = 'America/Chihuahua';
 $string['america/costa_rica'] = 'America/Costa_Rica';
+$string['america/creston'] = 'America/Creston';
 $string['america/cuiaba'] = 'America/Cuiaba';
 $string['america/curacao'] = 'America/Curacao';
 $string['america/danmarkshavn'] = 'America/Danmarkshavn';
@@ -118,6 +125,7 @@ $string['america/dominica'] = 'America/Dominica';
 $string['america/edmonton'] = 'America/Edmonton';
 $string['america/eirunepe'] = 'America/Eirunepe';
 $string['america/el_salvador'] = 'America/El_Salvador';
+$string['america/fort_nelson'] = 'America/Fort_Nelson';
 $string['america/fortaleza'] = 'America/Fortaleza';
 $string['america/glace_bay'] = 'America/Glace_Bay';
 $string['america/godthab'] = 'America/Godthab';
@@ -131,40 +139,50 @@ $string['america/guyana'] = 'America/Guyana';
 $string['america/halifax'] = 'America/Halifax';
 $string['america/havana'] = 'America/Havana';
 $string['america/hermosillo'] = 'America/Hermosillo';
-$string['america/chicago'] = 'America/Chicago';
-$string['america/chihuahua'] = 'America/Chihuahua';
+$string['america/indiana/indianapolis'] = 'America/Indiana/Indianapolis';
 $string['america/indiana/knox'] = 'America/Indiana/Knox';
 $string['america/indiana/marengo'] = 'America/Indiana/Marengo';
-$string['america/indianapolis'] = 'America/Indianapolis';
+$string['america/indiana/petersburg'] = 'America/Indiana/Petersburg';
+$string['america/indiana/tell_city'] = 'America/Indiana/Tell_City';
 $string['america/indiana/vevay'] = 'America/Indiana/Vevay';
+$string['america/indiana/vincennes'] = 'America/Indiana/Vincennes';
+$string['america/indiana/winamac'] = 'America/Indiana/Winamac';
 $string['america/inuvik'] = 'America/Inuvik';
 $string['america/iqaluit'] = 'America/Iqaluit';
 $string['america/jamaica'] = 'America/Jamaica';
 $string['america/juneau'] = 'America/Juneau';
+$string['america/kentucky/louisville'] = 'America/Kentucky/Louisville';
 $string['america/kentucky/monticello'] = 'America/Kentucky/Monticello';
+$string['america/kralendijk'] = 'America/Kralendijk';
 $string['america/la_paz'] = 'America/La_Paz';
 $string['america/lima'] = 'America/Lima';
 $string['america/los_angeles'] = 'America/Los_Angeles';
-$string['america/louisville'] = 'America/Louisville';
+$string['america/lower_princes'] = 'America/Lower_Princes';
 $string['america/maceio'] = 'America/Maceio';
 $string['america/managua'] = 'America/Managua';
 $string['america/manaus'] = 'America/Manaus';
+$string['america/marigot'] = 'America/Marigot';
 $string['america/martinique'] = 'America/Martinique';
+$string['america/matamoros'] = 'America/Matamoros';
 $string['america/mazatlan'] = 'America/Mazatlan';
 $string['america/menominee'] = 'America/Menominee';
 $string['america/merida'] = 'America/Merida';
+$string['america/metlakatla'] = 'America/Metlakatla';
 $string['america/mexico_city'] = 'America/Mexico_City';
 $string['america/miquelon'] = 'America/Miquelon';
+$string['america/moncton'] = 'America/Moncton';
 $string['america/monterrey'] = 'America/Monterrey';
 $string['america/montevideo'] = 'America/Montevideo';
-$string['america/montreal'] = 'America/Montreal';
 $string['america/montserrat'] = 'America/Montserrat';
 $string['america/nassau'] = 'America/Nassau';
 $string['america/new_york'] = 'America/New_York';
 $string['america/nipigon'] = 'America/Nipigon';
 $string['america/nome'] = 'America/Nome';
 $string['america/noronha'] = 'America/Noronha';
+$string['america/north_dakota/beulah'] = 'America/North_Dakota/Beulah';
 $string['america/north_dakota/center'] = 'America/North_Dakota/Center';
+$string['america/north_dakota/new_salem'] = 'America/North_Dakota/New_Salem';
+$string['america/ojinaga'] = 'America/Ojinaga';
 $string['america/panama'] = 'America/Panama';
 $string['america/pangnirtung'] = 'America/Pangnirtung';
 $string['america/paramaribo'] = 'America/Paramaribo';
@@ -173,15 +191,20 @@ $string['america/port-au-prince'] = 'America/Port-au-Prince';
 $string['america/port_of_spain'] = 'America/Port_of_Spain';
 $string['america/porto_velho'] = 'America/Porto_Velho';
 $string['america/puerto_rico'] = 'America/Puerto_Rico';
+$string['america/punta_arenas'] = 'America/Punta_Arenas';
 $string['america/rainy_river'] = 'America/Rainy_River';
 $string['america/rankin_inlet'] = 'America/Rankin_Inlet';
 $string['america/recife'] = 'America/Recife';
 $string['america/regina'] = 'America/Regina';
+$string['america/resolute'] = 'America/Resolute';
 $string['america/rio_branco'] = 'America/Rio_Branco';
+$string['america/santarem'] = 'America/Santarem';
 $string['america/santiago'] = 'America/Santiago';
 $string['america/santo_domingo'] = 'America/Santo_Domingo';
 $string['america/sao_paulo'] = 'America/Sao_Paulo';
 $string['america/scoresbysund'] = 'America/Scoresbysund';
+$string['america/sitka'] = 'America/Sitka';
+$string['america/st_barthelemy'] = 'America/St_Barthelemy';
 $string['america/st_johns'] = 'America/St_Johns';
 $string['america/st_kitts'] = 'America/St_Kitts';
 $string['america/st_lucia'] = 'America/St_Lucia';
@@ -202,12 +225,15 @@ $string['america/yellowknife'] = 'America/Yellowknife';
 $string['antarctica/casey'] = 'Antarctica/Casey';
 $string['antarctica/davis'] = 'Antarctica/Davis';
 $string['antarctica/dumontdurville'] = 'Antarctica/DumontDUrville';
+$string['antarctica/macquarie'] = 'Antarctica/Macquarie';
 $string['antarctica/mawson'] = 'Antarctica/Mawson';
 $string['antarctica/mcmurdo'] = 'Antarctica/McMurdo';
 $string['antarctica/palmer'] = 'Antarctica/Palmer';
 $string['antarctica/rothera'] = 'Antarctica/Rothera';
 $string['antarctica/syowa'] = 'Antarctica/Syowa';
+$string['antarctica/troll'] = 'Antarctica/Troll';
 $string['antarctica/vostok'] = 'Antarctica/Vostok';
+$string['arctic/longyearbyen'] = 'Arctic/Longyearbyen';
 $string['asia/aden'] = 'Asia/Aden';
 $string['asia/almaty'] = 'Asia/Almaty';
 $string['asia/amman'] = 'Asia/Amman';
@@ -215,34 +241,39 @@ $string['asia/anadyr'] = 'Asia/Anadyr';
 $string['asia/aqtau'] = 'Asia/Aqtau';
 $string['asia/aqtobe'] = 'Asia/Aqtobe';
 $string['asia/ashgabat'] = 'Asia/Ashgabat';
+$string['asia/atyrau'] = 'Asia/Atyrau';
 $string['asia/baghdad'] = 'Asia/Baghdad';
 $string['asia/bahrain'] = 'Asia/Bahrain';
 $string['asia/baku'] = 'Asia/Baku';
 $string['asia/bangkok'] = 'Asia/Bangkok';
+$string['asia/barnaul'] = 'Asia/Barnaul';
 $string['asia/beirut'] = 'Asia/Beirut';
 $string['asia/bishkek'] = 'Asia/Bishkek';
 $string['asia/brunei'] = 'Asia/Brunei';
-$string['asia/calcutta'] = 'Asia/Calcutta';
+$string['asia/chita'] = 'Asia/Chita';
+$string['asia/choibalsan'] = 'Asia/Choibalsan';
 $string['asia/colombo'] = 'Asia/Colombo';
 $string['asia/damascus'] = 'Asia/Damascus';
 $string['asia/dhaka'] = 'Asia/Dhaka';
 $string['asia/dili'] = 'Asia/Dili';
 $string['asia/dubai'] = 'Asia/Dubai';
 $string['asia/dushanbe'] = 'Asia/Dushanbe';
+$string['asia/famagusta'] = 'Asia/Famagusta';
 $string['asia/gaza'] = 'Asia/Gaza';
-$string['asia/harbin'] = 'Asia/Harbin';
+$string['asia/hebron'] = 'Asia/Hebron';
+$string['asia/ho_chi_minh'] = 'Asia/Ho_Chi_Minh';
 $string['asia/hong_kong'] = 'Asia/Hong_Kong';
 $string['asia/hovd'] = 'Asia/Hovd';
-$string['asia/choibalsan'] = 'Asia/Choibalsan';
-$string['asia/chongqing'] = 'Asia/Chongqing';
 $string['asia/irkutsk'] = 'Asia/Irkutsk';
+$string['asia/jakarta'] = 'Asia/Jakarta';
 $string['asia/jayapura'] = 'Asia/Jayapura';
 $string['asia/jerusalem'] = 'Asia/Jerusalem';
 $string['asia/kabul'] = 'Asia/Kabul';
 $string['asia/kamchatka'] = 'Asia/Kamchatka';
 $string['asia/karachi'] = 'Asia/Karachi';
-$string['asia/kashgar'] = 'Asia/Kashgar';
-$string['asia/katmandu'] = 'Asia/Katmandu';
+$string['asia/kathmandu'] = 'Asia/Kathmandu';
+$string['asia/khandyga'] = 'Asia/Khandyga';
+$string['asia/kolkata'] = 'Asia/Kolkata';
 $string['asia/krasnoyarsk'] = 'Asia/Krasnoyarsk';
 $string['asia/kuala_lumpur'] = 'Asia/Kuala_Lumpur';
 $string['asia/kuching'] = 'Asia/Kuching';
@@ -253,6 +284,7 @@ $string['asia/makassar'] = 'Asia/Makassar';
 $string['asia/manila'] = 'Asia/Manila';
 $string['asia/muscat'] = 'Asia/Muscat';
 $string['asia/nicosia'] = 'Asia/Nicosia';
+$string['asia/novokuznetsk'] = 'Asia/Novokuznetsk';
 $string['asia/novosibirsk'] = 'Asia/Novosibirsk';
 $string['asia/omsk'] = 'Asia/Omsk';
 $string['asia/oral'] = 'Asia/Oral';
@@ -260,42 +292,47 @@ $string['asia/phnom_penh'] = 'Asia/Phnom_Penh';
 $string['asia/pontianak'] = 'Asia/Pontianak';
 $string['asia/pyongyang'] = 'Asia/Pyongyang';
 $string['asia/qatar'] = 'Asia/Qatar';
+$string['asia/qostanay'] = 'Asia/Qostanay';
 $string['asia/qyzylorda'] = 'Asia/Qyzylorda';
-$string['asia/rangoon'] = 'Asia/Rangoon';
 $string['asia/riyadh'] = 'Asia/Riyadh';
-$string['asia/saigon'] = 'Asia/Saigon';
 $string['asia/sakhalin'] = 'Asia/Sakhalin';
 $string['asia/samarkand'] = 'Asia/Samarkand';
 $string['asia/seoul'] = 'Asia/Seoul';
 $string['asia/shanghai'] = 'Asia/Shanghai';
 $string['asia/singapore'] = 'Asia/Singapore';
+$string['asia/srednekolymsk'] = 'Asia/Srednekolymsk';
 $string['asia/taipei'] = 'Asia/Taipei';
 $string['asia/tashkent'] = 'Asia/Tashkent';
 $string['asia/tbilisi'] = 'Asia/Tbilisi';
 $string['asia/tehran'] = 'Asia/Tehran';
 $string['asia/thimphu'] = 'Asia/Thimphu';
 $string['asia/tokyo'] = 'Asia/Tokyo';
+$string['asia/tomsk'] = 'Asia/Tomsk';
 $string['asia/ulaanbaatar'] = 'Asia/Ulaanbaatar';
 $string['asia/urumqi'] = 'Asia/Urumqi';
+$string['asia/ust-nera'] = 'Asia/Ust-Nera';
 $string['asia/vientiane'] = 'Asia/Vientiane';
 $string['asia/vladivostok'] = 'Asia/Vladivostok';
 $string['asia/yakutsk'] = 'Asia/Yakutsk';
+$string['asia/yangon'] = 'Asia/Yangon';
 $string['asia/yekaterinburg'] = 'Asia/Yekaterinburg';
 $string['asia/yerevan'] = 'Asia/Yerevan';
 $string['atlantic/azores'] = 'Atlantic/Azores';
 $string['atlantic/bermuda'] = 'Atlantic/Bermuda';
 $string['atlantic/canary'] = 'Atlantic/Canary';
 $string['atlantic/cape_verde'] = 'Atlantic/Cape_Verde';
-$string['atlantic/faeroe'] = 'Atlantic/Faeroe';
+$string['atlantic/faroe'] = 'Atlantic/Faroe';
 $string['atlantic/madeira'] = 'Atlantic/Madeira';
 $string['atlantic/reykjavik'] = 'Atlantic/Reykjavik';
 $string['atlantic/south_georgia'] = 'Atlantic/South_Georgia';
-$string['atlantic/stanley'] = 'Atlantic/Stanley';
 $string['atlantic/st_helena'] = 'Atlantic/St_Helena';
+$string['atlantic/stanley'] = 'Atlantic/Stanley';
 $string['australia/adelaide'] = 'Australia/Adelaide';
 $string['australia/brisbane'] = 'Australia/Brisbane';
 $string['australia/broken_hill'] = 'Australia/Broken_Hill';
+$string['australia/currie'] = 'Australia/Currie';
 $string['australia/darwin'] = 'Australia/Darwin';
+$string['australia/eucla'] = 'Australia/Eucla';
 $string['australia/hobart'] = 'Australia/Hobart';
 $string['australia/lindeman'] = 'Australia/Lindeman';
 $string['australia/lord_howe'] = 'Australia/Lord_Howe';
@@ -304,51 +341,69 @@ $string['australia/perth'] = 'Australia/Perth';
 $string['australia/sydney'] = 'Australia/Sydney';
 $string['europe/amsterdam'] = 'Europe/Amsterdam';
 $string['europe/andorra'] = 'Europe/Andorra';
+$string['europe/astrakhan'] = 'Europe/Astrakhan';
 $string['europe/athens'] = 'Europe/Athens';
-$string['europe/belfast'] = 'Europe/Belfast';
 $string['europe/belgrade'] = 'Europe/Belgrade';
 $string['europe/berlin'] = 'Europe/Berlin';
+$string['europe/bratislava'] = 'Europe/Bratislava';
 $string['europe/brussels'] = 'Europe/Brussels';
-$string['europe/budapest'] = 'Europe/Budapest';
 $string['europe/bucharest'] = 'Europe/Bucharest';
+$string['europe/budapest'] = 'Europe/Budapest';
+$string['europe/busingen'] = 'Europe/Busingen';
+$string['europe/chisinau'] = 'Europe/Chisinau';
 $string['europe/copenhagen'] = 'Europe/Copenhagen';
 $string['europe/dublin'] = 'Europe/Dublin';
 $string['europe/gibraltar'] = 'Europe/Gibraltar';
+$string['europe/guernsey'] = 'Europe/Guernsey';
 $string['europe/helsinki'] = 'Europe/Helsinki';
-$string['europe/chisinau'] = 'Europe/Chisinau';
+$string['europe/isle_of_man'] = 'Europe/Isle_of_Man';
 $string['europe/istanbul'] = 'Europe/Istanbul';
+$string['europe/jersey'] = 'Europe/Jersey';
 $string['europe/kaliningrad'] = 'Europe/Kaliningrad';
 $string['europe/kiev'] = 'Europe/Kiev';
+$string['europe/kirov'] = 'Europe/Kirov';
 $string['europe/lisbon'] = 'Europe/Lisbon';
+$string['europe/ljubljana'] = 'Europe/Ljubljana';
 $string['europe/london'] = 'Europe/London';
 $string['europe/luxembourg'] = 'Europe/Luxembourg';
 $string['europe/madrid'] = 'Europe/Madrid';
 $string['europe/malta'] = 'Europe/Malta';
+$string['europe/mariehamn'] = 'Europe/Mariehamn';
 $string['europe/minsk'] = 'Europe/Minsk';
 $string['europe/monaco'] = 'Europe/Monaco';
 $string['europe/moscow'] = 'Europe/Moscow';
 $string['europe/oslo'] = 'Europe/Oslo';
 $string['europe/paris'] = 'Europe/Paris';
+$string['europe/podgorica'] = 'Europe/Podgorica';
 $string['europe/prague'] = 'Europe/Prague';
 $string['europe/riga'] = 'Europe/Riga';
 $string['europe/rome'] = 'Europe/Rome';
 $string['europe/samara'] = 'Europe/Samara';
+$string['europe/san_marino'] = 'Europe/San_Marino';
+$string['europe/sarajevo'] = 'Europe/Sarajevo';
+$string['europe/saratov'] = 'Europe/Saratov';
 $string['europe/simferopol'] = 'Europe/Simferopol';
+$string['europe/skopje'] = 'Europe/Skopje';
 $string['europe/sofia'] = 'Europe/Sofia';
 $string['europe/stockholm'] = 'Europe/Stockholm';
 $string['europe/tallinn'] = 'Europe/Tallinn';
 $string['europe/tirane'] = 'Europe/Tirane';
+$string['europe/ulyanovsk'] = 'Europe/Ulyanovsk';
 $string['europe/uzhgorod'] = 'Europe/Uzhgorod';
 $string['europe/vaduz'] = 'Europe/Vaduz';
+$string['europe/vatican'] = 'Europe/Vatican';
 $string['europe/vienna'] = 'Europe/Vienna';
 $string['europe/vilnius'] = 'Europe/Vilnius';
+$string['europe/volgograd'] = 'Europe/Volgograd';
 $string['europe/warsaw'] = 'Europe/Warsaw';
+$string['europe/zagreb'] = 'Europe/Zagreb';
 $string['europe/zaporozhye'] = 'Europe/Zaporozhye';
 $string['europe/zurich'] = 'Europe/Zurich';
 $string['indian/antananarivo'] = 'Indian/Antananarivo';
-$string['indian/comoro'] = 'Indian/Comoro';
 $string['indian/chagos'] = 'Indian/Chagos';
 $string['indian/christmas'] = 'Indian/Christmas';
+$string['indian/cocos'] = 'Indian/Cocos';
+$string['indian/comoro'] = 'Indian/Comoro';
 $string['indian/kerguelen'] = 'Indian/Kerguelen';
 $string['indian/mahe'] = 'Indian/Mahe';
 $string['indian/maldives'] = 'Indian/Maldives';
@@ -357,6 +412,9 @@ $string['indian/mayotte'] = 'Indian/Mayotte';
 $string['indian/reunion'] = 'Indian/Reunion';
 $string['pacific/apia'] = 'Pacific/Apia';
 $string['pacific/auckland'] = 'Pacific/Auckland';
+$string['pacific/bougainville'] = 'Pacific/Bougainville';
+$string['pacific/chatham'] = 'Pacific/Chatham';
+$string['pacific/chuuk'] = 'Pacific/Chuuk';
 $string['pacific/easter'] = 'Pacific/Easter';
 $string['pacific/efate'] = 'Pacific/Efate';
 $string['pacific/enderbury'] = 'Pacific/Enderbury';
@@ -368,7 +426,6 @@ $string['pacific/gambier'] = 'Pacific/Gambier';
 $string['pacific/guadalcanal'] = 'Pacific/Guadalcanal';
 $string['pacific/guam'] = 'Pacific/Guam';
 $string['pacific/honolulu'] = 'Pacific/Honolulu';
-$string['pacific/chatham'] = 'Pacific/Chatham';
 $string['pacific/kiritimati'] = 'Pacific/Kiritimati';
 $string['pacific/kosrae'] = 'Pacific/Kosrae';
 $string['pacific/kwajalein'] = 'Pacific/Kwajalein';
@@ -382,14 +439,33 @@ $string['pacific/noumea'] = 'Pacific/Noumea';
 $string['pacific/pago_pago'] = 'Pacific/Pago_Pago';
 $string['pacific/palau'] = 'Pacific/Palau';
 $string['pacific/pitcairn'] = 'Pacific/Pitcairn';
-$string['pacific/ponape'] = 'Pacific/Ponape';
+$string['pacific/pohnpei'] = 'Pacific/Pohnpei';
 $string['pacific/port_moresby'] = 'Pacific/Port_Moresby';
 $string['pacific/rarotonga'] = 'Pacific/Rarotonga';
 $string['pacific/saipan'] = 'Pacific/Saipan';
 $string['pacific/tahiti'] = 'Pacific/Tahiti';
 $string['pacific/tarawa'] = 'Pacific/Tarawa';
 $string['pacific/tongatapu'] = 'Pacific/Tongatapu';
-$string['pacific/truk'] = 'Pacific/Truk';
 $string['pacific/wake'] = 'Pacific/Wake';
 $string['pacific/wallis'] = 'Pacific/Wallis';
+$string['utc'] = 'UTC';
+// The following identifiers have been previous removed from TimeDateZone::listIdentifiers and are no longer used.
+// Deprecated since Moodle 3.9.
+$string['africa/asmera'] = 'Africa/Asmera';
+$string['africa/timbuktu'] = 'Africa/Timbuktu';
+$string['america/argentina/comodrivadavia'] = 'America/Argentina/ComodRivadavia';
+$string['america/indianapolis'] = 'America/Indianapolis';
+$string['america/louisville'] = 'America/Louisville';
+$string['america/montreal'] = 'America/Montreal';
+$string['asia/calcutta'] = 'Asia/Calcutta';
+$string['asia/chongqing'] = 'Asia/Chongqing';
+$string['asia/harbin'] = 'Asia/Harbin';
+$string['asia/kashgar'] = 'Asia/Kashgar';
+$string['asia/katmandu'] = 'Asia/Katmandu';
+$string['asia/rangoon'] = 'Asia/Rangoon';
+$string['asia/saigon'] = 'Asia/Saigon';
+$string['atlantic/faeroe'] = 'Atlantic/Faeroe';
+$string['europe/belfast'] = 'Europe/Belfast';
+$string['pacific/ponape'] = 'Pacific/Ponape';
+$string['pacific/truk'] = 'Pacific/Truk';
 $string['pacific/yap'] = 'Pacific/Yap';
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';
diff --git a/lib/classes/event/user_password_policy_failed.php b/lib/classes/event/user_password_policy_failed.php
new file mode 100644 (file)
index 0000000..18bee65
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * Password policy failed event.
+ *
+ * @package    core
+ * @copyright  2020 Peter Burnett <peterburnett@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event when user's current password fails the password policy
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Peter Burnett <peterburnett@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_password_policy_failed extends base {
+    /**
+     * Create event for user's current password failing password policy.
+     *
+     * @param \stdClass $user
+     * @return user_password_updated
+     */
+    public static function create_from_user(\stdClass $user) {
+        $data = array(
+            'context' => \context_user::instance($user->id),
+            'userid' => $user->id,
+            'relateduserid' => $user->id,
+        );
+        $event = self::create($data);
+        $event->add_record_snapshot('user', $user);
+        return $event;
+    }
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserpasswordpolicyfailed');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The password for user with id '$this->userid' failed the current password policy.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/user/profile.php', array('id' => $this->userid));
+    }
+}
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 eaa6904..ce4c641 100644 (file)
@@ -415,4 +415,21 @@ $definitions = array(
         'simplekeys' => false,
         'simpledata' => false,
     ],
+
+    // The list of content items (activities, resources and their subtypes) that can be added to a course for a user.
+    'user_course_content_items' => [
+        'mode' => cache_store::MODE_REQUEST,
+        'simplekeys' => true,
+    ],
+
+    // The list of favourited content items (activities, resources and their subtypes) for a user.
+    'user_favourite_course_content_items' => [
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+    ],
+
+    \core_course\local\service\content_item_service::RECOMMENDATION_CACHE => [
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+    ],
 );
index 50422b2..9442f9c 100644 (file)
@@ -629,14 +629,38 @@ $functions = array(
         'type' => 'read',
         'ajax' => true,
     ),
-    'core_course_get_activity_picker_info' => array(
+    'core_course_add_content_item_to_user_favourites' => array(
         'classname' => 'core_course_external',
-        'methodname' => 'fetch_modules_activity_chooser',
+        'methodname' => 'add_content_item_to_user_favourites',
         'classpath' => 'course/externallib.php',
-        'description' => 'Fetch all the module information for the activity picker',
+        'description' => 'Adds a content item (activity, resource or their subtypes) to the favourites for the user.',
+        'type' => 'write',
+        'ajax' => true,
+    ),
+    'core_course_remove_content_item_from_user_favourites' => array(
+        'classname' => 'core_course_external',
+        'methodname' => 'remove_content_item_from_user_favourites',
+        'classpath' => 'course/externallib.php',
+        'description' => 'Removes a content item (activity, resource or their subtypes) from the favourites for the user.',
+        'type' => 'write',
+        'ajax' => true,
+    ),
+    'core_course_get_course_content_items' => array(
+        'classname' => 'core_course_external',
+        'methodname' => 'get_course_content_items',
+        'classpath' => 'course/externallib.php',
+        'description' => 'Fetch all the content items (activities, resources and their subtypes) for the activity picker',
         '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 2c847a5..01b389e 100644 (file)
@@ -3307,3 +3307,102 @@ function report_insights_context_insights(\context $context) {
 
     return \core_analytics\manager::cached_models_with_insights($context);
 }
+
+/**
+ * Retrieve all metadata for the requested modules
+ *
+ * @deprecated since 3.9.
+ * @param object $course The Course
+ * @param array $modnames An array containing the list of modules and their
+ * names
+ * @param int $sectionreturn The section to return to
+ * @return array A list of stdClass objects containing metadata about each
+ * module
+ */
+function get_module_metadata($course, $modnames, $sectionreturn = null) {
+    global $OUTPUT;
+
+    debugging('get_module_metadata is deprecated. Please use \core_course\local\service\content_item_service instead.');
+
+    // get_module_metadata will be called once per section on the page and courses may show
+    // different modules to one another
+    static $modlist = array();
+    if (!isset($modlist[$course->id])) {
+        $modlist[$course->id] = array();
+    }
+
+    $return = array();
+    $urlbase = new moodle_url('/course/mod.php', array('id' => $course->id, 'sesskey' => sesskey()));
+    if ($sectionreturn !== null) {
+        $urlbase->param('sr', $sectionreturn);
+    }
+    foreach($modnames as $modname => $modnamestr) {
+        if (!course_allowed_module($course, $modname)) {
+            continue;
+        }
+        if (isset($modlist[$course->id][$modname])) {
+            // This module is already cached
+            $return += $modlist[$course->id][$modname];
+            continue;
+        }
+        $modlist[$course->id][$modname] = array();
+
+        // Create an object for a default representation of this module type in the activity chooser. It will be used
+        // if module does not implement callback get_shortcuts() and it will also be passed to the callback if it exists.
+        $defaultmodule = new stdClass();
+        $defaultmodule->title = $modnamestr;
+        $defaultmodule->name = $modname;
+        $defaultmodule->link = new moodle_url($urlbase, array('add' => $modname));
+        $defaultmodule->icon = $OUTPUT->pix_icon('icon', '', $defaultmodule->name, array('class' => 'icon'));
+        $sm = get_string_manager();
+        if ($sm->string_exists('modulename_help', $modname)) {
+            $defaultmodule->help = get_string('modulename_help', $modname);
+            if ($sm->string_exists('modulename_link', $modname)) {  // Link to further info in Moodle docs.
+                $link = get_string('modulename_link', $modname);
+                $linktext = get_string('morehelp');
+                $defaultmodule->help .= html_writer::tag('div',
+                    $OUTPUT->doc_link($link, $linktext, true), array('class' => 'helpdoclink'));
+            }
+        }
+        $defaultmodule->archetype = plugin_supports('mod', $modname, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
+
+        // Each module can implement callback modulename_get_shortcuts() in its lib.php and return the list
+        // of elements to be added to activity chooser.
+        $items = component_callback($modname, 'get_shortcuts', array($defaultmodule), null);
+        if ($items !== null) {
+            foreach ($items as $item) {
+                // Add all items to the return array. All items must have different links, use them as a key in the return array.
+                if (!isset($item->archetype)) {
+                    $item->archetype = $defaultmodule->archetype;
+                }
+                if (!isset($item->icon)) {
+                    $item->icon = $defaultmodule->icon;
+                }
+                // If plugin returned the only one item with the same link as default item - cache it as $modname,
+                // otherwise append the link url to the module name.
+                $item->name = (count($items) == 1 &&
+                    $item->link->out() === $defaultmodule->link->out()) ? $modname : $modname . ':' . $item->link;
+
+                // If the module provides the helptext property, append it to the help text to match the look and feel
+                // of the default course modules.
+                if (isset($item->help) && isset($item->helplink)) {
+                    $linktext = get_string('morehelp');
+                    $item->help .= html_writer::tag('div',
+                        $OUTPUT->doc_link($item->helplink, $linktext, true), array('class' => 'helpdoclink'));
+                }
+                $modlist[$course->id][$modname][$item->name] = $item;
+            }
+            $return += $modlist[$course->id][$modname];
+            // If get_shortcuts() callback is defined, the default module action is not added.
+            // It is a responsibility of the callback to add it to the return value unless it is not needed.
+            continue;
+        }
+
+        // The callback get_shortcuts() was not found, use the default item for the activity chooser.
+        $modlist[$course->id][$modname][$modname] = $defaultmodule;
+        $return[$modname] = $defaultmodule;
+    }
+
+    core_collator::asort_objects_by_property($return, 'title');
+    return $return;
+}
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 8f86592..91852d0 100644 (file)
@@ -4398,7 +4398,7 @@ function guest_user() {
  * @return stdClass|false A {@link $USER} object or false if error
  */
 function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null, $logintoken=false) {
-    global $CFG, $DB;
+    global $CFG, $DB, $PAGE;
     require_once("$CFG->libdir/authlib.php");
 
     if ($user = get_complete_user_data('username', $username, $CFG->mnet_localhost_id)) {
@@ -4512,6 +4512,42 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f
       &nb