Merge branch 'MDL-68636-master' of git://github.com/aanabit/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Mon, 18 May 2020 00:21:53 +0000 (08:21 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Mon, 18 May 2020 00:21:53 +0000 (08:21 +0800)
56 files changed:
admin/tool/mobile/classes/api.php
blocks/moodleblock.class.php
completion/classes/api.php
lang/en/plugin.php
lib/amd/build/prefetch.min.js
lib/amd/build/prefetch.min.js.map
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/build/user_date.min.js
lib/amd/build/user_date.min.js.map
lib/amd/src/prefetch.js
lib/amd/src/templates.js
lib/amd/src/user_date.js
lib/antivirus/clamav/adminlib.php
lib/antivirus/clamav/classes/scanner.php
lib/classes/task/legacy_plugin_cron_task.php
lib/classes/update/validator.php
lib/cronlib.php
lib/deprecatedlib.php
lib/external/externallib.php
lib/external/tests/external_test.php
lib/grouplib.php
lib/outputlib.php
lib/outputrequirementslib.php
lib/pagelib.php
lib/templates/time_element.mustache [new file with mode: 0644]
lib/testing/generator/block_generator.php
lib/testing/generator/module_generator.php
lib/testing/generator/repository_generator.php
lib/tests/grouplib_test.php
lib/upgrade.txt
mod/assign/lib.php
mod/chat/lib.php
mod/choice/locallib.php
mod/data/locallib.php
mod/feedback/lib.php
mod/forum/classes/local/exporters/post.php
mod/forum/locallib.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_post.mustache
mod/forum/tests/generator/lib.php
mod/forum/view.php
mod/lesson/lib.php
mod/quiz/lib.php
mod/scorm/locallib.php
mod/upgrade.txt
mod/workshop/lib.php
pix/i/contentbank.svg
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
user/classes/table/participants.php
user/classes/table/participants_search.php [new file with mode: 0644]
user/tests/table/participants_search_test.php [new file with mode: 0644]
version.php

index 914ef5f..e95cf83 100644 (file)
@@ -402,7 +402,8 @@ class api {
 
         $availablemods = core_plugin_manager::instance()->get_plugins_of_type('mod');
         $coursemodules = array();
-        $appsupportedmodules = array('assign', 'book', 'chat', 'choice', 'data', 'feedback', 'folder', 'forum', 'glossary', 'imscp',
+        $appsupportedmodules = array(
+            'assign', 'book', 'chat', 'choice', 'data', 'feedback', 'folder', 'forum', 'glossary', 'h5pactivity', 'imscp',
             'label', 'lesson', 'lti', 'page', 'quiz', 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop');
 
         foreach ($availablemods as $mod) {
@@ -425,6 +426,7 @@ class api {
         $courseblocks = array();
         $appsupportedblocks = array(
             'activity_modules' => 'CoreBlockDelegate_AddonBlockActivityModules',
+            'activity_results' => 'CoreBlockDelegate_AddonBlockActivityResults',
             'site_main_menu' => 'CoreBlockDelegate_AddonBlockSiteMainMenu',
             'myoverview' => 'CoreBlockDelegate_AddonBlockMyOverview',
             'timeline' => 'CoreBlockDelegate_AddonBlockTimeline',
@@ -468,6 +470,7 @@ class api {
                 'NoDelegate_ResponsiveMainMenuItems' => new lang_string('responsivemainmenuitems', 'tool_mobile'),
                 'NoDelegate_H5POffline' => new lang_string('h5poffline', 'tool_mobile'),
                 'NoDelegate_DarkMode' => new lang_string('darkmode', 'tool_mobile'),
+                'CoreFilterDelegate' => new lang_string('type_filter_plural', 'plugin'),
             ),
             "$mainmenu" => array(
                 '$mmSideMenuDelegate_mmaFrontpage' => new lang_string('sitehome'),
index dd45eed..873434b 100644 (file)
@@ -221,6 +221,11 @@ class block_base {
     public function get_content_for_output($output) {
         global $CFG;
 
+        // We can exit early if the current user doesn't have the capability to view the block.
+        if (!has_capability('moodle/block:view', $this->context)) {
+            return null;
+        }
+
         $bc = new block_contents($this->html_attributes());
         $bc->attributes['data-block'] = $this->name();
         $bc->blockinstanceid = $this->instance->id;
index b569e37..f55aa90 100644 (file)
@@ -87,7 +87,8 @@ class api {
             if ($completionexpectedtime !== null) {
                 // Calendar event exists so update it.
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->timestart = $completionexpectedtime;
                 $event->timesort = $completionexpectedtime;
                 $event->visible = instance_is_visible($modulename, $instance);
@@ -104,7 +105,8 @@ class api {
             // Event doesn't exist so create one.
             if ($completionexpectedtime !== null) {
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->courseid = $instance->course;
                 $event->groupid = 0;
                 $event->userid = 0;
index e9d2fc7..14e3f57 100644 (file)
@@ -232,6 +232,7 @@ $string['validationmsg_onedir'] = 'Invalid structure of the ZIP package.';
 $string['validationmsg_onedir_help'] = 'The ZIP package must contain just one root directory that holds the plugin code. The name of that root directory must match the name of the plugin.';
 $string['validationmsg_pathwritable'] = 'Write access check';
 $string['validationmsg_pluginversion'] = 'Plugin version';
+$string['validationmsg_pluginversiontoolow'] = 'A higher version of this plugin is already installed';
 $string['validationmsg_release'] = 'Plugin release';
 $string['validationmsg_requiresmoodle'] = 'Required Moodle version';
 $string['validationmsg_rootdir'] = 'Name of the plugin to be installed';
index 94652dc..95f70ab 100644 (file)
Binary files a/lib/amd/build/prefetch.min.js and b/lib/amd/build/prefetch.min.js differ
index ea27ce4..060c3c0 100644 (file)
Binary files a/lib/amd/build/prefetch.min.js.map and b/lib/amd/build/prefetch.min.js.map differ
index e9bf2bf..c907e2b 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index e18317b..202d4bd 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
index 6f512ef..c221d1a 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 898b39a..87973d0 100644 (file)
Binary files a/lib/amd/build/user_date.min.js.map and b/lib/amd/build/user_date.min.js.map differ
index e8e6fb8..b69606f 100644 (file)
@@ -75,11 +75,6 @@ const fetchQueue = () => {
  * Subsequent fetches are immediate.
  */
 const processQueue = () => {
-    if (Config.jsrev <= 0) {
-        // No point pre-fetching when cachejs is disabled as we do not store anything in the cache anyway.
-        return;
-    }
-
     if (prefetchTimer) {
         // There is a live prefetch timer. The initial prefetch has been scheduled but is not complete.
         return;
index 8ac79ab..db830df 100644 (file)
@@ -79,11 +79,6 @@ define([
      * @return {Object} jQuery promise resolved with the template source
      */
     var getTemplatePromiseFromCache = function(searchKey) {
-        // Do not cache anything if templaterev is not valid.
-        if (M.cfg.templaterev <= 0) {
-            return null;
-        }
-
         // First try the cache of promises.
         if (searchKey in templatePromises) {
             return templatePromises[searchKey];
@@ -96,6 +91,11 @@ define([
             return templatePromises[searchKey];
         }
 
+        if (M.cfg.templaterev <= 0) {
+            // Template caching is disabled. Do not store in persistent storage.
+            return null;
+        }
+
         // Now try local storage.
         var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);
         if (cached) {
@@ -183,7 +183,11 @@ define([
                                 // Cache all of the dependent templates because we'll need them to render
                                 // the requested template.
                                 templateCache[tempSearchKey] = data.value;
-                                storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
+
+                                if (M.cfg.templaterev > 0) {
+                                    // The template cache is enabled - set the value there.
+                                    storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
+                                }
 
                                 if (data.component == component && data.name == name) {
                                     // This is the original template that was requested so remember it to return.
index 20ea44c..95efe3d 100644 (file)
@@ -107,9 +107,14 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      */
     var loadDatesFromServer = function(dates) {
         var args = dates.map(function(data) {
+            var fixDay = data.hasOwnProperty('fixday') ? data.fixday : 1;
+            var fixHour = data.hasOwnProperty('fixhour') ? data.fixhour : 1;
             return {
                 timestamp: data.timestamp,
-                format: data.format
+                format: data.format,
+                type: data.type || '',
+                fixday: fixDay,
+                fixhour: fixHour
             };
         });
 
@@ -155,7 +160,8 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      * Only dates not found in either cache will be sent to the server
      * for transforming.
      *
-     * A request object must have a timestamp key and a format key.
+     * A request object must have a timestamp key and a format key and
+     * optionally may have a type key.
      *
      * E.g.
      * var request = [
@@ -165,7 +171,10 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      *     },
      *     {
      *         timestamp: 1293876000,
-     *         format: '%A, %d %B %Y, %I:%M %p'
+     *         format: '%A, %d %B %Y, %I:%M %p',
+     *         type: 'gregorian',
+     *         fixday: false,
+     *         fixhour: false
      *     }
      * ];
      *
index 488ce71..c6d2aa1 100644 (file)
@@ -155,6 +155,9 @@ class antivirus_clamav_tcpsockethost_setting extends antivirus_clamav_socket_set
         }
         $runningmethod = get_config('antivirus_clamav', 'runningmethod');
         $tcpport = get_config('antivirus_clamav', 'tcpsocketport');
+        if ($tcpport === false) {
+            $tcpport = 3310;
+        }
         if ($runningmethod === 'tcpsocket') {
             return $this->validate_clamav_socket('tcp://' . $data . ':' . $tcpport);
         }
index 624abc4..047cb49 100644 (file)
@@ -28,8 +28,8 @@ defined('MOODLE_INTERNAL') || die();
 
 /** Default socket timeout */
 define('ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT', 10);
-/** Default socket data stream chunk size */
-define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 1024);
+/** Default socket data stream chunk size (32Mb: 32 * 1024 * 1024) */
+define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 33554432);
 
 /**
  * Class implementing ClamAV antivirus.
index 4aa1f45..197dbd2 100644 (file)
@@ -27,6 +27,8 @@ namespace core\task;
  * Simple task to run cron for all plugins.
  * Note - this is only for plugins using the legacy cron method,
  * plugins can also now just add their own scheduled tasks which is the preferred method.
+ * @deprecated since Moodle 3.9 MDL-52846. Please use new task API.
+ * @todo MDL-61165 This will be deleted in Moodle 4.3
  */
 class legacy_plugin_cron_task extends scheduled_task {
 
@@ -56,6 +58,8 @@ class legacy_plugin_cron_task extends scheduled_task {
             if (method_exists($authplugin, 'cron')) {
                 mtrace("Running cron for auth/$auth...");
                 $authplugin->cron();
+                debugging("Use of legacy cron is deprecated (auth/$auth). Please use scheduled tasks.",
+                    DEBUG_DEVELOPER);
                 if (!empty($authplugin->log)) {
                     mtrace($authplugin->log);
                 }
@@ -74,6 +78,8 @@ class legacy_plugin_cron_task extends scheduled_task {
             }
             mtrace("Running cron for enrol_$ename...");
             $enrol->cron();
+            debugging("Use of legacy cron is deprecated (enrol_$ename). Please use scheduled tasks.",
+                DEBUG_DEVELOPER);
             $enrol->set_config('lastcron', time());
         }
 
@@ -91,6 +97,8 @@ class legacy_plugin_cron_task extends scheduled_task {
                         $predbqueries = $DB->perf_get_queries();
                         $pretime      = microtime(1);
                         if ($cronfunction()) {
+                            debugging("Use of legacy cron is deprecated ($cronfunction). Please use scheduled tasks.",
+                                DEBUG_DEVELOPER);
                             $DB->set_field("modules", "lastcron", $timenow, array("id" => $mod->id));
                         }
                         if (isset($predbqueries)) {
@@ -119,6 +127,8 @@ class legacy_plugin_cron_task extends scheduled_task {
                     if (method_exists($blockobj, 'cron')) {
                         mtrace("Processing cron function for ".$block->name.'....', '');
                         if ($blockobj->cron()) {
+                            debugging("Use of legacy cron is deprecated ($classname::cron()). Please use scheduled tasks.",
+                                DEBUG_DEVELOPER);
                             $DB->set_field('block', 'lastcron', $timenow, array('id' => $block->id));
                         }
                         // Reset possible changes by blocks to time_limit. MDL-11597.
index 7d0f997..b57cdf7 100644 (file)
@@ -394,6 +394,13 @@ class validator {
         }
         $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
 
+        // Ensure the version we are uploading is higher than the version currently installed.
+        $plugininfo = $this->get_plugin_manager()->get_plugin_info($this->versionphp['component']);
+        if (!is_null($plugininfo) && $this->versionphp['version'] < $plugininfo->versiondb) {
+            $this->add_message(self::ERROR, 'pluginversiontoolow', $plugininfo->versiondb);
+            return false;
+        }
+
         if (isset($info['plugin->maturity'])) {
             $this->versionphp['maturity'] = $info['plugin->maturity'];
             if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
index bea9252..88c7463 100644 (file)
@@ -377,133 +377,6 @@ function cron_trace_time_and_memory() {
     mtrace('... started ' . date('H:i:s') . '. Current memory use ' . display_size(memory_get_usage()) . '.');
 }
 
-/**
- * Executes cron functions for a specific type of plugin.
- *
- * @param string $plugintype Plugin type (e.g. 'report')
- * @param string $description If specified, will display 'Starting (whatever)'
- *   and 'Finished (whatever)' lines, otherwise does not display
- */
-function cron_execute_plugin_type($plugintype, $description = null) {
-    global $DB;
-
-    // Get list from plugin => function for all plugins
-    $plugins = get_plugin_list_with_function($plugintype, 'cron');
-
-    // Modify list for backward compatibility (different files/names)
-    $plugins = cron_bc_hack_plugin_functions($plugintype, $plugins);
-
-    // Return if no plugins with cron function to process
-    if (!$plugins) {
-        return;
-    }
-
-    if ($description) {
-        mtrace('Starting '.$description);
-    }
-
-    foreach ($plugins as $component=>$cronfunction) {
-        $dir = core_component::get_component_directory($component);
-
-        // Get cron period if specified in version.php, otherwise assume every cron
-        $cronperiod = 0;
-        if (file_exists("$dir/version.php")) {
-            $plugin = new stdClass();
-            include("$dir/version.php");
-            if (isset($plugin->cron)) {
-                $cronperiod = $plugin->cron;
-            }
-        }
-
-        // Using last cron and cron period, don't run if it already ran recently
-        $lastcron = get_config($component, 'lastcron');
-        if ($cronperiod && $lastcron) {
-            if ($lastcron + $cronperiod > time()) {
-                // do not execute cron yet
-                continue;
-            }
-        }
-
-        mtrace('Processing cron function for ' . $component . '...');
-        cron_trace_time_and_memory();
-        $pre_dbqueries = $DB->perf_get_queries();
-        $pre_time = microtime(true);
-
-        $cronfunction();
-
-        mtrace("done. (" . ($DB->perf_get_queries() - $pre_dbqueries) . " dbqueries, " .
-                round(microtime(true) - $pre_time, 2) . " seconds)");
-
-        set_config('lastcron', time(), $component);
-        core_php_time_limit::raise();
-    }
-
-    if ($description) {
-        mtrace('Finished ' . $description);
-    }
-}
-
-/**
- * Used to add in old-style cron functions within plugins that have not been converted to the
- * new standard API. (The standard API is frankenstyle_name_cron() in lib.php; some types used
- * cron.php and some used a different name.)
- *
- * @param string $plugintype Plugin type e.g. 'report'
- * @param array $plugins Array from plugin name (e.g. 'report_frog') to function name (e.g.
- *   'report_frog_cron') for plugin cron functions that were already found using the new API
- * @return array Revised version of $plugins that adds in any extra plugin functions found by
- *   looking in the older location
- */
-function cron_bc_hack_plugin_functions($plugintype, $plugins) {
-    global $CFG; // mandatory in case it is referenced by include()d PHP script
-
-    if ($plugintype === 'report') {
-        // Admin reports only - not course report because course report was
-        // never implemented before, so doesn't need BC
-        foreach (core_component::get_plugin_list($plugintype) as $pluginname=>$dir) {
-            $component = $plugintype . '_' . $pluginname;
-            if (isset($plugins[$component])) {
-                // We already have detected the function using the new API
-                continue;
-            }
-            if (!file_exists("$dir/cron.php")) {
-                // No old style cron file present
-                continue;
-            }
-            include_once("$dir/cron.php");
-            $cronfunction = $component . '_cron';
-            if (function_exists($cronfunction)) {
-                $plugins[$component] = $cronfunction;
-            } else {
-                debugging("Invalid legacy cron.php detected in $component, " .
-                        "please use lib.php instead");
-            }
-        }
-    } else if (strpos($plugintype, 'grade') === 0) {
-        // Detect old style cron function names
-        // Plugin gradeexport_frog used to use grade_export_frog_cron() instead of
-        // new standard API gradeexport_frog_cron(). Also applies to gradeimport, gradereport
-        foreach(core_component::get_plugin_list($plugintype) as $pluginname=>$dir) {
-            $component = $plugintype.'_'.$pluginname;
-            if (isset($plugins[$component])) {
-                // We already have detected the function using the new API
-                continue;
-            }
-            if (!file_exists("$dir/lib.php")) {
-                continue;
-            }
-            include_once("$dir/lib.php");
-            $cronfunction = str_replace('grade', 'grade_', $plugintype) . '_' .
-                    $pluginname . '_cron';
-            if (function_exists($cronfunction)) {
-                $plugins[$component] = $cronfunction;
-            }
-        }
-    }
-
-    return $plugins;
-}
-
 /**
  * Prepare the output renderer for the cron run.
  *
index 938746e..faeb9ac 100644 (file)
@@ -3425,3 +3425,137 @@ function cron_run_single_task(\core\task\scheduled_task $task) {
         DEBUG_DEVELOPER);
     return \core\task\manager::run_from_cli($task);
 }
+
+/**
+ * Executes cron functions for a specific type of plugin.
+ *
+ * @param string $plugintype Plugin type (e.g. 'report')
+ * @param string $description If specified, will display 'Starting (whatever)'
+ *   and 'Finished (whatever)' lines, otherwise does not display
+ *
+ * @deprecated since Moodle 3.9 MDL-52846. Please use new task API.
+ * @todo MDL-61165 This will be deleted in Moodle 4.3.
+ */
+function cron_execute_plugin_type($plugintype, $description = null) {
+    global $DB;
+
+    // Get list from plugin => function for all plugins.
+    $plugins = get_plugin_list_with_function($plugintype, 'cron');
+
+    // Modify list for backward compatibility (different files/names).
+    $plugins = cron_bc_hack_plugin_functions($plugintype, $plugins);
+
+    // Return if no plugins with cron function to process.
+    if (!$plugins) {
+        return;
+    }
+
+    if ($description) {
+        mtrace('Starting '.$description);
+    }
+
+    foreach ($plugins as $component => $cronfunction) {
+        $dir = core_component::get_component_directory($component);
+
+        // Get cron period if specified in version.php, otherwise assume every cron.
+        $cronperiod = 0;
+        if (file_exists("$dir/version.php")) {
+            $plugin = new stdClass();
+            include("$dir/version.php");
+            if (isset($plugin->cron)) {
+                $cronperiod = $plugin->cron;
+            }
+        }
+
+        // Using last cron and cron period, don't run if it already ran recently.
+        $lastcron = get_config($component, 'lastcron');
+        if ($cronperiod && $lastcron) {
+            if ($lastcron + $cronperiod > time()) {
+                // Do not execute cron yet.
+                continue;
+            }
+        }
+
+        mtrace('Processing cron function for ' . $component . '...');
+        debugging("Use of legacy cron is deprecated ($cronfunction). Please use scheduled tasks.", DEBUG_DEVELOPER);
+        cron_trace_time_and_memory();
+        $pre_dbqueries = $DB->perf_get_queries();
+        $pre_time = microtime(true);
+
+        $cronfunction();
+
+        mtrace("done. (" . ($DB->perf_get_queries() - $pre_dbqueries) . " dbqueries, " .
+                round(microtime(true) - $pre_time, 2) . " seconds)");
+
+        set_config('lastcron', time(), $component);
+        core_php_time_limit::raise();
+    }
+
+    if ($description) {
+        mtrace('Finished ' . $description);
+    }
+}
+
+/**
+ * Used to add in old-style cron functions within plugins that have not been converted to the
+ * new standard API. (The standard API is frankenstyle_name_cron() in lib.php; some types used
+ * cron.php and some used a different name.)
+ *
+ * @param string $plugintype Plugin type e.g. 'report'
+ * @param array $plugins Array from plugin name (e.g. 'report_frog') to function name (e.g.
+ *   'report_frog_cron') for plugin cron functions that were already found using the new API
+ * @return array Revised version of $plugins that adds in any extra plugin functions found by
+ *   looking in the older location
+ *
+ * @deprecated since Moodle 3.9 MDL-52846. Please use new task API.
+ * @todo MDL-61165 This will be deleted in Moodle 4.3.
+ */
+function cron_bc_hack_plugin_functions($plugintype, $plugins) {
+    global $CFG; // Mandatory in case it is referenced by include()d PHP script.
+
+    if ($plugintype === 'report') {
+        // Admin reports only - not course report because course report was
+        // never implemented before, so doesn't need BC.
+        foreach (core_component::get_plugin_list($plugintype) as $pluginname => $dir) {
+            $component = $plugintype . '_' . $pluginname;
+            if (isset($plugins[$component])) {
+                // We already have detected the function using the new API.
+                continue;
+            }
+            if (!file_exists("$dir/cron.php")) {
+                // No old style cron file present.
+                continue;
+            }
+            include_once("$dir/cron.php");
+            $cronfunction = $component . '_cron';
+            if (function_exists($cronfunction)) {
+                $plugins[$component] = $cronfunction;
+            } else {
+                debugging("Invalid legacy cron.php detected in $component, " .
+                        "please use lib.php instead");
+            }
+        }
+    } else if (strpos($plugintype, 'grade') === 0) {
+        // Detect old style cron function names.
+        // Plugin gradeexport_frog used to use grade_export_frog_cron() instead of
+        // new standard API gradeexport_frog_cron(). Also applies to gradeimport, gradereport.
+        foreach (core_component::get_plugin_list($plugintype) as $pluginname => $dir) {
+            $component = $plugintype.'_'.$pluginname;
+            if (isset($plugins[$component])) {
+                // We already have detected the function using the new API.
+                continue;
+            }
+            if (!file_exists("$dir/lib.php")) {
+                continue;
+            }
+            include_once("$dir/lib.php");
+            $cronfunction = str_replace('grade', 'grade_', $plugintype) . '_' .
+                    $pluginname . '_cron';
+            if (function_exists($cronfunction)) {
+                $plugins[$component] = $cronfunction;
+            }
+        }
+    }
+
+    return $plugins;
+}
index 6721d2a..c8e08ab 100644 (file)
@@ -120,7 +120,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_string() result value
      *
-     * @return string
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_string_returns() {
@@ -189,7 +189,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_string() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_strings_returns() {
@@ -233,6 +233,9 @@ class core_external extends external_api {
                         [
                             'timestamp' => new external_value(PARAM_INT, 'unix timestamp'),
                             'format' => new external_value(PARAM_TEXT, 'format string'),
+                            'type' => new external_value(PARAM_PLUGIN, 'The calendar type', VALUE_DEFAULT),
+                            'fixday' => new external_value(PARAM_INT, 'Remove leading zero for day', VALUE_DEFAULT, 1),
+                            'fixhour' => new external_value(PARAM_INT, 'Remove leading zero for hour', VALUE_DEFAULT, 1),
                         ]
                     )
                 )
@@ -264,7 +267,12 @@ class core_external extends external_api {
         self::validate_context($context);
 
         $formatteddates = array_map(function($timestamp) {
-            return userdate($timestamp['timestamp'], $timestamp['format']);
+
+            $calendartype = $timestamp['type'];
+            $fixday = !empty($timestamp['fixday']);
+            $fixhour = !empty($timestamp['fixhour']);
+            $calendar  = \core_calendar\type_factory::get_calendar_instance($calendartype);
+            return $calendar->timestamp_to_date_string($timestamp['timestamp'], $timestamp['format'], 99, $fixday, $fixhour);
         }, $params['timestamps']);
 
         return ['dates' => $formatteddates];
@@ -273,7 +281,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_user_dates() result value
      *
-     * @return array
+     * @return external_description
      */
     public static function get_user_dates_returns() {
         return new external_single_structure(
@@ -333,7 +341,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_component_strings() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_component_strings_returns() {
@@ -421,7 +429,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_fragment() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 3.1
      */
     public static function get_fragment_returns() {
index 6277bc7..bf43f49 100644 (file)
@@ -203,7 +203,6 @@ class core_external_testcase extends externallib_advanced_testcase {
     }
 
     public function test_get_user_dates() {
-        global $USER, $CFG, $DB;
         $this->resetAfterTest();
 
         $this->setAdminUser();
@@ -222,6 +221,11 @@ class core_external_testcase extends externallib_advanced_testcase {
                 'timestamp' => 1293876000,
                 'format' => '%d %m %Y'
             ],
+            [
+                'timestamp' => 1293876000,
+                'format' => '%d %m %Y',
+                'type' => 'gregorian'
+            ],
             [
                 'timestamp' => 1293876000,
                 'format' => 'some invalid format'
@@ -233,6 +237,7 @@ class core_external_testcase extends externallib_advanced_testcase {
 
         $this->assertEquals('Saturday, 1 January 2011, 6:00', $result['dates'][0]);
         $this->assertEquals('1 01 2011', $result['dates'][1]);
-        $this->assertEquals('some invalid format', $result['dates'][2]);
+        $this->assertEquals('1 01 2011', $result['dates'][2]);
+        $this->assertEquals('some invalid format', $result['dates'][3]);
     }
 }
index 4336eb1..34b4869 100644 (file)
@@ -979,15 +979,19 @@ function groups_group_visible($groupid, $course, $cm = null, $userid = null) {
 }
 
 /**
- * Get sql and parameters that will return user ids for a group
+ * Get sql and parameters that will return user ids for a group or groups
  *
- * @param int $groupid
+ * @param int|array $groupids Where this is an array of multiple groups, it will match on members of any of the groups
  * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
  * @return array($sql, $params)
  * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_ids_sql($groupid, context $context = null) {
-    $groupjoin = groups_get_members_join($groupid, 'u.id', $context);
+function groups_get_members_ids_sql($groupids, context $context = null) {
+    if (!is_array($groupids)) {
+        $groupids = [$groupids];
+    }
+
+    $groupjoin = groups_get_members_join($groupids, 'u.id', $context);
 
     $sql = "SELECT DISTINCT u.id
               FROM {user} u
@@ -1003,39 +1007,61 @@ function groups_get_members_ids_sql($groupid, context $context = null) {
 /**
  * Get sql join to return users in a group
  *
- * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int|array $groupids The groupids, 0 or [] means all groups and USERSWITHOUTGROUP no group
  * @param string $useridcolumn The column of the user id from the calling SQL, e.g. u.id
- * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
+ * @param context $context Course context or a context within a course. Mandatory when $groupids includes USERSWITHOUTGROUP
  * @return \core\dml\sql_join Contains joins, wheres, params
  * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_join($groupid, $useridcolumn, context $context = null) {
+function groups_get_members_join($groupids, $useridcolumn, context $context = null) {
+    global $DB;
+
     // Use unique prefix just in case somebody makes some SQL magic with the result.
     static $i = 0;
     $i++;
     $prefix = 'gm' . $i . '_';
 
+    if (!is_array($groupids)) {
+        $groupids = $groupids ? [$groupids] : [];
+    }
+
     $coursecontext = (!empty($context)) ? $context->get_course_context() : null;
-    if ($groupid == USERSWITHOUTGROUP && empty($coursecontext)) {
+    if (in_array(USERSWITHOUTGROUP, $groupids) && empty($coursecontext)) {
         // Throw an exception if $context is empty or invalid because it's needed to get the users without any group.
         throw new coding_exception('Missing or wrong $context parameter in an attempt to get members without any group');
     }
 
-    if ($groupid == USERSWITHOUTGROUP) {
+    // Handle cases where we need to include users not in any groups.
+    if (($nogroupskey = array_search(USERSWITHOUTGROUP, $groupids)) !== false) {
         // Get members without any group.
         $join = "LEFT JOIN (
                     SELECT g.courseid, m.groupid, m.userid
                     FROM {groups_members} m
                     JOIN {groups} g ON g.id = m.groupid
-                ) {$prefix}gm ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.courseid = :{$prefix}gcourseid)";
+                ) {$prefix}gm ON ({$prefix}gm.userid = {$useridcolumn} AND {$prefix}gm.courseid = :{$prefix}gcourseid)";
         $where = "{$prefix}gm.userid IS NULL";
-        $param = array("{$prefix}gcourseid" => $coursecontext->instanceid);
+        $param = ["{$prefix}gcourseid" => $coursecontext->instanceid];
+        unset($groupids[$nogroupskey]);
+
+        // Handle any groups that also need to be included (eg searching for users in no groups OR within specified groups).
+        if (!empty($groupids)) {
+            list($groupssql, $groupsparams) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, $prefix);
+
+            $join .= "LEFT JOIN {groups_members} {$prefix}gm2
+                             ON ({$prefix}gm2.userid = {$useridcolumn} AND {$prefix}gm2.groupid {$groupssql})";
+            // TODO: This only handles 'Any' (logical OR) of the provided groups. MDL-68348 will add 'All' and 'None' support.
+            $where = "({$where} OR {$prefix}gm2.userid IS NOT NULL)";
+            $param = array_merge($param, $groupsparams);
+        }
+
     } else {
-        // Get members of defined groupid.
+        // Get members of defined group IDs only.
+        list($groupssql, $param) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, $prefix);
+
+        // TODO: This only handles 'Any' (logical OR) of the provided groups. MDL-68348 will add 'All' and 'None' support.
         $join = "JOIN {groups_members} {$prefix}gm
-                ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.groupid = :{$prefix}gmid)";
+                   ON ({$prefix}gm.userid = {$useridcolumn} AND {$prefix}gm.groupid {$groupssql})";
         $where = '';
-        $param = array("{$prefix}gmid" => $groupid);
     }
 
     return new \core\dml\sql_join($join, $where, $param);
index d50d022..e7cb41e 100644 (file)
@@ -1207,7 +1207,6 @@ class theme_config {
      * @return string CSS markup
      */
     public function get_css_content_debug($type, $subtype, $sheet) {
-
         if ($type === 'scss') {
             // The SCSS file of the theme is requested.
             $csscontent = $this->get_css_content_from_scss(true);
@@ -1430,9 +1429,17 @@ class theme_config {
 
         // TODO: MDL-62757 When changing anything in this method please do not forget to check
         // if the validate() method in class admin_setting_configthemepreset needs updating too.
-
+        $cacheoptions = '';
+        if ($themedesigner) {
+            $scsscachedir = $CFG->localcachedir . '/scsscache/';
+            $cacheoptions = array(
+                  'cacheDir' => $scsscachedir,
+                  'prefix' => 'scssphp_',
+                  'forceRefresh' => false,
+            );
+        }
         // Set-up the compiler.
-        $compiler = new core_scss();
+        $compiler = new core_scss($cacheoptions);
         $compiler->prepend_raw_scss($this->get_pre_scss_code());
         if (is_string($scss)) {
             $compiler->set_file($scss);
index 236b0ed..7bca2a7 100644 (file)
@@ -1390,9 +1390,7 @@ class page_requirements_manager {
         // First include must be to a module with no dependencies, this prevents multiple requests.
         $prefix = 'M.util.js_pending("core/first");';
         $prefix .= "require(['core/first'], function() {\n";
-        if ($cachejs) {
-            $prefix .= "require(['core/prefetch']);\n";
-        }
+        $prefix .= "require(['core/prefetch']);\n";
         $suffix = 'M.util.js_complete("core/first");';
         $suffix .= "\n});";
         $output .= html_writer::script($prefix . implode(";\n", $this->amdjscode) . $suffix);
index 7e17307..99366fc 100644 (file)
@@ -1572,6 +1572,15 @@ class moodle_page {
         $this->_wherethemewasinitialised = debug_backtrace();
     }
 
+    /**
+     * For diagnostic/debugging purposes, find where the theme setup was triggered.
+     *
+     * @return null|array null if theme not yet setup. Stacktrace if it was.
+     */
+    public function get_where_theme_was_initialised() {
+        return $this->_wherethemewasinitialised;
+    }
+
     /**
      * Reset the theme and output for a new context. This only makes sense from
      * external::validate_context(). Do not cheat.
diff --git a/lib/templates/time_element.mustache b/lib/templates/time_element.mustache
new file mode 100644 (file)
index 0000000..f225448
--- /dev/null
@@ -0,0 +1,92 @@
+{{!
+    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/time_element
+
+    Template to display an HTML time element.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-timestamp Number - The timestamp for the element.
+    * data-datetimeformat String - A valid format for the datetime attribute.
+
+    Context variables required for this template:
+    * timestamp Number - The timestamp for the element.
+    * userdateformat String - The user-facing date format
+    * datetimeformat String - A valid format for the datetime attribute. Defaults to the ISO-8601 format of '%Y-%m-%dT%H:%M%z'.
+    Example context (json):
+    {
+        "timestamp": 0,
+        "userdateformat": "%d %b %Y",
+        "datetimeformat": "%Y-%m-%dT%H:%M%z"
+    }
+}}
+<time id="time-{{$elementid}}{{uniqid}}{{/elementid}}" class="{{$elementclass}}{{timeclass}}{{/elementclass}}" datetime="{{$datetimeval}}{{datetime}}{{/datetimeval}}"
+      data-timestamp="{{$timestampval}}{{timestamp}}{{/timestampval}}"
+      data-datetimeformat="{{$datetimeformatval}}{{#datetimeformat}}{{.}}{{/datetimeformat}}{{^datetimeformat}}%Y-%m-%dT%H:%M%z{{/datetimeformat}}{{/datetimeformatval}}">
+    {{$datedisplay}}
+        {{#userdate}} {{$timestampval}}{{timestamp}}{{/timestampval}}, {{$userdateformatval}}{{userdateformat}}{{/userdateformatval}} {{/userdate}}
+    {{/datedisplay}}
+</time>
+{{#js}}
+    /** Fetches the formatted date/time for the time element's datetime attribute. */
+    require(['core/user_date'], function(UserDate) {
+        var root = document.getElementById('time-{{$elementid}}{{uniqid}}{{/elementid}}');
+        // Fetch value for the datetime attribute using core/user_date, if it's not available.
+        if (!root.getAttribute('datetime')) {
+            var dateTimeFormat = root.getAttribute('data-datetimeformat');
+            var timestamp = root.getAttribute('data-timestamp');
+
+            if (!dateTimeFormat.match(/%(?![YmdHMSzZ])./g)) {
+                var zeroPad = function(nNum, nPad) {
+                    return ((Math.pow(10, nPad) + nNum) + '').slice(1);
+                };
+
+                var date = new Date(timestamp * 1000);
+
+                var datetime = dateTimeFormat.replace(/%./g, function(sMatch) {
+                    return (({
+                        '%Y': date.getFullYear(),
+                        '%m': zeroPad(date.getMonth() + 1, 2),
+                        '%d': zeroPad(date.getDate(), 2),
+                        '%H': zeroPad(date.getHours(), 2),
+                        '%M': zeroPad(date.getMinutes(), 2),
+                        '%S': zeroPad(date.getSeconds(), 2),
+                        '%z': date.toTimeString().replace(/.+GMT([+-]\d+).+/, '$1'),
+                        '%Z': date.toTimeString().replace(/.+\((.+?)\)$/, '$1')
+                    }[sMatch] || '') + '') || sMatch;
+                });
+                root.setAttribute('datetime', datetime);
+            }  else {
+                // Otherwise, use core/user_date.
+                var timestamps = [{
+                    timestamp: timestamp,
+                    format: dateTimeFormat,
+                    type: 'gregorian',
+                    fixday: 0,
+                    fixhour: 0
+                }];
+                UserDate.get(timestamps).done(function(dates) {
+                    var datetime = dates.pop();
+                    root.setAttribute('datetime', datetime);
+                });
+            }
+        }
+    });
+{{/js}}
index ce5da1f..3c4d9a4 100644 (file)
@@ -114,10 +114,14 @@ abstract class testing_block_generator extends component_generator_base {
      * @return stdClass the block_instance record that has just been created.
      */
     public function create_instance($record = null, $options = array()) {
-        global $DB;
+        global $DB, $PAGE;
 
         $this->instancecount++;
 
+        // Creating a block is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the block instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         $record = (object)(array)$record;
         $this->preprocess_record($record, $options);
         $record = $this->prepare_record($record);
@@ -133,6 +137,19 @@ abstract class testing_block_generator extends component_generator_base {
         context_block::instance($id);
 
         $instance = $DB->get_record('block_instances', array('id' => $id), '*', MUST_EXIST);
+
+        // If the theme was initialised while creating the block instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a block_' . $this->get_blockname() . ' initialised the theme and output!',
+                'This should not happen. Creating a block should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $instance;
     }
 
index 5400648..4a2396e 100644 (file)
@@ -222,11 +222,15 @@ abstract class testing_module_generator extends component_generator_base {
      *     cmid (corresponding id in course_modules table)
      */
     public function create_instance($record = null, array $options = null) {
-        global $CFG, $DB;
+        global $CFG, $DB, $PAGE;
         require_once($CFG->dirroot.'/course/modlib.php');
 
         $this->instancecount++;
 
+        // Creating an activity is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the module instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         // Merge options into record and add default values.
         $record = $this->prepare_moduleinfo_record($record, $options);
 
@@ -269,6 +273,19 @@ abstract class testing_module_generator extends component_generator_base {
         // Prepare object to return with additional field cmid.
         $instance = $DB->get_record($this->get_modulename(), array('id' => $moduleinfo->instance), '*', MUST_EXIST);
         $instance->cmid = $moduleinfo->coursemodule;
+
+        // If the theme was initialised while creating the module instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a mod_' . $this->get_modulename() . ' activity initialised the theme and output!',
+                'This should not happen. Creating an activity should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $instance;
     }
 
index 3d5f560..21a4066 100644 (file)
@@ -111,12 +111,16 @@ class testing_repository_generator extends component_generator_base {
      * @return stdClass repository instance record
      */
     public function create_instance($record = null, array $options = null) {
-        global $CFG, $DB;
+        global $CFG, $DB, $PAGE;
         require_once($CFG->dirroot . '/repository/lib.php');
 
         $this->instancecount++;
         $record = (array) $record;
 
+        // Creating a repository is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the repository instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         $typeid = $DB->get_field('repository', 'id', array('type' => $this->get_typename()), MUST_EXIST);
         $instanceoptions = repository::static_function($this->get_typename(), 'get_instance_option_names');
 
@@ -146,6 +150,18 @@ class testing_repository_generator extends component_generator_base {
             $id = repository::static_function($this->get_typename(), 'create', $this->get_typename(), 0, $context, $record);
         }
 
+        // If the theme was initialised while creating the repository instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a repository_' . $this->get_typename() . ' initialised the theme and output!',
+                'This should not happen. Creating a repository should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $DB->get_record('repository_instances', array('id' => $id), '*', MUST_EXIST);
     }
 
index d63c084..117d323 100644 (file)
@@ -213,6 +213,53 @@ class core_grouplib_testcase extends advanced_testcase {
         $this->assertTrue(array_key_exists($student1->id, $users));
     }
 
+    public function test_groups_get_members_ids_sql_multiple_groups() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        $course = $generator->create_course();
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
+        $plugin = enrol_get_plugin('manual');
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $group1 = $generator->create_group(array('courseid' => $course->id));
+        $group2 = $generator->create_group(array('courseid' => $course->id));
+        $groupids = [
+            $group1->id,
+            $group2->id,
+        ];
+        $instance = $DB->get_record('enrol', array(
+                'courseid' => $course->id,
+                'enrol' => 'manual',
+        ));
+
+        $this->assertNotEquals($instance, false);
+
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
+
+        list($sql, $params) = groups_get_members_ids_sql($groupids);
+
+        // Test an empty group.
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertFalse(array_key_exists($student1->id, $users));
+
+        // Test with a member of one of the two group.
+        groups_add_member($group1->id, $student1->id);
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertTrue(array_key_exists($student1->id, $users));
+
+        // Test with members of two groups.
+        groups_add_member($group2->id, $student2->id);
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertTrue(array_key_exists($student1->id, $users));
+        $this->assertTrue(array_key_exists($student2->id, $users));
+    }
+
     public function test_groups_get_members_ids_sql_valid_context() {
         global $DB;
 
index 68379ea..c6e0d0d 100644 (file)
@@ -60,6 +60,17 @@ information provided here is intended especially for developers.
   The confirmation dialogue no longer has a configurable "No" button as per similar changes in MDL-59759.
   This set of confirmation modals was unintentionally missed from that deprecation process.
 * The download_as_dataformat() method has been deprecated. Please use \core\dataformat::download_data() instead
+* The following functions have been updated to support passing in an array of group IDs (but still support passing in a single ID):
+  * groups_get_members_join()
+  * groups_get_members_ids_sql()
+* Additional parameters were added to core_get_user_dates:
+    - type: specifies the calendar type. Optional, defaults to Gregorian.
+    - fixday: Whether to remove leading zero for day. Optional, defaults to 1.
+    - fixhour: Whether to remove leading zero for hour. Optional, defaults to 1.
+* Legacy cron has been deprecated and will be removed in Moodle 4.3. This includes the functions:
+  - cron_execute_plugin_type()
+  - cron_bc_hack_plugin_functions()
+  Please, use the Task API instead: https://docs.moodle.org/dev/Task_API
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index 5f65f2e..d9ded66 100644 (file)
@@ -297,7 +297,8 @@ function assign_update_events($assign, $override = null) {
 
         $event = new stdClass();
         $event->type = CALENDAR_EVENT_TYPE_ACTION;
-        $event->description = format_module_intro('assign', $assigninstance, $cmid);
+        $event->description = format_module_intro('assign', $assigninstance, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $assigninstance->course;
         $event->groupid     = $groupid;
index 503023c..478fe8b 100644 (file)
@@ -123,7 +123,8 @@ function chat_add_instance($chat) {
         $event = new stdClass();
         $event->type        = CALENDAR_EVENT_TYPE_ACTION;
         $event->name        = $chat->name;
-        $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+        $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+        $event->format      = FORMAT_HTML;
         $event->courseid    = $chat->course;
         $event->groupid     = 0;
         $event->userid      = 0;
@@ -169,7 +170,8 @@ function chat_update_instance($chat) {
         if ($chat->schedule > 0) {
             $event->type        = CALENDAR_EVENT_TYPE_ACTION;
             $event->name        = $chat->name;
-            $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+            $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+            $event->format      = FORMAT_HTML;
             $event->timestart   = $chat->chattime;
             $event->timesort    = $chat->chattime;
 
@@ -186,7 +188,8 @@ function chat_update_instance($chat) {
             $event = new stdClass();
             $event->type        = CALENDAR_EVENT_TYPE_ACTION;
             $event->name        = $chat->name;
-            $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+            $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+            $event->format      = FORMAT_HTML;
             $event->courseid    = $chat->course;
             $event->groupid     = 0;
             $event->userid      = 0;
@@ -460,7 +463,8 @@ function chat_prepare_update_events($chat, $cm = null) {
     $event = new stdClass();
     $event->name        = $chat->name;
     $event->type        = CALENDAR_EVENT_TYPE_ACTION;
-    $event->description = format_module_intro('chat', $chat, $cm->id);
+    $event->description = format_module_intro('chat', $chat, $cm->id, false);
+    $event->format      = FORMAT_HTML;
     $event->timestart   = $chat->chattime;
     $event->timesort    = $chat->chattime;
     if ($event->id = $DB->get_field('event', 'id', array('modulename' => 'chat', 'instance' => $chat->id,
index b5cb29b..a00641d 100644 (file)
@@ -52,7 +52,8 @@ function choice_set_events($choice) {
         if ((!empty($choice->timeopen)) && ($choice->timeopen > 0)) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarstart', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $choice->timeopen;
             $event->timesort     = $choice->timeopen;
             $event->visible      = instance_is_visible('choice', $choice);
@@ -68,7 +69,8 @@ function choice_set_events($choice) {
         // Event doesn't exist so create one.
         if ((!empty($choice->timeopen)) && ($choice->timeopen > 0)) {
             $event->name         = get_string('calendarstart', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $choice->course;
             $event->groupid      = 0;
             $event->userid       = 0;
@@ -91,7 +93,8 @@ function choice_set_events($choice) {
         if ((!empty($choice->timeclose)) && ($choice->timeclose > 0)) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarend', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $choice->timeclose;
             $event->timesort     = $choice->timeclose;
             $event->visible      = instance_is_visible('choice', $choice);
@@ -107,7 +110,8 @@ function choice_set_events($choice) {
         // Event doesn't exist so create one.
         if ((!empty($choice->timeclose)) && ($choice->timeclose > 0)) {
             $event->name         = get_string('calendarend', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $choice->course;
             $event->groupid      = 0;
             $event->userid       = 0;
index b8bad67..c915408 100644 (file)
@@ -607,7 +607,8 @@ function data_set_events($data) {
         if ($data->timeavailablefrom > 0) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarstart', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $data->timeavailablefrom;
             $event->timesort     = $data->timeavailablefrom;
             $event->visible      = instance_is_visible('data', $data);
@@ -623,7 +624,8 @@ function data_set_events($data) {
         // Event doesn't exist so create one.
         if (isset($data->timeavailablefrom) && $data->timeavailablefrom > 0) {
             $event->name         = get_string('calendarstart', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $data->course;
             $event->groupid      = 0;
             $event->userid       = 0;
@@ -646,7 +648,8 @@ function data_set_events($data) {
         if ($data->timeavailableto > 0) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarend', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $data->timeavailableto;
             $event->timesort     = $data->timeavailableto;
             $event->visible      = instance_is_visible('data', $data);
@@ -662,7 +665,8 @@ function data_set_events($data) {
         // Event doesn't exist so create one.
         if (isset($data->timeavailableto) && $data->timeavailableto > 0) {
             $event->name         = get_string('calendarend', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $data->course;
             $event->groupid      = 0;
             $event->userid       = 0;
index a318641..458cb73 100644 (file)
@@ -809,7 +809,8 @@ function feedback_set_events($feedback) {
         $event->eventtype    = FEEDBACK_EVENT_TYPE_OPEN;
         $event->type         = empty($feedback->timeclose) ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
         $event->name         = get_string('calendarstart', 'feedback', $feedback->name);
-        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule);
+        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule, false);
+        $event->format       = FORMAT_HTML;
         $event->timestart    = $feedback->timeopen;
         $event->timesort     = $feedback->timeopen;
         $event->visible      = instance_is_visible('feedback', $feedback);
@@ -844,7 +845,8 @@ function feedback_set_events($feedback) {
         $event->type         = CALENDAR_EVENT_TYPE_ACTION;
         $event->eventtype    = FEEDBACK_EVENT_TYPE_CLOSE;
         $event->name         = get_string('calendarend', 'feedback', $feedback->name);
-        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule);
+        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule, false);
+        $event->format       = FORMAT_HTML;
         $event->timestart    = $feedback->timeclose;
         $event->timesort     = $feedback->timeclose;
         $event->visible      = instance_is_visible('feedback', $feedback);
index 8faf0f9..a1919f6 100644 (file)
@@ -638,9 +638,8 @@ class post extends exporter {
     private function get_author_subheading_html(stdClass $exportedauthor, int $timecreated) : string {
         $fullname = $exportedauthor->fullname;
         $profileurl = $exportedauthor->urls['profile'] ?? null;
-        $formatteddate = userdate($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         $name = $profileurl ? "<a href=\"{$profileurl}\">{$fullname}</a>" : $fullname;
-        $date = "<time>{$formatteddate}</time>";
+        $date = userdate_htmltime($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         return get_string('bynameondate', 'mod_forum', ['name' => $name, 'date' => $date]);
     }
 }
index ab333a9..d13042d 100644 (file)
@@ -723,7 +723,8 @@ function forum_update_calendar($forum, $cmid) {
 
     if (!empty($forum->duedate)) {
         $event->name = get_string('calendardue', 'forum', $forum->name);
-        $event->description = format_module_intro('forum', $forum, $cmid);
+        $event->description = format_module_intro('forum', $forum, $cmid, false);
+        $event->format = FORMAT_HTML;
         $event->courseid = $forum->course;
         $event->modulename = 'forum';
         $event->instance = $forum->id;
index 50cc04d..da67f20 100644 (file)
                                         <div class="author-info align-middle">
                                             <div class="mb-1 line-height-3 text-truncate">{{fullname}}</div>
                                             <div class="line-height-3">
-                                                {{#userdate}}{{discussion.times.created}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
+                                                {{< core/time_element }}
+                                                    {{$elementid}}created-{{discussion.id}}{{/elementid}}
+                                                    {{$timestampval}}{{discussion.times.created}}{{/timestampval}}
+                                                    {{$userdateformatval}}{{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdateformatval}}
+                                                {{/core/time_element}}
                                             </div>
                                         </div>
                                     </div>
                                             <div class="line-height-3">
                                                 {{#latestpostid}}
                                                     <a href="{{{discussion.urls.viewlatest}}}" title="{{#userdate}}{{discussion.times.modified}},{{#str}}strftimerecentfull{{/str}}{{/userdate}}">
-                                                        {{#userdate}}{{discussion.times.modified}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
+                                                        {{< core/time_element }}
+                                                            {{$elementid}}modified-{{discussion.id}}{{/elementid}}
+                                                            {{$timestampval}}{{discussion.times.modified}}{{/timestampval}}
+                                                            {{$userdateformatval}}{{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdateformatval}}
+                                                        {{/ core/time_element }}
                                                     </a>
                                                 {{/latestpostid}}
                                             </div>
index 32b44d0..f138cd2 100644 (file)
@@ -77,7 +77,7 @@
             <header id="post-header-{{id}}-{{uniqid}}">
                 {{^isdeleted}}
                     <div class="d-flex flex-wrap align-items-center mb-1">
-                        <address class="mb-0 mr-2" tabindex="-1">
+                        <div class="mr-2" tabindex="-1">
                             {{#author}}
                                 <h4 class="h6 d-lg-inline-block mb-0 author-header mr-1">
                                     {{#parentauthorname}}
                                     {{/parentauthorname}}
                                 </h4>
                             {{/author}}
-                            <time class="text-muted">
-                                {{#userdate}} {{timecreated}}, {{#str}} strftimerecentfull, core_langconfig {{/str}} {{/userdate}}
-                            </time>
-                        </address>
+                            {{< core/time_element }}
+                                {{$elementid}}created-{{id}}-{{uniqid}}{{/elementid}}
+                                {{$elementclass}}text-muted{{/elementclass}}
+                                {{$timestampval}}{{timecreated}}{{/timestampval}}
+                                {{$userdateformatval}}{{#str}} strftimerecentfull, core_langconfig {{/str}}{{/userdateformatval}}
+                            {{/core/time_element}}
+                        </div>
 
                         <div class="d-flex align-items-center ml-auto">
                             {{#author.groups}}
index dad785c..868c1e8 100644 (file)
                             }}>{{$subject}}{{{subject}}}{{/subject}}</h3>
                     {{/subjectheading}}
                     {{^isdeleted}}
-                        <address tabindex="-1">
+                        <div class="mb-3" tabindex="-1">
                             {{#html.authorsubheading}}{{{.}}}{{/html.authorsubheading}}
                             {{^html.authorsubheading}}
-                                <time>
-                                    {{#userdate}} {{timecreated}}, {{#str}} strftimedaydatetime, core_langconfig {{/str}} {{/userdate}}
-                                </time>
+                                {{< core/time_element }}
+                                    {{$elementid}}created-{{id}}-{{uniqid}}{{/elementid}}
+                                    {{$timestampval}}{{timecreated}}{{/timestampval}}
+                                    {{$userdateformatval}}{{#str}} strftimedaydatetime, core_langconfig {{/str}}{{/userdateformatval}}
+                                {{/core/time_element}}
                             {{/html.authorsubheading}}
-                        </address>
+                        </div>
                     {{/isdeleted}}
                     {{#isprivatereply}}
                         <div class="privatereplyinfo">
index fd4c034..a4819dd 100644 (file)
@@ -38,9 +38,7 @@
 >
     <a href="{{{urls.viewisolated}}}">{{subject}}</a>
     {{^isdeleted}}
-        <address class="d-inline-block mb-0">
-            {{{html.authorsubheading}}}
-        </address>
+        {{{html.authorsubheading}}}
     {{/isdeleted}}
 
     <div data-region="replies-container">
index 303c195..bb071a0 100644 (file)
@@ -371,9 +371,8 @@ class mod_forum_generator extends testing_module_generator {
     public function get_author_subheading_html(stdClass $exportedauthor, int $timecreated) : string {
         $fullname = $exportedauthor->fullname;
         $profileurl = $exportedauthor->urls['profile'] ?? null;
-        $formatteddate = userdate($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         $name = $profileurl ? "<a href=\"{$profileurl}\">{$fullname}</a>" : $fullname;
-        $date = "<time>{$formatteddate}</time>";
+        $date = userdate_htmltime($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         return get_string('bynameondate', 'mod_forum', ['name' => $name, 'date' => $date]);
     }
 }
index 5c9f43e..9514e74 100644 (file)
@@ -108,7 +108,7 @@ $PAGE->set_heading($course->fullname);
 $PAGE->set_button(forum_search_form($course, $search));
 
 if ($istypesingle && $displaymode == FORUM_MODE_NESTED_V2) {
-    $PAGE->add_body_class('reset-style');
+    $PAGE->add_body_class('nested-v2-display-mode reset-style');
     $settingstrigger = $OUTPUT->render_from_template('mod_forum/settings_drawer_trigger', null);
     $PAGE->add_header_action($settingstrigger);
 }
index 1758dc3..45444fc 100644 (file)
@@ -162,7 +162,8 @@ function lesson_update_events($lesson, $override = null) {
 
         $event = new stdClass();
         $event->type = !$deadline ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
-        $event->description = format_module_intro('lesson', $lesson, $cmid);
+        $event->description = format_module_intro('lesson', $lesson, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $lesson->course;
         $event->groupid     = $groupid;
index 20fe537..7330c3e 100644 (file)
@@ -1260,7 +1260,8 @@ function quiz_update_events($quiz, $override = null) {
 
         $event = new stdClass();
         $event->type = !$timeclose ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
-        $event->description = format_module_intro('quiz', $quiz, $cmid);
+        $event->description = format_module_intro('quiz', $quiz, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $quiz->course;
         $event->groupid     = $groupid;
index f2dd536..6647a2b 100644 (file)
@@ -2424,7 +2424,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         if ((!empty($scorm->timeopen)) && ($scorm->timeopen > 0)) {
             // Calendar event exists so update it.
             $event->name = get_string('calendarstart', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->timestart = $scorm->timeopen;
             $event->timesort = $scorm->timeopen;
             $event->visible = instance_is_visible('scorm', $scorm);
@@ -2441,7 +2442,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         // Event doesn't exist so create one.
         if ((!empty($scorm->timeopen)) && ($scorm->timeopen > 0)) {
             $event->name = get_string('calendarstart', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->courseid = $scorm->course;
             $event->groupid = 0;
             $event->userid = 0;
@@ -2465,7 +2467,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         if ((!empty($scorm->timeclose)) && ($scorm->timeclose > 0)) {
             // Calendar event exists so update it.
             $event->name = get_string('calendarend', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->timestart = $scorm->timeclose;
             $event->timesort = $scorm->timeclose;
             $event->visible = instance_is_visible('scorm', $scorm);
@@ -2482,7 +2485,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         // Event doesn't exist so create one.
         if ((!empty($scorm->timeclose)) && ($scorm->timeclose > 0)) {
             $event->name = get_string('calendarend', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->courseid = $scorm->course;
             $event->groupid = 0;
             $event->userid = 0;
index 3c25f25..998bce0 100644 (file)
@@ -5,6 +5,27 @@ information provided here is intended especially for developers.
 
 * The callback get_shortcuts() is now deprecated. Please use get_course_content_items and get_all_content_items instead.
   See source code examples in get_course_content_items() and get_all_content_items() in mod/lti/lib.php for details.
+* When creating the calendar events and setting the event description to match the module intro description, the filters
+  must not be applied on the passed description text. Doing so leads to loosing some expected text filters features and
+  causes unnecessarily early theme and output initialisation in unit tests. If your activity creates calendar events,
+  you probably have code like:
+    ```
+    $event->description = format_module_intro('quiz', $quiz, $cmid);
+    ```
+  You need to change it to:
+    ```
+    $event->description = format_module_intro('quiz', $quiz, $cmid, false);
+    $event->format = FORMAT_HTML;
+    ```
+  Even this is still technically wrong. Content should normally only be formatted just before it is output. Ideally, we
+  should pass the raw description text, format and have a way to copy the embedded files; or provide another way for the
+  calendar to call the right format_text() later. The calendar API does not allow us to do these things easily at the
+  moment. Therefore, this compromise approach is used. The false parameter added ensures that text filters are not run
+  at this time which is important. And the format must be set to HTML, because otherwise it would use the current user's
+  preferred editor default format.
+* Related to the above and to help with detecting the problematic places in contributed 3rd party modules, the
+  testing_module_generator::create_instance() now throws coding_exception if creating a module instance initialised the
+  theme and output as a side effect.
 
 === 3.8 ===
 
index c38199a..951b631 100644 (file)
@@ -1668,6 +1668,7 @@ function workshop_calendar_update(stdClass $workshop, $cmid) {
     // the common properties for all events
     $base = new stdClass();
     $base->description  = format_module_intro('workshop', $workshop, $cmid, false);
+    $base->format       = FORMAT_HTML;
     $base->courseid     = $workshop->course;
     $base->groupid      = 0;
     $base->userid       = 0;
index 3d87f77..e245bf6 100644 (file)
@@ -1,2 +1,2 @@
 <?xml version="1.0" encoding="utf-8"?>
-<svg width="16" height="16" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1615 0q70 0 122.5 46.5t52.5 116.5q0 63-45 151-332 629-465 752-97 91-218 91-126 0-216.5-92.5t-90.5-219.5q0-128 92-212l638-579q59-54 130-54zm-909 1034q39 76 106.5 130t150.5 76l1 71q4 213-129.5 347t-348.5 134q-123 0-218-46.5t-152.5-127.5-86.5-183-29-220q7 5 41 30t62 44.5 59 36.5 46 17q41 0 55-37 25-66 57.5-112.5t69.5-76 88-47.5 103-25.5 125-10.5z" fill="#999"/></svg>
\ No newline at end of file
+<svg width="16" height="16" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMid meet"><path d="M1615 0q70 0 122.5 46.5t52.5 116.5q0 63-45 151-332 629-465 752-97 91-218 91-126 0-216.5-92.5t-90.5-219.5q0-128 92-212l638-579q59-54 130-54zm-909 1034q39 76 106.5 130t150.5 76l1 71q4 213-129.5 347t-348.5 134q-123 0-218-46.5t-152.5-127.5-86.5-183-29-220q7 5 41 30t62 44.5 59 36.5 46 17q41 0 55-37 25-66 57.5-112.5t69.5-76 88-47.5 103-25.5 125-10.5z" fill="#999"/></svg>
\ No newline at end of file
index 5e6e4b3..0d2408f 100644 (file)
@@ -236,7 +236,7 @@ $allow-reset-style: true !default;
                     vertical-align: top;
 
                     div[role="main"] {
-                        flex: 1;
+                        flex: 1 0 auto;
                     }
 
                     .activity-navigation {
index 48c6ea9..aa6c0db 100644 (file)
@@ -17737,7 +17737,7 @@ body.reset-style #page-content {
     padding-right: 1.25rem;
     vertical-align: top; }
     body.reset-style #page-content #region-main-box #region-main div[role="main"] {
-      flex: 1; }
+      flex: 1 0 auto; }
     body.reset-style #page-content #region-main-box #region-main .activity-navigation {
       overflow: hidden; }
     body.reset-style #page-content #region-main-box #region-main.has-blocks {
index 6dee76b..c1058cd 100644 (file)
@@ -30,6 +30,7 @@ use context;
 use core_table\dynamic as dynamic_table;
 use core_table\local\filter\filterset;
 use core_user\output\status_field;
+use core_user\table\participants_search;
 use moodle_url;
 
 defined('MOODLE_INTERNAL') || die;
@@ -53,36 +54,6 @@ class participants extends \table_sql implements dynamic_table {
      */
     protected $courseid;
 
-    /**
-     * @var int|false False if groups not used, int if groups used, 0 for all groups.
-     */
-    protected $currentgroup;
-
-    /**
-     * @var int $accesssince The time the user last accessed the site
-     */
-    protected $accesssince;
-
-    /**
-     * @var int $roleid The role we are including, 0 means all enrolled users
-     */
-    protected $roleid;
-
-    /**
-     * @var int $enrolid The applied filter for the user enrolment ID.
-     */
-    protected $enrolid;
-
-    /**
-     * @var int $status The applied filter for the user's enrolment status.
-     */
-    protected $status;
-
-    /**
-     * @var string $search The string being searched.
-     */
-    protected $search;
-
     /**
      * @var bool $selectall Has the user selected all users on the page?
      */
@@ -133,6 +104,11 @@ class participants extends \table_sql implements dynamic_table {
      */
     protected $profileroles;
 
+    /**
+     * @var filterset Filterset describing which participants to include.
+     */
+    protected $filterset;
+
     /** @var \stdClass[] $viewableroles */
     private $viewableroles;
 
@@ -431,9 +407,9 @@ class participants extends \table_sql implements dynamic_table {
      */
     public function query_db($pagesize, $useinitialsbar = true) {
         list($twhere, $tparams) = $this->get_sql_where();
+        $psearch = new participants_search($this->course, $this->context, $this->filterset);
 
-        $total = user_get_total_participants($this->course->id, $this->currentgroup, $this->accesssince,
-            $this->roleid, $this->enrolid, $this->status, $this->search, $twhere, $tparams);
+        $total = $psearch->get_total_participants_count($twhere, $tparams);
 
         $this->pagesize($pagesize, $total);
 
@@ -442,9 +418,8 @@ class participants extends \table_sql implements dynamic_table {
             $sort = 'ORDER BY ' . $sort;
         }
 
-        $rawdata = user_get_participants($this->course->id, $this->currentgroup, $this->accesssince,
-            $this->roleid, $this->enrolid, $this->status, $this->search, $twhere, $tparams, $sort, $this->get_page_start(),
-            $this->get_page_size());
+        $rawdata = $psearch->get_participants($twhere, $tparams, $sort, $this->get_page_start(), $this->get_page_size());
+
         $this->rawdata = [];
         foreach ($rawdata as $user) {
             $this->rawdata[$user->id] = $user;
@@ -501,36 +476,6 @@ class participants extends \table_sql implements dynamic_table {
         $this->context = \context_course::instance($this->courseid, MUST_EXIST);
 
         // Process the filterset.
-        $this->currentgroup = null;
-        if ($filterset->has_filter('groups')) {
-            $this->currentgroup = $filterset->get_filter('groups')->current();
-        }
-
-        $this->roleid = null;
-        if ($filterset->has_filter('roles')) {
-            $this->roleid = $filterset->get_filter('roles')->current();
-        }
-
-        $this->enrolid = null;
-        if ($filterset->has_filter('enrolments')) {
-            $this->enrolid = $filterset->get_filter('enrolments')->current();
-        }
-
-        $this->status = -1;
-        if ($filterset->has_filter('status')) {
-            $this->status = $filterset->get_filter('status')->current();
-        }
-
-        $this->accesssince = null;
-        if ($filterset->has_filter('accesssince')) {
-            $this->accesssince = $filterset->get_filter('accesssince')->current();
-        }
-
-        $this->search = null;
-        if ($filterset->has_filter('keywords')) {
-            $this->search = $filterset->get_filter('keywords')->get_filter_values();
-        }
-
         parent::set_filterset($filterset);
     }
 
diff --git a/user/classes/table/participants_search.php b/user/classes/table/participants_search.php
new file mode 100644 (file)
index 0000000..1ede7f6
--- /dev/null
@@ -0,0 +1,595 @@
+<?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/>.
+
+/**
+ * Class used to fetch participants based on a filterset.
+ *
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_user\table;
+
+use context;
+use context_helper;
+use core_table\local\filter\filterset;
+use core_user;
+use moodle_recordset;
+use stdClass;
+use user_picture;
+
+/**
+ * Class used to fetch participants based on a filterset.
+ *
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class participants_search {
+
+    /**
+     * @var filterset $filterset The filterset describing which participants to include in the search.
+     */
+    protected $filterset;
+
+    /**
+     * @var stdClass $course The course being searched.
+     */
+    protected $course;
+
+    /**
+     * @var context_course $context The course context being searched.
+     */
+    protected $context;
+
+    /**
+     * @var string[] $userfields Names of any extra user fields to be shown when listing users.
+     */
+    protected $userfields;
+
+    /**
+     * Class constructor.
+     *
+     * @param stdClass $course The course being searched.
+     * @param context $context The context of the search.
+     * @param filterset $filterset The filterset used to filter the participants in a course.
+     */
+    public function __construct(stdClass $course, context $context, filterset $filterset) {
+        $this->course = $course;
+        $this->context = $context;
+        $this->filterset = $filterset;
+
+        $this->userfields = get_extra_user_fields($this->context);
+    }
+
+    /**
+     * Fetch participants matching the filterset.
+     *
+     * @param string $additionalwhere Any additional SQL to add to where.
+     * @param array $additionalparams The additional params used by $additionalwhere.
+     * @param string $sort Optional SQL sort.
+     * @param int $limitfrom Return a subset of records, starting at this point (optional).
+     * @param int $limitnum Return a subset comprising this many records (optional, required if $limitfrom is set).
+     * @return moodle_recordset
+     */
+    public function get_participants(string $additionalwhere = '', array $additionalparams = [], string $sort = '',
+            int $limitfrom = 0, int $limitnum = 0): moodle_recordset {
+        global $DB;
+
+        [
+            'select' => $select,
+            'from' => $from,
+            'where' => $where,
+            'params' => $params,
+        ] = $this->get_participants_sql($additionalwhere, $additionalparams);
+
+        return $DB->get_recordset_sql("{$select} {$from} {$where} {$sort}", $params, $limitfrom, $limitnum);
+    }
+
+    /**
+     * Returns the total number of participants for a given course.
+     *
+     * @param string $additionalwhere Any additional SQL to add to where.
+     * @param array $additionalparams The additional params used by $additionalwhere.
+     * @return int
+     */
+    public function get_total_participants_count(string $additionalwhere = '', array $additionalparams = []): int {
+        global $DB;
+
+        [
+            'from' => $from,
+            'where' => $where,
+            'params' => $params,
+        ] = $this->get_participants_sql($additionalwhere, $additionalparams);
+
+        return $DB->count_records_sql("SELECT COUNT(u.id) {$from} {$where}", $params);
+    }
+
+    /**
+     * Generate the SQL used to fetch filtered data for the participants table.
+     *
+     * @param string $additionalwhere Any additional SQL to add to where
+     * @param array $additionalparams The additional params
+     * @return array
+     */
+    protected function get_participants_sql(string $additionalwhere, array $additionalparams): array {
+        $isfrontpage = ($this->course->id == SITEID);
+        $accesssince = $this->filterset->has_filter('accesssince') ? $this->filterset->get_filter('accesssince')->current() : 0;
+
+        [
+            'sql' => $esql,
+            'params' => $params,
+        ] = $this->get_enrolled_sql();
+
+        $joins = ['FROM {user} u'];
+        $wheres = [];
+
+        $userfieldssql = user_picture::fields('u', $this->userfields);
+
+        if ($isfrontpage) {
+            $select = "SELECT $userfieldssql, u.lastaccess";
+            $joins[] = "JOIN ($esql) e ON e.id = u.id"; // Everybody on the frontpage usually.
+            if ($accesssince) {
+                $wheres[] = user_get_user_lastaccess_sql($accesssince);
+            }
+        } else {
+            $select = "SELECT $userfieldssql, COALESCE(ul.timeaccess, 0) AS lastaccess";
+            $joins[] = "JOIN ($esql) e ON e.id = u.id"; // Course enrolled users only.
+            // Not everybody has accessed the course yet.
+            $joins[] = 'LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid)';
+            $params['courseid'] = $this->course->id;
+            if ($accesssince) {
+                $wheres[] = user_get_course_lastaccess_sql($accesssince);
+            }
+        }
+
+        // Performance hacks - we preload user contexts together with accounts.
+        $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
+        $ccjoin = 'LEFT JOIN {context} ctx ON (ctx.instanceid = u.id AND ctx.contextlevel = :contextlevel)';
+        $params['contextlevel'] = CONTEXT_USER;
+        $select .= $ccselect;
+        $joins[] = $ccjoin;
+
+        // Apply any role filtering.
+        if ($this->filterset->has_filter('roles')) {
+            [
+                'where' => $roleswhere,
+                'params' => $rolesparams,
+            ] = $this->get_roles_sql();
+
+            if (!empty($roleswhere)) {
+                $wheres[] = "({$roleswhere})";
+            }
+
+            if (!empty($rolesparams)) {
+                $params = array_merge($params, $rolesparams);
+            }
+        }
+
+        // Apply any keyword text searches.
+        if ($this->filterset->has_filter('keywords')) {
+            [
+                'wheres' => $keywordswheres,
+                'params' => $keywordsparams,
+            ] = $this->get_keywords_search_sql();
+
+            if (!empty($keywordswheres)) {
+                $wheres = array_merge($wheres, $keywordswheres);
+            }
+
+            if (!empty($keywordsparams)) {
+                $params = array_merge($params, $keywordsparams);
+            }
+        }
+
+        // Add any supplied additional WHERE clauses.
+        if (!empty($additionalwhere)) {
+            $wheres[] = $additionalwhere;
+            $params = array_merge($params, $additionalparams);
+        }
+
+        // Prepare final values.
+        $from = implode("\n", $joins);
+        if ($wheres) {
+            $where = 'WHERE ' . implode(' AND ', $wheres);
+        } else {
+            $where = '';
+        }
+
+        return [
+            'select' => $select,
+            'from' => $from,
+            'where' => $where,
+            'params' => $params,
+        ];
+    }
+
+    /**
+     * Prepare SQL and associated parameters for users enrolled in the course.
+     *
+     * @return array SQL query data in the format ['sql' => '', 'params' => []].
+     */
+    protected function get_enrolled_sql(): array {
+        // Default status filter settings.
+        // We only show active by default, especially if the user has no capability to review enrolments.
+        $onlyactive = true;
+        $onlysuspended = false;
+
+        $enrolids = [];
+        $groupids = [];
+
+        if ($this->filterset->has_filter('enrolments')) {
+            $enrolids = $this->filterset->get_filter('enrolments')->get_filter_values();
+        }
+
+        if ($this->filterset->has_filter('groups')) {
+            $groupids = $this->filterset->get_filter('groups')->get_filter_values();
+        }
+
+        $prefix = 'eu_';
+        $uid = "{$prefix}u.id";
+        $joins = [];
+        $wheres = [];
+
+        // Set enrolment types.
+        if (has_capability('moodle/course:enrolreview', $this->context) &&
+                (has_capability('moodle/course:viewsuspendedusers', $this->context))) {
+            $statusids = [-1];
+
+            if ($this->filterset->has_filter('status')) {
+                $statusids = $this->filterset->get_filter('status')->get_filter_values();
+            }
+
+            // If both status IDs are selected, treat it as not filtering by status.
+            // Note: This is a temporary measure that supports the existing logic.
+            // It will be updated when support is added for all logical operators (all/none).
+            if (count($statusids) !== 1) {
+                $statusid = -1;
+            } else {
+                $statusid = $statusids[0];
+            }
+
+            switch ($statusid) {
+                case ENROL_USER_ACTIVE:
+                    // Nothing to do here.
+                    break;
+                case ENROL_USER_SUSPENDED:
+                    $onlyactive = false;
+                    $onlysuspended = true;
+                    break;
+                default:
+                    // If the user has capability to review user enrolments, but statusid is set to -1, set $onlyactive to false.
+                    $onlyactive = false;
+                    break;
+            }
+        }
+
+        // Prepare enrolment type filtering.
+        // This will need to use a custom method or new function when 'All'/'Not' cases are introduced,
+        // to avoid the separate passing in of status values ($onlyactive and $onlysuspended).
+        $enrolledjoin = $this->get_enrolled_join($this->context, $uid, $onlyactive, $onlysuspended, $enrolids);
+        $joins[] = $enrolledjoin->joins;
+        $wheres[] = $enrolledjoin->wheres;
+        $params = $enrolledjoin->params;
+
+        // Prepare any groups filtering.
+        if ($groupids) {
+            $groupjoin = groups_get_members_join($groupids, $uid, $this->context);
+            $joins[] = $groupjoin->joins;
+            $params = array_merge($params, $groupjoin->params);
+            if (!empty($groupjoin->wheres)) {
+                $wheres[] = $groupjoin->wheres;
+            }
+        }
+
+        $joinsql = implode("\n", $joins);
+        $wheres[] = "{$prefix}u.deleted = 0";
+        $wheresql = implode(" AND ", $wheres);
+
+        $sql = "SELECT DISTINCT {$prefix}u.id
+                  FROM {user} {$prefix}u
+                       {$joinsql}
+                 WHERE {$wheresql}";
+        return [
+            'sql' => $sql,
+            'params' => $params,
+        ];
+    }
+
+    /**
+     * Returns array with SQL joins and parameters returning all IDs of users enrolled into course.
+     *
+     * Note: This is a temporary method (based on get_enrolled_join from enrollib), supporting multiple enrolment IDs
+     * matched using logical OR. A more complete implementation of other logical operators and supporting more
+     * flexible enrolment statuses will be implemented in MDL-68348.
+     *
+     * This method is using 'ej[0-9]+_' prefix for table names and parameters.
+     *
+     * @throws coding_exception
+     *
+     * @param \context $context
+     * @param string $useridcolumn User id column used the calling query, e.g. u.id
+     * @param bool $onlyactive consider only active enrolments in enabled plugins and time restrictions
+     * @param bool $onlysuspended inverse of onlyactive, consider only suspended enrolments
+     * @param array $enrolids The enrolment IDs. If not [], only users enrolled using these enrolment methods will be returned.
+     * @return \core\dml\sql_join Contains joins, wheres, params
+     */
+    protected function get_enrolled_join(\context $context, $useridcolumn, $onlyactive = false, $onlysuspended = false,
+            $enrolids = []) {
+
+        global $DB;
+
+        // Use unique prefix just in case somebody makes some SQL magic with the result.
+        static $i = 0;
+        $i++;
+        $prefix = 'ej' . $i . '_';
+
+        if (!is_array($enrolids)) {
+            $enrolids = $enrolids ? [$enrolids] : [];
+        }
+
+        // First find the course context.
+        $coursecontext = $context->get_course_context();
+
+        $isfrontpage = ($coursecontext->instanceid == SITEID);
+
+        if ($onlyactive && $onlysuspended) {
+            throw new \coding_exception("Both onlyactive and onlysuspended are set, this is probably not what you want!");
+        }
+        if ($isfrontpage && $onlysuspended) {
+            throw new \coding_exception("onlysuspended is not supported on frontpage; please add your own early-exit!");
+        }
+
+        $joins  = [];
+        $wheres = [];
+        $params = [];
+
+        $wheres[] = "1 = 1"; // Prevent broken where clauses later on.
+
+        // Note all users are "enrolled" on the frontpage, but for others...
+        if (!$isfrontpage) {
+            $where1 = "{$prefix}ue.status = :{$prefix}active AND {$prefix}e.status = :{$prefix}enabled";
+            $where2 = "{$prefix}ue.timestart < :{$prefix}now1 AND ({$prefix}ue.timeend = 0
+                       OR {$prefix}ue.timeend > :{$prefix}now2)";
+
+            $enrolconditions = [
+                "{$prefix}e.id = {$prefix}ue.enrolid",
+                "{$prefix}e.courseid = :{$prefix}courseid",
+            ];
+
+            // TODO: This only handles 'Any' (logical OR) of the provided enrol IDs. MDL-68348 will add 'All' and 'None' support.
+            if (!empty($enrolids)) {
+                list($enrolidssql, $enrolidsparams) = $DB->get_in_or_equal($enrolids, SQL_PARAMS_NAMED, $prefix);
+                $enrolconditions[] = "{$prefix}e.id {$enrolidssql}";
+                $params = array_merge($params, $enrolidsparams);
+            }
+
+            $enrolconditionssql = implode(" AND ", $enrolconditions);
+            $ejoin = "JOIN {enrol} {$prefix}e ON ($enrolconditionssql)";
+
+            $params[$prefix.'courseid'] = $coursecontext->instanceid;
+
+            if (!$onlysuspended) {
+                $joins[] = "JOIN {user_enrolments} {$prefix}ue ON {$prefix}ue.userid = $useridcolumn";
+                $joins[] = $ejoin;
+                if ($onlyactive) {
+                    $wheres[] = "$where1 AND $where2";
+                }
+            } else {
+                // Suspended only where there is enrolment but ALL are suspended.
+                // Consider multiple enrols where one is not suspended or plain role_assign.
+                $enrolselect = "SELECT DISTINCT {$prefix}ue.userid
+                                           FROM {user_enrolments} {$prefix}ue $ejoin
+                                          WHERE $where1 AND $where2";
+                $joins[] = "JOIN {user_enrolments} {$prefix}ue1 ON {$prefix}ue1.userid = $useridcolumn";
+                $enrolconditions = [
+                    "{$prefix}e1.id = {$prefix}ue1.enrolid",
+                    "{$prefix}e1.courseid = :{$prefix}_e1_courseid",
+                ];
+
+                if (!empty($enrolids)) {
+                    list($enrolidssql, $enrolidsparams) = $DB->get_in_or_equal($enrolids, SQL_PARAMS_NAMED, $prefix);
+                    $enrolconditions[] = "{$prefix}e1.id {$enrolidssql}";
+                    $params = array_merge($params, $enrolidsparams);
+                }
+
+                $enrolconditionssql = implode(" AND ", $enrolconditions);
+                $joins[] = "JOIN {enrol} {$prefix}e1 ON ($enrolconditionssql)";
+                $params["{$prefix}_e1_courseid"] = $coursecontext->instanceid;
+                $wheres[] = "$useridcolumn NOT IN ($enrolselect)";
+            }
+
+            if ($onlyactive || $onlysuspended) {
+                $now = round(time(), -2); // Rounding helps caching in DB.
+                $params = array_merge($params, [
+                        $prefix . 'enabled' => ENROL_INSTANCE_ENABLED,
+                        $prefix . 'active' => ENROL_USER_ACTIVE,
+                        $prefix . 'now1' => $now,
+                        $prefix . 'now2' => $now]);
+            }
+        }
+
+        $joins = implode("\n", $joins);
+        $wheres = implode(" AND ", $wheres);
+
+        return new \core\dml\sql_join($joins, $wheres, $params);
+    }
+
+    /**
+     * Prepare SQL where clause and associated parameters for any roles filtering being performed.
+     *
+     * @return array SQL query data in the format ['where' => '', 'params' => []].
+     */
+    protected function get_roles_sql(): array {
+        global $DB;
+
+        $where = '';
+        $params = [];
+
+        // Limit list to users with some role only.
+        if ($this->filterset->has_filter('roles')) {
+            $roleids = $this->filterset->get_filter('roles')->get_filter_values();
+
+            // We want to query both the current context and parent contexts.
+            $rolecontextids = $this->context->get_parent_context_ids(true);
+
+            // Get users without any role, if needed.
+            if (($withoutkey = array_search(-1, $roleids)) !== false) {
+                list($relatedctxsql1, $relatedctxparams1) = $DB->get_in_or_equal($rolecontextids, SQL_PARAMS_NAMED, 'relatedctx1');
+
+                $where .= "(u.id NOT IN (SELECT userid FROM {role_assignments} WHERE contextid {$relatedctxsql1}))";
+                $params = array_merge($params, $relatedctxparams1);
+                unset($roleids[$withoutkey]);
+
+                if (!empty($roleids)) {
+                    // Currently only handle 'Any' (logical OR) case within filters.
+                    // This will need to be extended to support 'All'/'None'.
+                    $where .= ' OR ';
+                }
+            }
+
+            // Get users with specified roles, if needed.
+            if (!empty($roleids)) {
+                list($relatedctxsql2, $relatedctxparams2) = $DB->get_in_or_equal($rolecontextids, SQL_PARAMS_NAMED, 'relatedctx2');
+                list($roleidssql, $roleidsparams) = $DB->get_in_or_equal($roleids, SQL_PARAMS_NAMED);
+
+                $where .= "(u.id IN (
+                                  SELECT userid
+                                    FROM {role_assignments}
+                                   WHERE roleid {$roleidssql}
+                                     AND contextid {$relatedctxsql2})
+                                )";
+                $params = array_merge($params, $roleidsparams, $relatedctxparams2);
+            }
+        }
+
+        return [
+            'where' => $where,
+            'params' => $params,
+        ];
+    }
+
+    /**
+     * Prepare SQL where clauses and associated parameters for any keyword searches being performed.
+     *
+     * @return array SQL query data in the format ['wheres' => [], 'params' => []].
+     */
+    protected function get_keywords_search_sql(): array {
+        global $CFG, $DB, $USER;
+
+        $keywords = [];
+        $wheres = [];
+        $params = [];
+
+        if ($this->filterset->has_filter('keywords')) {
+            $keywords = $this->filterset->get_filter('keywords')->get_filter_values();
+        }
+
+        foreach ($keywords as $index => $keyword) {
+            $searchkey1 = 'search' . $index . '1';
+            $searchkey2 = 'search' . $index . '2';
+            $searchkey3 = 'search' . $index . '3';
+            $searchkey4 = 'search' . $index . '4';
+            $searchkey5 = 'search' . $index . '5';
+            $searchkey6 = 'search' . $index . '6';
+            $searchkey7 = 'search' . $index . '7';
+
+            $conditions = [];
+            // Search by fullname.
+            $fullname = $DB->sql_fullname('u.firstname', 'u.lastname');
+            $conditions[] = $DB->sql_like($fullname, ':' . $searchkey1, false, false);
+
+            // Search by email.
+            $email = $DB->sql_like('email', ':' . $searchkey2, false, false);
+            if (!in_array('email', $this->userfields)) {
+                $maildisplay = 'maildisplay' . $index;
+                $userid1 = 'userid' . $index . '1';
+                // Prevent users who hide their email address from being found by others
+                // who aren't allowed to see hidden email addresses.
+                $email = "(". $email ." AND (" .
+                        "u.maildisplay <> :$maildisplay " .
+                        "OR u.id = :$userid1". // User can always find himself.
+                        "))";
+                $params[$maildisplay] = core_user::MAILDISPLAY_HIDE;
+                $params[$userid1] = $USER->id;
+            }
+            $conditions[] = $email;
+
+            // Search by idnumber.
+            $idnumber = $DB->sql_like('idnumber', ':' . $searchkey3, false, false);
+            if (!in_array('idnumber', $this->userfields)) {
+                $userid2 = 'userid' . $index . '2';
+                // Users who aren't allowed to see idnumbers should at most find themselves
+                // when searching for an idnumber.
+                $idnumber = "(". $idnumber . " AND u.id = :$userid2)";
+                $params[$userid2] = $USER->id;
+            }
+            $conditions[] = $idnumber;
+
+            if (!empty($CFG->showuseridentity)) {
+                // Search all user identify fields.
+                $extrasearchfields = explode(',', $CFG->showuseridentity);
+                foreach ($extrasearchfields as $extrasearchfield) {
+                    if (in_array($extrasearchfield, ['email', 'idnumber', 'country'])) {
+                        // Already covered above. Search by country not supported.
+                        continue;
+                    }
+                    $param = $searchkey3 . $extrasearchfield;
+                    $condition = $DB->sql_like($extrasearchfield, ':' . $param, false, false);
+                    $params[$param] = "%$keyword%";
+                    if (!in_array($extrasearchfield, $this->userfields)) {
+                        // User cannot see this field, but allow match if their own account.
+                        $userid3 = 'userid' . $index . '3' . $extrasearchfield;
+                        $condition = "(". $condition . " AND u.id = :$userid3)";
+                        $params[$userid3] = $USER->id;
+                    }
+                    $conditions[] = $condition;
+                }
+            }
+
+            // Search by middlename.
+            $middlename = $DB->sql_like('middlename', ':' . $searchkey4, false, false);
+            $conditions[] = $middlename;
+
+            // Search by alternatename.
+            $alternatename = $DB->sql_like('alternatename', ':' . $searchkey5, false, false);
+            $conditions[] = $alternatename;
+
+            // Search by firstnamephonetic.
+            $firstnamephonetic = $DB->sql_like('firstnamephonetic', ':' . $searchkey6, false, false);
+            $conditions[] = $firstnamephonetic;
+
+            // Search by lastnamephonetic.
+            $lastnamephonetic = $DB->sql_like('lastnamephonetic', ':' . $searchkey7, false, false);
+            $conditions[] = $lastnamephonetic;
+
+            $wheres[] = "(". implode(" OR ", $conditions) .") ";
+            $params[$searchkey1] = "%$keyword%";
+            $params[$searchkey2] = "%$keyword%";
+            $params[$searchkey3] = "%$keyword%";
+            $params[$searchkey4] = "%$keyword%";
+            $params[$searchkey5] = "%$keyword%";
+            $params[$searchkey6] = "%$keyword%";
+            $params[$searchkey7] = "%$keyword%";
+        }
+
+        return [
+            'wheres' => $wheres,
+            'params' => $params,
+        ];
+    }
+}
diff --git a/user/tests/table/participants_search_test.php b/user/tests/table/participants_search_test.php
new file mode 100644 (file)
index 0000000..0290830
--- /dev/null
@@ -0,0 +1,1134 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Provides {@link core_user_table_participants_search_test} class.
+ *
+ * @package   core_user
+ * @category  test
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types=1);
+
+namespace core_user\table;
+
+use advanced_testcase;
+use context_course;
+use context_coursecat;
+use core_table\local\filter\filter;
+use core_table\local\filter\integer_filter;
+use core_table\local\filter\string_filter;
+use core_user\table\participants_filterset;
+use core_user\table\participants_search;
+use moodle_recordset;
+use stdClass;
+
+/**
+ * Tests for the implementation of {@link core_user_table_participants_search} class.
+ *
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class participants_search_test extends advanced_testcase {
+
+    /**
+     * Helper to convert a moodle_recordset to an array of records.
+     *
+     * @param moodle_recordset $recordset
+     * @return array
+     */
+    protected function convert_recordset_to_array(moodle_recordset $recordset): array {
+        $records = [];
+        foreach ($recordset as $record) {
+            $records[$record->id] = $record;
+        }
+        $recordset->close();
+
+        return $records;
+    }
+
+    /**
+     * Create and enrol a set of users into the specified course.
+     *
+     * @param stdClass $course
+     * @param int $count
+     * @param null|string $role
+     * @return array
+     */
+    protected function create_and_enrol_users(stdClass $course, int $count, ?string $role = null): array {
+        $this->resetAfterTest(true);
+        $users = [];
+
+        for ($i = 0; $i < $count; $i++) {
+            $user = $this->getDataGenerator()->create_user();
+            $this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
+            $users[] = $user;
+        }
+
+        return $users;
+    }
+
+    /**
+     * Create a new course with several types of user.
+     *
+     * @param int $editingteachers The number of editing teachers to create in the course.
+     * @param int $teachers The number of non-editing teachers to create in the course.
+     * @param int $students The number of students to create in the course.
+     * @param int $norole The number of users with no role to create in the course.
+     * @return stdClass
+     */
+    protected function create_course_with_users(int $editingteachers, int $teachers, int $students, int $norole): stdClass {
+        $data = (object) [
+            'course' => $this->getDataGenerator()->create_course(),
+            'editingteachers' => [],
+            'teachers' => [],
+            'students' => [],
+            'norole' => [],
+        ];
+
+        $data->context = context_course::instance($data->course->id);
+
+        $data->editingteachers = $this->create_and_enrol_users($data->course, $editingteachers, 'editingteacher');
+        $data->teachers = $this->create_and_enrol_users($data->course, $teachers, 'teacher');
+        $data->students = $this->create_and_enrol_users($data->course, $students, 'student');
+        $data->norole = $this->create_and_enrol_users($data->course, $norole);
+
+        return $data;
+    }
+    /**
+     * Ensure that the roles filter works as expected with the provided test cases.
+     *
+     * @param array $usersdata The list of users and their roles to create
+     * @param array $testroles The list of roles to filter by
+     * @param int $jointype The join type to use when combining filter values
+     * @param int $count The expected count
+     * @param array $expectedusers
+     * @dataProvider role_provider
+     */
+    public function test_roles_filter(array $usersdata, array $testroles, int $jointype, int $count, array $expectedusers): void {
+        global $DB;
+
+        $roles = $DB->get_records_menu('role', [], '', 'shortname, id');
+
+        // Remove the default role.
+        set_config('roleid', 0, 'enrol_manual');
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $category = $DB->get_record('course_categories', ['id' => $course->category]);
+        $categorycontext = context_coursecat::instance($category->id);
+
+        $users = [];
+
+        foreach ($usersdata as $username => $userdata) {
+            $user = $this->getDataGenerator()->create_user(['username' => $username]);
+
+            if (array_key_exists('courseroles', $userdata)) {
+                $this->getDataGenerator()->enrol_user($user->id, $course->id, null);
+                foreach ($userdata['courseroles'] as $rolename) {
+                    $this->getDataGenerator()->role_assign($roles[$rolename], $user->id, $coursecontext->id);
+                }
+            }
+
+            if (array_key_exists('categoryroles', $userdata)) {
+                foreach ($userdata['categoryroles'] as $rolename) {
+                    $this->getDataGenerator()->role_assign($roles[$rolename], $user->id, $categorycontext->id);
+                }
+            }
+            $users[$username] = $user;
+        }
+
+        // Create a secondary course with users. We should not see these users.
+        $this->create_course_with_users(1, 1, 1, 1);
+
+        // Create the basic filter.
+        $filterset = new participants_filterset();
+        $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id]));
+
+        // Create the role filter.
+        $rolefilter = new integer_filter('roles');
+        $filterset->add_filter($rolefilter);
+
+        // Configure the filter.
+        foreach ($testroles as $rolename) {
+            $rolefilter->add_filter_value((int) $roles[$rolename]);
+        }
+        $rolefilter->set_join_type($jointype);
+
+        // Run the search.
+        $search = new participants_search($course, $coursecontext, $filterset);
+        $rs = $search->get_participants();
+        $this->assertInstanceOf(moodle_recordset::class, $rs);
+        $records = $this->convert_recordset_to_array($rs);
+
+        $this->assertCount($count, $records);
+        $this->assertEquals($count, $search->get_total_participants_count());
+
+        foreach ($expectedusers as $expecteduser) {
+            $this->assertArrayHasKey($users[$expecteduser]->id, $records);
+        }
+    }
+
+    /**
+     * Data provider for role tests.
+     *
+     * @return array
+     */
+    public function role_provider(): array {
+        $tests = [
+            // Users who only have one role each.
+            'Users in each role' => (object) [
+                'users' => [
+                    'a' => [
+                        'courseroles' => [
+                            'student',
+                        ],
+                    ],
+                    'b' => [
+                        'courseroles' => [
+                            'student',
+                        ],
+                    ],
+                    'c' => [
+                        'courseroles' => [
+                            'editingteacher',
+                        ],
+                    ],
+                    'd' => [
+                        'courseroles' => [
+                            'editingteacher',
+                        ],
+                    ],
+                    'e' => [
+                        'courseroles' => [
+                            'teacher',
+                        ],
+                    ],
+                    'f' => [
+                        'courseroles' => [
+                            'teacher',
+                        ],
+                    ],
+                    // User is enrolled in the course without role.
+                    'g' => [
+                        'courseroles' => [
+                        ],
+                    ],
+
+                    // User is a category manager and also enrolled without role in the course.
+                    'h' => [
+                        'courseroles' => [
+                        ],
+                        'categoryroles' => [
+                            'manager',
+                        ],
+                    ],
+
+                    // User is a category manager and not enrolled in the course.
+                    // This user should not show up in any filter.
+                    'i' => [
+                        'categoryroles' => [
+                            'manager',
+                        ],
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No role filter' => (object) [
+                        'roles' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 8,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                            'e',
+                            'f',
+                            'g',
+                            'h',
+                        ],
+                    ],
+                    'ANY: Filter on student' => (object) [
+                        'roles' => ['student'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                    'ANY: Filter on student, teacher' => (object) [
+                        'roles' => ['student', 'teacher'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 4,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'e',
+                            'f',
+                        ],
+                    ],
+                    'ANY: Filter on student, manager (category level role)' => (object) [
+                        'roles' => ['student', 'manager'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'h',
+                        ],
+                    ],
+                    'ANY: Filter on student, coursecreator (not assigned)' => (object) [
+                        'roles' => ['student', 'coursecreator'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No role filter' => (object) [
+                        'roles' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 8,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                            'e',
+                            'f',
+                            'g',
+                            'h',
+                        ],
+                    ],
+                    'ALL: Filter on student' => (object) [
+                        'roles' => ['student'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                ],
+            ],
+            'Users with multiple roles' => (object) [
+                'users' => [
+                    'a' => [
+                        'courseroles' => [
+                            'student',
+                        ],
+                    ],
+                    'b' => [
+                        'courseroles' => [
+                            'student',
+                            'teacher',
+                        ],
+                    ],
+                    'c' => [
+                        'courseroles' => [
+                            'editingteacher',
+                        ],
+                    ],
+                    'd' => [
+                        'courseroles' => [
+                            'editingteacher',
+                        ],
+                    ],
+                    'e' => [
+                        'courseroles' => [
+                            'teacher',
+                            'editingteacher',
+                        ],
+                    ],
+                    'f' => [
+                        'courseroles' => [
+                            'teacher',
+                        ],
+                    ],
+
+                    // User is enrolled in the course without role.
+                    'g' => [
+                        'courseroles' => [
+                        ],
+                    ],
+
+                    // User is a category manager and also enrolled without role in the course.
+                    'h' => [
+                        'courseroles' => [
+                        ],
+                        'categoryroles' => [
+                            'manager',
+                        ],
+                    ],
+
+                    // User is a category manager and not enrolled in the course.
+                    // This user should not show up in any filter.
+                    'i' => [
+                        'categoryroles' => [
+                            'manager',
+                        ],
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No role filter' => (object) [
+                        'roles' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 8,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                            'e',
+                            'f',
+                            'g',
+                            'h',
+                        ],
+                    ],
+                    'ANY: Filter on student' => (object) [
+                        'roles' => ['student'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                    'ANY: Filter on teacher' => (object) [
+                        'roles' => ['teacher'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'b',
+                            'e',
+                            'f',
+                        ],
+                    ],
+                    'ANY: Filter on editingteacher' => (object) [
+                        'roles' => ['editingteacher'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'c',
+                            'd',
+                            'e',
+                        ],
+                    ],
+                    'ANY: Filter on student, teacher' => (object) [
+                        'roles' => ['student', 'teacher'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 4,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'e',
+                            'f',
+                        ],
+                    ],
+                    'ANY: Filter on teacher, editingteacher' => (object) [
+                        'roles' => ['teacher', 'editingteacher'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 5,
+                        'expectedusers' => [
+                            'b',
+                            'c',
+                            'd',
+                            'e',
+                            'f',
+                        ],
+                    ],
+                    'ANY: Filter on student, manager (category level role)' => (object) [
+                        'roles' => ['student', 'manager'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'h',
+                        ],
+                    ],
+                    'ANY: Filter on student, coursecreator (not assigned)' => (object) [
+                        'roles' => ['student', 'coursecreator'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No role filter' => (object) [
+                        'roles' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 8,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                            'e',
+                            'f',
+                            'g',
+                            'h',
+                        ],
+                    ],
+                    'ALL: Filter on student' => (object) [
+                        'roles' => ['student'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                    'ALL: Filter on teacher' => (object) [
+                        'roles' => ['teacher'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'b',
+                            'e',
+                            'f',
+                        ],
+                    ],
+                    'ALL: Filter on editingteacher' => (object) [
+                        'roles' => ['editingteacher'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'c',
+                            'd',
+                            'e',
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        $finaltests = [];
+        foreach ($tests as $testname => $testdata) {
+            foreach ($testdata->expect as $expectname => $expectdata) {
+                $finaltests["{$testname} => {$expectname}"] = [
+                    'users' => $testdata->users,
+                    'roles' => $expectdata->roles,
+                    'jointype' => $expectdata->jointype,
+                    'count' => $expectdata->count,
+                    'expectedusers' => $expectdata->expectedusers,
+                ];
+            }
+        }
+
+        return $finaltests;
+    }
+
+    /**
+     * Ensure that the keywords filter works as expected with the provided test cases.
+     *
+     * @param array $usersdata The list of users to create
+     * @param array $keywords The list of keywords to filter by
+     * @param int $jointype The join type to use when combining filter values
+     * @param int $count The expected count
+     * @param array $expectedusers
+     * @dataProvider keywords_provider
+     */
+    public function test_keywords_filter(array $usersdata, array $keywords, int $jointype, int $count, array $expectedusers): void {
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $users = [];
+
+        foreach ($usersdata as $username => $userdata) {
+            $user = $this->getDataGenerator()->create_user($userdata);
+            $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+            $users[$username] = $user;
+        }
+
+        // Create a secondary course with users. We should not see these users.
+        $this->create_course_with_users(10, 10, 10, 10);
+
+        // Create the basic filter.
+        $filterset = new participants_filterset();
+        $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id]));
+
+        // Create the keyword filter.
+        $keywordfilter = new string_filter('keywords');
+        $filterset->add_filter($keywordfilter);
+
+        // Configure the filter.
+        foreach ($keywords as $keyword) {
+            $keywordfilter->add_filter_value($keyword);
+        }
+        $keywordfilter->set_join_type($jointype);
+
+        // Run the search.
+        $search = new participants_search($course, $coursecontext, $filterset);
+        $rs = $search->get_participants();
+        $this->assertInstanceOf(moodle_recordset::class, $rs);
+        $records = $this->convert_recordset_to_array($rs);
+
+        $this->assertCount($count, $records);
+        $this->assertEquals($count, $search->get_total_participants_count());
+
+        foreach ($expectedusers as $expecteduser) {
+            $this->assertArrayHasKey($users[$expecteduser]->id, $records);
+        }
+    }
+
+    /**
+     * Data provider for keywords tests.
+     *
+     * @return array
+     */
+    public function keywords_provider(): array {
+        $tests = [
+            // Users where the keyword matches firstname, lastname, or username.
+            'Users with basic names' => (object) [
+                'users' => [
+                    'adam.ant' => [
+                        'firstname' => 'Adam',
+                        'lastname' => 'Ant',
+                    ],
+                    'barbara.bennett' => [
+                        'firstname' => 'Barbara',
+                        'lastname' => 'Bennett',
+                    ],
+                    'colin.carnforth' => [
+                        'firstname' => 'Colin',
+                        'lastname' => 'Carnforth',
+                    ],
+                    'tony.rogers' => [
+                        'firstname' => 'Anthony',
+                        'lastname' => 'Rogers',
+                    ],
+                    'sarah.rester' => [
+                        'firstname' => 'Sarah',
+                        'lastname' => 'Rester',
+                        'email' => 'zazu@example.com',
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No filter' => (object) [
+                        'keywords' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 5,
+                        'expectedusers' => [
+                            'adam.ant',
+                            'barbara.bennett',
+                            'colin.carnforth',
+                            'tony.rogers',
+                            'sarah.rester',
+                        ],
+                    ],
+                    'ANY: First name only' => (object) [
+                        'keywords' => ['adam'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'adam.ant',
+                        ],
+                    ],
+                    'ANY: Last name only' => (object) [
+                        'keywords' => ['BeNNeTt'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'barbara.bennett',
+                        ],
+                    ],
+                    'ANY: First/Last name' => (object) [
+                        'keywords' => ['ant'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'adam.ant',
+                            'tony.rogers',
+                        ],
+                    ],
+                    'ANY: Username (no match)' => (object) [
+                        'keywords' => ['sara.rester'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                    'ANY: Email' => (object) [
+                        'keywords' => ['zazu'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'sarah.rester',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No filter' => (object) [
+                        'keywords' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 5,
+                        'expectedusers' => [
+                            'adam.ant',
+                            'barbara.bennett',
+                            'colin.carnforth',
+                            'tony.rogers',
+                            'sarah.rester',
+                        ],
+                    ],
+                    'ALL: First name only' => (object) [
+                        'keywords' => ['adam'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'adam.ant',
+                        ],
+                    ],
+                    'ALL: Last name only' => (object) [
+                        'keywords' => ['BeNNeTt'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'barbara.bennett',
+                        ],
+                    ],
+                    'ALL: First/Last name' => (object) [
+                        'keywords' => ['ant'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'adam.ant',
+                            'tony.rogers',
+                        ],
+                    ],
+                    'ALL: Username (no match)' => (object) [
+                        'keywords' => ['sara.rester'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                    'ALL: Email' => (object) [
+                        'keywords' => ['zazu'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'sarah.rester',
+                        ],
+                    ],
+                    'ALL: Multiple keywords' => (object) [
+                        'keywords' => ['ant', 'rog'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'tony.rogers',
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        $finaltests = [];
+        foreach ($tests as $testname => $testdata) {
+            foreach ($testdata->expect as $expectname => $expectdata) {
+                $finaltests["{$testname} => {$expectname}"] = [
+                    'users' => $testdata->users,
+                    'keywords' => $expectdata->keywords,
+                    'jointype' => $expectdata->jointype,
+                    'count' => $expectdata->count,
+                    'expectedusers' => $expectdata->expectedusers,
+                ];
+            }
+        }
+
+        return $finaltests;
+    }
+
+    /**
+     * Ensure that the enrolment status filter works as expected with the provided test cases.
+     *
+     * @param array $usersdata The list of users to create
+     * @param array $statuses The list of statuses to filter by
+     * @param int $jointype The join type to use when combining filter values
+     * @param int $count The expected count
+     * @param array $expectedusers
+     * @dataProvider status_provider
+     */
+    public function test_status_filter(array $usersdata, array $statuses, int $jointype, int $count, array $expectedusers): void {
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $users = [];
+
+        // Ensure sufficient capabilities to view all statuses.
+        $this->setAdminUser();
+
+        // Ensure all enrolment methods enabled.
+        $enrolinstances = enrol_get_instances($course->id, false);
+        foreach ($enrolinstances as $instance) {
+            $plugin = enrol_get_plugin($instance->enrol);
+            $plugin->update_status($instance, ENROL_INSTANCE_ENABLED);
+        }
+
+        foreach ($usersdata as $username => $userdata) {
+            $user = $this->getDataGenerator()->create_user(['username' => $username]);
+
+            if (array_key_exists('statuses', $userdata)) {
+                foreach ($userdata['statuses'] as $enrolmethod => $status) {
+                    $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student', $enrolmethod, 0, 0, $status);
+                }
+            }
+
+            $users[$username] = $user;
+        }
+
+        // Create a secondary course with users. We should not see these users.
+        $this->create_course_with_users(1, 1, 1, 1);
+
+        // Create the basic filter.
+        $filterset = new participants_filterset();
+        $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id]));
+
+        // Create the status filter.
+        $statusfilter = new integer_filter('status');
+        $filterset->add_filter($statusfilter);
+
+        // Configure the filter.
+        foreach ($statuses as $status) {
+            $statusfilter->add_filter_value($status);
+        }
+        $statusfilter->set_join_type($jointype);
+
+        // Run the search.
+        $search = new participants_search($course, $coursecontext, $filterset);
+        $rs = $search->get_participants();
+        $this->assertInstanceOf(moodle_recordset::class, $rs);
+        $records = $this->convert_recordset_to_array($rs);
+
+        $this->assertCount($count, $records);
+        $this->assertEquals($count, $search->get_total_participants_count());
+
+        foreach ($expectedusers as $expecteduser) {
+            $this->assertArrayHasKey($users[$expecteduser]->id, $records);
+        }
+    }
+
+    /**
+     * Data provider for status filter tests.
+     *
+     * @return array
+     */
+    public function status_provider(): array {
+        $tests = [
+            // Users with different statuses and enrolment methods (so multiple statuses are possible for the same user).
+            'Users with different enrolment statuses' => (object) [
+                'users' => [
+                    'a' => [
+                        'statuses' => [
+                            'manual' => ENROL_USER_ACTIVE,
+                        ]
+                    ],
+                    'b' => [
+                        'statuses' => [
+                            'self' => ENROL_USER_ACTIVE,
+                        ]
+                    ],
+                    'c' => [
+                        'statuses' => [
+                            'manual' => ENROL_USER_SUSPENDED,
+                        ]
+                    ],
+                    'd' => [
+                        'statuses' => [
+                            'self' => ENROL_USER_SUSPENDED,
+                        ]
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No filter' => (object) [
+                        'statuses' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 4,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                        ],
+                    ],
+                    'ANY: Active only' => (object) [
+                        'statuses' => [ENROL_USER_ACTIVE],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                    'ANY: Suspended only' => (object) [
+                        'statuses' => [ENROL_USER_SUSPENDED],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'c',
+                            'd',
+                        ],
+                    ],
+                    'ANY: Multiple statuses' => (object) [
+                        'statuses' => [ENROL_USER_ACTIVE, ENROL_USER_SUSPENDED],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 4,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No filter' => (object) [
+                       'statuses' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 4,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                            'd',
+                        ],
+                    ],
+                    'ALL: Active only' => (object) [
+                        'statuses' => [ENROL_USER_ACTIVE],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                        ],
+                    ],
+                    'ALL: Suspended only' => (object) [
+                        'statuses' => [ENROL_USER_SUSPENDED],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'c',
+                            'd',
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        $finaltests = [];
+        foreach ($tests as $testname => $testdata) {
+            foreach ($testdata->expect as $expectname => $expectdata) {
+                $finaltests["{$testname} => {$expectname}"] = [
+                    'users' => $testdata->users,
+                    'statuses' => $expectdata->statuses,
+                    'jointype' => $expectdata->jointype,
+                    'count' => $expectdata->count,
+                    'expectedusers' => $expectdata->expectedusers,
+                ];
+            }
+        }
+
+        return $finaltests;
+    }
+
+    /**
+     * Ensure that the enrolment methods filter works as expected with the provided test cases.
+     *
+     * @param array $usersdata The list of users to create
+     * @param array $enrolmethods The list of enrolment methods to filter by
+     * @param int $jointype The join type to use when combining filter values
+     * @param int $count The expected count
+     * @param array $expectedusers
+     * @dataProvider enrolments_provider
+     */
+    public function test_enrolments_filter(array $usersdata, array $enrolmethods, int $jointype, int $count,
+            array $expectedusers): void {
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $users = [];
+
+        // Ensure all enrolment methods enabled and mapped for setting the filter later.
+        $enrolinstances = enrol_get_instances($course->id, false);
+        $enrolinstancesmap = [];
+        foreach ($enrolinstances as $instance) {
+            $plugin = enrol_get_plugin($instance->enrol);
+            $plugin->update_status($instance, ENROL_INSTANCE_ENABLED);
+
+            $enrolinstancesmap[$instance->enrol] = (int) $instance->id;
+        }
+
+        foreach ($usersdata as $username => $userdata) {
+            $user = $this->getDataGenerator()->create_user(['username' => $username]);
+
+            if (array_key_exists('enrolmethods', $userdata)) {
+                foreach ($userdata['enrolmethods'] as $enrolmethod) {
+                    $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student', $enrolmethod);
+                }
+            }
+
+            $users[$username] = $user;
+        }
+
+        // Create a secondary course with users. We should not see these users.
+        $this->create_course_with_users(1, 1, 1, 1);
+
+        // Create the basic filter.
+        $filterset = new participants_filterset();
+        $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id]));
+
+        // Create the enrolment methods filter.
+        $enrolmethodfilter = new integer_filter('enrolments');
+        $filterset->add_filter($enrolmethodfilter);
+
+        // Configure the filter.
+        foreach ($enrolmethods as $enrolmethod) {
+            $enrolmethodfilter->add_filter_value($enrolinstancesmap[$enrolmethod]);
+        }
+        $enrolmethodfilter->set_join_type($jointype);
+
+        // Run the search.
+        $search = new participants_search($course, $coursecontext, $filterset);
+        $rs = $search->get_participants();
+        $this->assertInstanceOf(moodle_recordset::class, $rs);
+        $records = $this->convert_recordset_to_array($rs);
+
+        $this->assertCount($count, $records);
+        $this->assertEquals($count, $search->get_total_participants_count());
+
+        foreach ($expectedusers as $expecteduser) {
+            $this->assertArrayHasKey($users[$expecteduser]->id, $records);
+        }
+    }
+
+    /**
+     * Data provider for enrolments filter tests.
+     *
+     * @return array
+     */
+    public function enrolments_provider(): array {
+        $tests = [
+            // Users with different enrolment methods.
+            'Users with different enrolment methods' => (object) [
+                'users' => [
+                    'a' => [
+                        'enrolmethods' => [
+                            'manual',
+                        ]
+                    ],
+                    'b' => [
+                        'enrolmethods' => [
+                            'self',
+                        ]
+                    ],
+                    'c' => [
+                        'enrolmethods' => [
+                            'manual',
+                            'self',
+                        ]
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No filter' => (object) [
+                        'enrolmethods' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Manual enrolments only' => (object) [
+                        'enrolmethods' => ['manual'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Self enrolments only' => (object) [
+                        'enrolmethods' => ['self'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Multiple enrolment methods' => (object) [
+                        'enrolmethods' => ['manual', 'self'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No filter' => (object) [
+                       'enrolmethods' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ALL: Manual enrolments only' => (object) [
+                        'enrolmethods' => ['manual'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        $finaltests = [];
+        foreach ($tests as $testname => $testdata) {
+            foreach ($testdata->expect as $expectname => $expectdata) {
+                $finaltests["{$testname} => {$expectname}"] = [
+                    'users' => $testdata->users,
+                    'enrolmethods' => $expectdata->enrolmethods,
+                    'jointype' => $expectdata->jointype,
+                    'count' => $expectdata->count,
+                    'expectedusers' => $expectdata->expectedusers,
+                ];
+            }
+        }
+
+        return $finaltests;
+    }
+}
index dd63fb5..c3ab577 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020051200.01;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020051500.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9dev+ (Build: 20200512)'; // Human-friendly version name
+$release  = '3.9dev+ (Build: 20200515)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.