Merge branch 'MDL-68833-master' of git://github.com/andrewnicols/moodle
authorSara Arjona <sara@moodle.com>
Wed, 3 Jun 2020 15:57:57 +0000 (17:57 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 3 Jun 2020 15:57:57 +0000 (17:57 +0200)
85 files changed:
admin/index.php
admin/renderer.php
config-dist.php
contentbank/amd/build/sort.min.js
contentbank/amd/build/sort.min.js.map
contentbank/amd/src/sort.js
contentbank/classes/output/bankcontent.php
contentbank/classes/privacy/provider.php
contentbank/lib.php [new file with mode: 0644]
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/toolbar.mustache
contentbank/tests/behat/edit_content.feature
contentbank/tests/behat/view_preferences.feature [new file with mode: 0644]
contentbank/tests/privacy_test.php
course/tests/behat/rename_roles.feature
filter/displayh5p/filter.php
grade/edit/tree/index.php
grade/edit/tree/lib.php
grade/report/grader/lib.php
grade/report/lib.php
grade/tests/behat/behat_grade.php
grade/tests/behat/grade_item_duplication.feature [new file with mode: 0644]
group/tests/behat/create_groups.feature
group/tests/behat/group_description.feature
h5p/amd/build/editor_display.min.js
h5p/amd/build/editor_display.min.js.map
h5p/amd/src/editor_display.js
h5p/classes/framework.php
h5p/classes/helper.php
lang/en/contentbank.php
lang/en/deprecated.txt
lang/en/grades.php
lang/en/moodle.php
lang/en/user.php
lib/behat/classes/behat_core_generator.php
lib/classes/user.php
lib/deprecatedlib.php
lib/grade/grade_item.php
lib/grade/tests/grade_item_test.php
lib/outputcomponents.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/table/classes/local/filter/filter.php
lib/table/classes/local/filter/filterset.php
lib/templates/campaign_content.mustache [new file with mode: 0644]
message/output/popup/templates/notification_popover.mustache
mod/quiz/classes/output/edit_renderer.php
mod/quiz/styles.css
privacy/classes/local/request/moodle_content_writer.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/amd/build/local/participantsfilter/filter.min.js
user/amd/build/local/participantsfilter/filter.min.js.map
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map
user/amd/build/local/participantsfilter/selectors.min.js
user/amd/build/local/participantsfilter/selectors.min.js.map
user/amd/build/participantsfilter.min.js
user/amd/build/participantsfilter.min.js.map
user/amd/build/unified_filter.min.js.map
user/amd/build/unified_filter_datasource.min.js.map
user/amd/src/local/participantsfilter/filter.js
user/amd/src/local/participantsfilter/filtertypes/keyword.js
user/amd/src/local/participantsfilter/selectors.js
user/amd/src/participantsfilter.js
user/amd/src/unified_filter.js
user/amd/src/unified_filter_datasource.js
user/classes/output/participants_filter.php
user/classes/output/unified_filter.php
user/classes/table/participants_search.php
user/index.php
user/lib.php
user/renderer.php
user/templates/local/participantsfilter/autocomplete_selection_items.mustache
user/templates/local/participantsfilter/filterrow.mustache
user/templates/unified_filter.mustache
user/tests/behat/filter_participants.feature
user/tests/behat/filter_participants_showall.feature
user/tests/behat/view_participants_groups.feature
user/tests/table/participants_search_test.php
user/tests/userlib_test.php
user/upgrade.txt

index ee3b2e3..5660318 100644 (file)
@@ -899,6 +899,9 @@ if (empty($CFG->disabledevlibdirscheck) && (is_dir($CFG->dirroot.'/vendor') || i
 // Check if the site is being foced onto ssl.
 $overridetossl = !empty($CFG->overridetossl);
 
+// Check if moodle campaign content setting is enabled or not.
+$showcampaigncontent = !isset($CFG->showcampaigncontent) || $CFG->showcampaigncontent;
+
 admin_externalpage_setup('adminnotifications');
 
 $output = $PAGE->get_renderer('core', 'admin');
@@ -906,4 +909,5 @@ $output = $PAGE->get_renderer('core', 'admin');
 echo $output->admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, $cronoverdue, $dbproblems,
                                        $maintenancemode, $availableupdates, $availableupdatesfetch, $buggyiconvnomb,
                                        $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir,
-                                       $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl, $croninfrequent);
+                                       $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl, $croninfrequent,
+                                       $showcampaigncontent);
index 45f46a5..cda9ed6 100644 (file)
@@ -282,6 +282,7 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param bool $overridetossl Whether or not ssl is being forced.
      * @param bool $invalidforgottenpasswordurl Whether the forgotten password URL does not link to a valid URL.
      * @param bool $croninfrequent If true, warn that cron hasn't run in the past few minutes
+     * @param bool $showcampaigncontent Whether the campaign content should be visible or not.
      *
      * @return string HTML to output.
      */
@@ -289,7 +290,9 @@ class core_admin_renderer extends plugin_renderer_base {
             $cronoverdue, $dbproblems, $maintenancemode, $availableupdates, $availableupdatesfetch,
             $buggyiconvnomb, $registered, array $cachewarnings = array(), $eventshandlers = 0,
             $themedesignermode = false, $devlibdir = false, $mobileconfigured = false,
-            $overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false) {
+            $overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false,
+            $showcampaigncontent = false) {
+
         global $CFG;
         $output = '';
 
@@ -312,6 +315,7 @@ class core_admin_renderer extends plugin_renderer_base {
         $output .= $this->registration_warning($registered);
         $output .= $this->mobile_configuration_warning($mobileconfigured);
         $output .= $this->forgotten_password_url_warning($invalidforgottenpasswordurl);
+        $output .= $this->campaign_content($showcampaigncontent);
 
         //////////////////////////////////////////////////////////////////////////////////////////////////
         ////  IT IS ILLEGAL AND A VIOLATION OF THE GPL TO HIDE, REMOVE OR MODIFY THIS COPYRIGHT NOTICE ///
@@ -878,6 +882,20 @@ class core_admin_renderer extends plugin_renderer_base {
         return $output;
     }
 
+    /**
+     * Display campaign content.
+     *
+     * @param bool $showcampaigncontent Whether the campaign content should be visible or not.
+     * @return string the campaign content raw html.
+     */
+    protected function campaign_content(bool $showcampaigncontent): string {
+        if (!$showcampaigncontent) {
+            return '';
+        }
+
+        return $this->render_from_template('core/campaign_content', ['lang' => current_language()]);
+    }
+
     /**
      * Display a warning about the forgotten password URL not linking to a valid URL.
      *
index ccc760a..995e0df 100644 (file)
@@ -1055,6 +1055,15 @@ $CFG->admin = 'admin';
 //      $CFG->alternative_file_system_class = '\\local_myfilestorage\\file_system';
 //
 //=========================================================================
+// 15. CAMPAIGN CONTENT
+//=========================================================================
+//
+// We have added a campaign content to the notifications page, in case you want to hide that from your site you just
+// need to set showcampaigncontent setting to false.
+//
+//      $CFG->showcampaigncontent = true;
+//
+//=========================================================================
 // ALL DONE!  To continue installation, visit your main page with a browser
 //=========================================================================
 
index 5d0babf..bd655b7 100644 (file)
Binary files a/contentbank/amd/build/sort.min.js and b/contentbank/amd/build/sort.min.js differ
index 4609a9e..663a438 100644 (file)
Binary files a/contentbank/amd/build/sort.min.js.map and b/contentbank/amd/build/sort.min.js.map differ
index bfa61c8..24f4f79 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-import selectors from 'core_contentbank/selectors';
+import selectors from './selectors';
 import {get_string as getString} from 'core/str';
 import Prefetch from 'core/prefetch';
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
 
 /**
  * Set up the contentbank views.
@@ -59,6 +61,7 @@ const registerListenerEvents = (contentBank) => {
         contentBank.classList.add('view-grid');
         viewGrid.classList.add('active');
         viewList.classList.remove('active');
+        setViewListPreference(false);
     });
 
     viewList.addEventListener('click', () => {
@@ -66,6 +69,7 @@ const registerListenerEvents = (contentBank) => {
         contentBank.classList.add('view-list');
         viewList.classList.add('active');
         viewGrid.classList.remove('active');
+        setViewListPreference(true);
     });
 
     // Sort by file name alphabetical
@@ -97,6 +101,35 @@ const registerListenerEvents = (contentBank) => {
     });
 };
 
+
+/**
+ * Set the contentbank user preference in list view
+ *
+ * @param  {Bool} viewList view ContentBank as list.
+ * @return {Promise} Repository promise.
+ */
+const setViewListPreference = function(viewList) {
+
+    // If the given status is not hidden, the preference has to be deleted with a null value.
+    if (viewList === false) {
+        viewList = null;
+    }
+
+    const request = {
+        methodname: 'core_user_update_user_preferences',
+        args: {
+            preferences: [
+                {
+                    type: 'core_contentbank_view_list',
+                    value: viewList
+                }
+            ]
+        }
+    };
+
+    return Ajax.call([request])[0].catch(Notification.exception);
+};
+
 /**
  * Update the sort button view.
  *
index 93b512b..5549392 100644 (file)
@@ -97,6 +97,7 @@ class bankcontent implements renderable, templatable {
                 'type' => $mimetype
             );
         }
+        $data->viewlist = get_user_preferences('core_contentbank_view_list');
         $data->contents = $contentdata;
         // The tools are displayed in the action bar on the index page.
         foreach ($this->toolbar as $tool) {
index d654ab3..c0c49c5 100644 (file)
@@ -44,7 +44,8 @@ use context_course;
 class provider implements
     \core_privacy\local\metadata\provider,
     \core_privacy\local\request\core_userlist_provider,
-    \core_privacy\local\request\plugin\provider {
+    \core_privacy\local\request\plugin\provider,
+    \core_privacy\local\request\user_preference_provider {
 
     /**
      * Returns meta data about this system.
@@ -65,6 +66,26 @@ class provider implements
         return $collection;
     }
 
+    /**
+     * Export all user preferences for the contentbank
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $preference = get_user_preferences('core_contentbank_view_list', null, $userid);
+        if (isset($preference)) {
+            writer::export_user_preference(
+                    'core_contentbank',
+                    'core_contentbank_view_list',
+                    $preference,
+                    get_string('privacy:request:preference:set', 'core_contentbank', (object) [
+                            'name' => 'core_contentbank_view_list',
+                            'value' => $preference,
+                    ])
+            );
+        }
+    }
+
     /**
      * Get the list of contexts that contain user information for the specified user.
      *
diff --git a/contentbank/lib.php b/contentbank/lib.php
new file mode 100644 (file)
index 0000000..e709df7
--- /dev/null
@@ -0,0 +1,39 @@
+<?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/>.
+
+/**
+ * Library functions for contentbank
+ *
+ * @package   core_contentbank
+ * @copyright 2020 Bas Brands
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Get the current user preferences that are available
+ *
+ * @return Array preferences configuration
+ */
+function core_contentbank_user_preferences() {
+    return [
+        'core_contentbank_view_list' => [
+            'choices' => array(0, 1),
+            'type' => PARAM_INT,
+            'null' => NULL_NOT_ALLOWED,
+            'default' => 'none'
+        ],
+    ];
+}
index 020b905..4801a7f 100644 (file)
@@ -74,7 +74,8 @@
     }
 
 }}
-<div class="content-bank-container view-grid" data-region="contentbank">
+<div class="content-bank-container {{#viewlist}}view-list{{/viewlist}} {{^viewlist}}view-grid{{/viewlist}}"
+data-region="contentbank">
     <div class="d-flex justify-content-between flex-column flex-sm-row">
         <div class="cb-search-container mb-2">
             {{>core_contentbank/bankcontent/search}}
                         <div class="cb-thumbnail" role="img" aria-label="{{{ name }}}"
                         style="background-image: url('{{{ icon }}}');">
                         </div>
-                        <a href="{{{ link }}}" class="cb-link stretched-link">
+                        <a href="{{{ link }}}" class="cb-link stretched-link" title="{{{ name }}}">
                             <span class="cb-name word-break-all clamp-2" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
index 242fa1a..4d590ce 100644 (file)
         </a>
     {{/dropdown}}
 {{/tools}}
-<button class="icon-no-margin btn btn-secondary active ml-2"
+<button class="icon-no-margin btn btn-secondary {{^viewlist}}active{{/viewlist}} ml-2"
 title="{{#str}}  displayicons, contentbank  {{/str}}"
 data-action="viewgrid">
     {{#pix}}a/view_icon_active, core, {{#str}} displayicons, contentbank {{/str}} {{/pix}}
 </button>
-<button class="icon-no-margin btn btn-secondary"
+<button class="icon-no-margin btn btn-secondary {{#viewlist}}active{{/viewlist}}"
 title="{{#str}} displaydetails, contentbank {{/str}}"
 data-action="viewlist">
     {{#pix}}t/viewdetails, core, {{#str}} displaydetails, contentbank {{/str}} {{/pix}}
index 713768c..aef6eab 100644 (file)
@@ -97,3 +97,21 @@ Feature: Content bank use editor feature
       | moodle/contentbank:useeditor     | Prohibit   | editingteacher | System       |           |
     And I reload the page
     Then "[data-action=Add-content]" "css_element" should not exist
+
+  Scenario: Users can edit content and save changes
+    Given the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user  | contentname             | filepath                                    |
+      | System       |           | contenttype_h5p | admin | filltheblanks.h5p       | /h5p/tests/fixtures/filltheblanks.h5p       |
+    And I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Edit" "link"
+    And I switch to "h5p-editor-iframe" class iframe
+    And the field "Title" matches value "Geography"
+    And I set the field "Title" to "New title"
+    And I switch to the main frame
+    When I click on "Save" "button"
+    And I should see "filltheblanks.h5p" in the "h1" "css_element"
+    And I click on "Edit" "link"
+    And I switch to "h5p-editor-iframe" class iframe
+    Then the field "Title" matches value "New title"
diff --git a/contentbank/tests/behat/view_preferences.feature b/contentbank/tests/behat/view_preferences.feature
new file mode 100644 (file)
index 0000000..44fa8bf
--- /dev/null
@@ -0,0 +1,28 @@
+@core @core_contentbank @contentbank_h5p @javascript
+Feature: Store the content bank view preference
+  In order to consistantly view the content bank in icons or details view
+  As an admin
+  I need to be able to store my view preference
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextlevel | reference | contenttype       | user  | contentname          |
+        | System       |           | contenttype_h5p   | admin | filltheblanks.h5p    |
+        | System       |           | contenttype_h5p   | admin | mathsbook.h5p        |
+
+  Scenario: Admins can order content in the content bank
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Display content bank with file details" "button"
+    And I should see "Last modified"
+    And I follow "filltheblanks.h5p"
+    And I click on "Content bank" "link"
+    And I should see "Last modified"
+    And I click on "Display content bank with icons" "button"
+    And I follow "filltheblanks.h5p"
+    And I click on "Content bank" "link"
+    And I should not see "Last modified"
index e737117..3717d40 100644 (file)
@@ -29,6 +29,7 @@ use stdClass;
 use context_system;
 use context_coursecat;
 use context_course;
+use context_user;
 use core_contentbank\privacy\provider;
 use core_privacy\local\request\approved_contextlist;
 use core_privacy\local\request\writer;
@@ -361,4 +362,50 @@ class core_contentbank_privacy_testcase extends provider_testcase {
 
         return $scenario;
     }
+
+    /**
+     * Ensure that export_user_preferences returns no data if the user has not visited any content bank.
+     */
+    public function test_export_user_preferences_no_pref() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+        $this->getDataGenerator()->role_assign($managerroleid, $user->id);
+
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(context_system::instance());
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    /**
+     * Test for provider::test_export_user_preferences().
+     */
+    public function test_export_user_preferences() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        set_user_preference('core_contentbank_view_list', 1);
+        // Test the user preferences export contains 1 user preference record for the User.
+        provider::export_user_preferences($user->id);
+        $contextuser = context_user::instance($user->id);
+        $writer = writer::with_context($contextuser);
+        $this->assertTrue($writer->has_any_data());
+
+        $prefs = $writer->get_user_preferences('core_contentbank');
+        $this->assertCount(1, (array) $prefs);
+        $this->assertEquals(1, $prefs->core_contentbank_view_list->value);
+        $this->assertEquals(
+                get_string('privacy:request:preference:set', 'core_contentbank', (object) [
+                        'name' => 'core_contentbank_view_list',
+                        'value' => $prefs->core_contentbank_view_list->value,
+                ]),
+                $prefs->core_contentbank_view_list->description
+        );
+    }
 }
index 8c8a5e8..bb5ab23 100644 (file)
@@ -30,10 +30,11 @@ Feature: Rename roles within a course
     Then "Tutor" "button" should exist
     And "Learner" "button" should exist
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I should see "Role: Tutor" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Role: Learner" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Tutor" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Learner" in the ".form-autocomplete-suggestions" "css_element"
+    And I should not see "Student" in the ".form-autocomplete-suggestions" "css_element"
     And I am on "Course 1" course homepage
     And I navigate to "Edit settings" in current page administration
     And I set the following fields to these values:
@@ -45,6 +46,7 @@ Feature: Rename roles within a course
     And "Student" "button" should exist
     And "Learner" "button" should not exist
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I should see "Role: Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Student" in the ".form-autocomplete-suggestions" "css_element"
index 8d85c1c..f693b3c 100644 (file)
@@ -65,7 +65,7 @@ class filter_displayh5p extends moodle_text_filter {
         $allowedsources = get_config('filter_displayh5p', 'allowedsources');
         $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
 
-        $localsource = '('.preg_quote($CFG->wwwroot).'/[^ &\#"\'<]*\.h5p([?][^ "\'<]*)?[^ \#"\'<]*)';
+        $localsource = '('.preg_quote($CFG->wwwroot, '~').'/[^ &\#"\'<]*\.h5p([?][^ "\'<]*)?[^ \#"\'<]*)';
         $allowedsources[] = $localsource;
 
         $params = array(
index aa58497..152cd65 100644 (file)
@@ -86,6 +86,17 @@ if ($action == 'moveselect') {
 $grade_edit_tree = new grade_edit_tree($gtree, $movingeid, $gpr);
 
 switch ($action) {
+    case 'duplicate':
+        if ($eid and confirm_sesskey()) {
+            if (!$el = $gtree->locate_element($eid)) {
+                print_error('invalidelementid', '', $returnurl);
+            }
+
+            $object->duplicate();
+            redirect($returnurl);
+        }
+        break;
+
     case 'delete':
         if ($eid && confirm_sesskey()) {
             if (!$grade_edit_tree->element_deletable($element)) {
index 8eac5fe..91744d8 100644 (file)
@@ -149,6 +149,18 @@ class grade_edit_tree {
                 $actionsmenu->add($icon);
             }
 
+            if ($this->element_duplicatable($element)) {
+                $duplicateparams = array();
+                $duplicateparams['id'] = $COURSE->id;
+                $duplicateparams['action'] = 'duplicate';
+                $duplicateparams['eid'] = $eid;
+                $duplicateparams['sesskey'] = sesskey();
+                $aurl = new moodle_url('index.php', $duplicateparams);
+                $duplicateicon = new pix_icon('t/copy', get_string('duplicate'));
+                $icon = new action_menu_link_secondary($aurl, $duplicateicon, get_string('duplicate'));
+                $actionsmenu->add($icon);
+            }
+
             $aurl = new moodle_url('index.php', array('id' => $COURSE->id, 'action' => 'moveselect', 'eid' => $eid, 'sesskey' => sesskey()));
             $moveaction .= $OUTPUT->action_icon($aurl, new pix_icon('t/move', get_string('move')));
         }
@@ -460,6 +472,24 @@ class grade_edit_tree {
         return false;
     }
 
+    /**
+     * Given an element of the grade tree, returns whether it is duplicatable or not (only manual grade items are duplicatable)
+     *
+     * @param array $element
+     * @return bool
+     */
+    public function element_duplicatable($element) {
+        if ($element['type'] != 'item') {
+            return false;
+        }
+
+        $gradeitem = $element['object'];
+        if ($gradeitem->itemtype != 'mod') {
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Given the grade tree and an array of element ids (e.g. c15, i42), and expecting the 'moveafter' URL param,
      * moves the selected items to the requested location. Then redirects the user to the given $returnurl
index 5227baf..40a9b11 100644 (file)
@@ -725,13 +725,15 @@ class grade_report_grader extends grade_report {
             $usercell->scope = 'row';
 
             if ($showuserimage) {
-                $usercell->text = $OUTPUT->user_picture($user, array('visibletoscreenreaders' => false));
+                $usercell->text = $OUTPUT->user_picture($user, ['link' => false, 'visibletoscreenreaders' => false]);
             }
 
             $fullname = fullname($user, $viewfullnames);
-            $usercell->text .= html_writer::link(new moodle_url('/user/view.php', array('id' => $user->id, 'course' => $this->course->id)), $fullname, array(
-                'class' => 'username',
-            ));
+            $usercell->text = html_writer::link(
+                    new moodle_url('/user/view.php', ['id' => $user->id, 'course' => $this->course->id]),
+                    $usercell->text . $fullname,
+                    ['class' => 'username']
+            );
 
             if (!empty($user->suspendedenrolment)) {
                 $usercell->attributes['class'] .= ' usersuspended';
@@ -753,13 +755,18 @@ class grade_report_grader extends grade_report {
                 $a = new stdClass();
                 $a->user = $fullname;
                 $strgradesforuser = get_string('gradesforuser', 'grades', $a);
-                $url = new moodle_url('/grade/report/'.$CFG->grade_profilereport.'/index.php', array('userid' => $user->id, 'id' => $this->course->id));
-                $userreportcell->text .= $OUTPUT->action_icon($url, new pix_icon('t/grades', $strgradesforuser));
+                $url = new moodle_url('/grade/report/'.$CFG->grade_profilereport.'/index.php',
+                        ['userid' => $user->id, 'id' => $this->course->id]);
+                $userreportcell->text .= $OUTPUT->action_icon($url, new pix_icon('t/grades', ''), null,
+                        ['title' => $strgradesforuser, 'aria-label' => $strgradesforuser]);
             }
 
             if ($canseesingleview) {
-                $url = new moodle_url('/grade/report/singleview/index.php', array('id' => $this->course->id, 'itemid' => $user->id, 'item' => 'user'));
-                $singleview = $OUTPUT->action_icon($url, new pix_icon('t/editstring', get_string('singleview', 'grades', $fullname)));
+                $strsingleview = get_string('singleview', 'grades', $fullname);
+                $url = new moodle_url('/grade/report/singleview/index.php',
+                        ['id' => $this->course->id, 'itemid' => $user->id, 'item' => 'user']);
+                $singleview = $OUTPUT->action_icon($url, new pix_icon('t/editstring', ''), null,
+                        ['title' => $strsingleview, 'aria-label' => $strsingleview]);
                 $userreportcell->text .= $singleview;
             }
 
@@ -913,13 +920,16 @@ class grade_report_grader extends grade_report {
                         if (has_all_capabilities(array('gradereport/singleview:view', 'moodle/grade:viewall',
                             'moodle/grade:edit'), $this->context)) {
 
+                            $strsingleview = get_string('singleview', 'grades', $element['object']->get_name());
                             $url = new moodle_url('/grade/report/singleview/index.php', array(
                                 'id' => $this->course->id,
                                 'item' => 'grade',
                                 'itemid' => $element['object']->id));
                             $singleview = $OUTPUT->action_icon(
-                                $url,
-                                new pix_icon('t/editstring', get_string('singleview', 'grades', $element['object']->get_name()))
+                                    $url,
+                                    new pix_icon('t/editstring', ''),
+                                    null,
+                                    ['title' => $strsingleview, 'aria-label' => $strsingleview]
                             );
                         }
                     }
@@ -1269,7 +1279,8 @@ class grade_report_grader extends grade_report {
         $fulltable = new html_table();
         $fulltable->attributes['class'] = 'gradereport-grader-table';
         $fulltable->id = 'user-grades';
-        $fulltable->summary = get_string('summarygrader', 'gradereport_grader');
+        $fulltable->caption = get_string('summarygrader', 'gradereport_grader');
+        $fulltable->captionhide = true;
 
         // Extract rows from each side (left and right) and collate them into one row each
         foreach ($leftrows as $key => $row) {
@@ -1637,24 +1648,32 @@ class grade_report_grader extends grade_report {
 
             if (in_array($element['object']->id, $this->collapsed['aggregatesonly'])) {
                 $url->param('action', 'switch_plus');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_plus', $strswitchplus), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_plus', ''), null,
+                        ['title' => $strswitchplus, 'aria-label' => $strswitchplus]);
                 $showing = get_string('showingaggregatesonly', 'grades');
             } else if (in_array($element['object']->id, $this->collapsed['gradesonly'])) {
                 $url->param('action', 'switch_whole');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_whole', $strswitchwhole), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_whole', ''), null,
+                        ['title' => $strswitchwhole, 'aria-label' => $strswitchwhole]);
                 $showing = get_string('showinggradesonly', 'grades');
             } else {
                 $url->param('action', 'switch_minus');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_minus', $strswitchminus), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_minus', ''), null,
+                        ['title' => $strswitchminus, 'aria-label' => $strswitchminus]);
                 $showing = get_string('showingfullmode', 'grades');
             }
         }
 
         $name = $element['object']->get_name();
-        $courseheaderid = 'courseheader_' . clean_param($name, PARAM_ALPHANUMEXT);
-        $courseheader = html_writer::tag('span', $name, array('id' => $courseheaderid,
-                'title' => $name, 'class' => 'gradeitemheader'));
-        $courseheader .= html_writer::label($showing, $courseheaderid, false, array('class' => 'accesshide'));
+        $describedbyid = uniqid();
+        $courseheader = html_writer::tag('span', $name, [
+            'title' => $name,
+            'class' => 'gradeitemheader',
+            'aria-describedby' => $describedbyid
+        ]);
+        $courseheader .= html_writer::div($showing, 'sr-only', [
+            'id' => $describedbyid
+        ]);
         $courseheader .= $icon;
 
         return $courseheader;
index 70be5da..8df6324 100644 (file)
@@ -417,8 +417,8 @@ abstract class grade_report {
         $matrix = array('up' => 'desc', 'down' => 'asc', 'move' => 'desc');
         $strsort = $this->get_lang_string('sort' . $matrix[$direction]);
 
-        $arrow = $OUTPUT->pix_icon($pix[$direction], $strsort, '', array('class' => 'sorticon'));
-        return html_writer::link($sortlink, $arrow, array('title'=>$strsort));
+        $arrow = $OUTPUT->pix_icon($pix[$direction], '', '', ['class' => 'sorticon']);
+        return html_writer::link($sortlink, $arrow, ['title' => $strsort, 'aria-label' => $strsort]);
     }
 
     /**
index b17c4cd..b5e6dde 100644 (file)
@@ -117,6 +117,29 @@ class behat_grade extends behat_base {
             "//tr[descendant::*[text() = " . $this->escape($gradeitem) . "]]", 'xpath_element'));
     }
 
+    /**
+     * Duplicates a grade item or category.
+     *
+     * Teacher must be on the grade setup page.
+     *
+     * @Given /^I duplicate the grade item "(?P<grade_item_string>(?:[^"]|\\")*)"$/
+     * @param string $gradeitem
+     */
+    public function i_duplicate_the_grade_item($gradeitem) {
+
+        $gradeitem = behat_context_helper::escape($gradeitem);
+
+        if ($this->running_javascript()) {
+            $xpath = "//tr[contains(.,$gradeitem)]//*[contains(@class,'moodle-actionmenu')]//a[contains(@class,'toggle-display')]";
+            if ($this->getSession()->getPage()->findAll('xpath', $xpath)) {
+                $this->execute("behat_general::i_click_on", array($this->escape($xpath), "xpath_element"));
+            }
+        }
+
+        $this->execute("behat_general::i_click_on_in_the", array(get_string('duplicate'), 'link',
+            "//tr[descendant::*[text() = " . $this->escape($gradeitem) . "]]", 'xpath_element'));
+    }
+
     /**
      * Sets a calculated manual grade item. Needs a table with item name - idnumber relation.
      * The step requires you to be in the 'Gradebook setup' page.
diff --git a/grade/tests/behat/grade_item_duplication.feature b/grade/tests/behat/grade_item_duplication.feature
new file mode 100644 (file)
index 0000000..8bbcdad
--- /dev/null
@@ -0,0 +1,45 @@
+@core @core_grades
+Feature: We can duplicate grade items that already exist.
+  In order to quickly create grade items that have similar settings.
+  As a teacher
+  I need to duplicate an existing grade item and check that its values are properly duplicated.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "grade categories" exist:
+      | fullname  | course |
+      | Category1 | C1     |
+    And the following "activities" exist:
+      | activity | course | idnumber | name        | gradecategory |
+      | assign   | C1     | a1       | Assignment1 | Category1     |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "grade items" exist:
+      | itemname | course | category  | idnumber | gradetype | grademax | grademin | gradepass | display | decimals | hidden | weightoverride |
+      | Item1    | C1     | Category1 | 001      | Value     | 80.00    | 5.00     | 40.00     | 1       | 1        | 0      | 1              |
+
+  Scenario: Ensure the duplicated grade item settings match the original grade item
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I should not see "Duplicate   Category1"
+    And I should not see "Duplicate   Assignment1"
+    When I duplicate the grade item "Item1"
+    Then I should see "Item1 (copy)"
+    And I follow "Edit   Item1 (copy)"
+    And the field "Item name" matches value "Item1 (copy)"
+    And the field "ID number" matches value ""
+    And the field "Grade type" matches value "Value"
+    And the field "Maximum grade" matches value "80.00"
+    And the field "Minimum grade" matches value "5.00"
+    And the field "Grade to pass" matches value "40.00"
+    And the field "Grade display type" matches value "Real"
+    And the field "Overall decimal places" matches value "1"
+    And the field "Hidden" matches value "0"
+    And the field "Weight adjusted" matches value "1"
index 4786637..ef8bc24 100644 (file)
@@ -49,14 +49,17 @@ Feature: Organize students into groups
     And the "members" select box should not contain "Student 0 (student0@example.com)"
     And the "members" select box should not contain "Student 1 (student1@example.com)"
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group 1" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group 1" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 0"
     And I should see "Student 1"
     And I should not see "Student 2"
-    And I click on "Group: Group 1" "text" in the ".form-autocomplete-selection" "css_element"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group 2" item in the autocomplete list
+    And I click on "Remove \"Group 1\" from filter" "button" in the "Filter 1" "fieldset"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group 2" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 2"
     And I should see "Student 3"
     And I should not see "Student 0"
index 5f4b82e..e63ee05 100644 (file)
@@ -41,24 +41,32 @@ Feature: The description of a group can be viewed by students and teachers
     And I add "Student 2 (student2@example.com)" user to "Group B" group members
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group A" item in the autocomplete list
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
-    And I should see "Description for Group A"
-    And I click on "Group: Group A" "autocomplete_selection"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group B" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group B" "list_item"
+    And I click on "Apply filters" "button"
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     Then I should see "Description for Group A"
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
 
   @javascript
@@ -83,22 +91,31 @@ Feature: The description of a group can be viewed by students and teachers
     And I add "Student 2 (student2@example.com)" user to "Group B" group members
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group A" item in the autocomplete list
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
-    And I click on "Group: Group A" "autocomplete_selection"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group B" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group B" "list_item"
+    And I click on "Apply filters" "button"
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    Then I should not see "Description for Group A"
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
+    And I should see "Student 1" in the "participants" "table"
+    And I should not see "Description for Group A"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
index 8d7d442..a33bad7 100644 (file)
Binary files a/h5p/amd/build/editor_display.min.js and b/h5p/amd/build/editor_display.min.js differ
index 9560735..09d896b 100644 (file)
Binary files a/h5p/amd/build/editor_display.min.js.map and b/h5p/amd/build/editor_display.min.js.map differ
index 8b89e4f..e27dcff 100644 (file)
@@ -59,4 +59,5 @@ export const init = (elementId) => {
         inputname,
         cancelSubmitCallback
     );
+    document.querySelector('#' + elementId + ' iframe').setAttribute('name', 'h5p-editor');
 };
index 4524f0a..2e4a2ca 100644 (file)
@@ -796,7 +796,12 @@ class framework implements \H5PFrameworkInterface {
         }
 
         $content['disable'] = $content['disable'] ?? null;
-
+        // Add title to 'params' to use in the editor.
+        if (!empty($content['title'])) {
+            $params = json_decode($content['params']);
+            $params->title = $content['title'];
+            $content['params'] = json_encode($params);
+        }
         $data = [
             'jsoncontent' => $content['params'],
             'displayoptions' => $content['disable'],
@@ -1206,6 +1211,10 @@ class framework implements \H5PFrameworkInterface {
         if (empty($params->metadata)) {
             $params->metadata = new \stdClass();
         }
+        // Add title to metadata.
+        if (!empty($params->title) && empty($params->metadata->title)) {
+            $params->metadata->title = $params->title;
+        }
         $content['metadata'] = $params->metadata;
         $content['params'] = json_encode($params->params ?? $params);
 
index 8e85ffc..721187e 100644 (file)
@@ -75,6 +75,10 @@ class helper {
             ];
             $options = ['disable' => self::get_display_options($core, $config)];
 
+            // Add the 'title' if exists from 'h5p.json' data to keep it for the editor.
+            if (!empty($h5pvalidator->h5pC->mainJsonData['title'])) {
+                $content['title'] = $h5pvalidator->h5pC->mainJsonData['title'];
+            }
             $h5pstorage->savePackage($content, null, $skipcontent, $options);
 
             return $h5pstorage->contentId;
index 8ea78e9..d34dcfe 100644 (file)
@@ -59,6 +59,7 @@ $string['privacy:metadata:content:usercreated'] = 'The user has created the cont
 $string['privacy:metadata:content:usermodified'] = 'The last user who modified the content.';
 $string['privacy:metadata:contentbankcontent'] = 'Stores the content of the content bank.';
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
+$string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
 $string['searchcontentbankbyname'] = 'Search for content by name';
index 1bb420f..4203ac9 100644 (file)
@@ -144,3 +144,4 @@ pacific/yap,core_timezones
 editsettings,core_badges
 availablelicenses,core_admin
 managelicenses,core_admin
+userfilterplaceholder,core
index f717e4f..11d5b11 100644 (file)
@@ -163,6 +163,7 @@ $string['dropped'] = 'Dropped';
 $string['droplowestvalues'] = 'Drop {$a} lowest values';
 $string['dropxlowest'] = 'Drop X lowest';
 $string['dropxlowestwarning'] = 'Note: If you use drop x lowest the grading assumes that all items in the category have the same point value. If point values differ results will be unpredictable';
+$string['duplicatedgradeitem'] = '{$a} (copy)';
 $string['duplicatescale'] = 'Duplicate scale';
 $string['edit'] = 'Edit';
 $string['editcalculation'] = 'Edit calculation';
index 50ea6d2..4f20017 100644 (file)
@@ -2166,7 +2166,6 @@ $string['userdescription'] = 'Description';
 $string['userdescription_help'] = 'This box enables you to enter some text about yourself which will then be displayed on your profile page for others to view.';
 $string['userdetails'] = 'User details';
 $string['userfiles'] = 'User files';
-$string['userfilterplaceholder'] = 'Search keyword or select filter';
 $string['userlist'] = 'User list';
 $string['usermenu'] = 'User menu';
 $string['username'] = 'Username';
@@ -2292,3 +2291,4 @@ $string['sitemessage'] = 'Message users';
 
 // Deprecated since Moodle 3.9.
 $string['participantscount'] = 'Number of participants: {$a}';
+$string['userfilterplaceholder'] = 'Search keyword or select filter';
index 5eee2cb..ee3c7fd 100644 (file)
@@ -29,7 +29,9 @@ $string['adverbfor_or'] = 'or';
 $string['applyfilters'] = 'Apply filters';
 $string['clearfilterrow'] = 'Remove filter row';
 $string['clearfilters'] = 'Clear filters';
+$string['clearfilterselection'] = 'Remove "{$a}" from filter';
 $string['countparticipantsfound'] = '{$a} participants found';
+$string['filterrowlegend'] = 'Filter {$a}';
 $string['filtersetmatchdescription'] = 'How multiple filters should be combined';
 $string['match'] = 'Match';
 $string['matchofthefollowing'] = 'of the following:';
index a1ae1ac..6e2dd2d 100644 (file)
@@ -230,7 +230,12 @@ class behat_core_generator extends behat_generator_base {
                 'datagenerator' => 'setup_backpack_connected',
                 'required' => ['user', 'externalbackpack'],
                 'switchids' => ['user' => 'userid', 'externalbackpack' => 'externalbackpackid']
-            ]
+            ],
+            'last access times' => [
+                'datagenerator' => 'last_access_times',
+                'required' => ['user', 'course', 'lastaccess'],
+                'switchids' => ['user' => 'userid', 'course' => 'courseid'],
+            ],
         ];
     }
 
@@ -951,4 +956,100 @@ class behat_core_generator extends behat_generator_base {
         $backpack->externalbackpackid = $data['externalbackpackid'];
         $DB->insert_record('badge_backpack', $backpack);
     }
+
+    /**
+     * Creates user last access data within given courses.
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function process_last_access_times(array $data) {
+        global $DB;
+
+        if (!isset($data['userid'])) {
+            throw new Exception('\'last acces times\' requires the field \'user\' to be specified');
+        }
+
+        if (!isset($data['courseid'])) {
+            throw new Exception('\'last acces times\' requires the field \'course\' to be specified');
+        }
+
+        if (!isset($data['lastaccess'])) {
+            throw new Exception('\'last acces times\' requires the field \'lastaccess\' to be specified');
+        }
+
+        $userdata = [];
+        $userdata['old'] = $DB->get_record('user', ['id' => $data['userid']], 'firstaccess, lastaccess, lastlogin, currentlogin');
+        $userdata['new'] = [
+            'firstaccess' => $userdata['old']->firstaccess,
+            'lastaccess' => $userdata['old']->lastaccess,
+            'lastlogin' => $userdata['old']->lastlogin,
+            'currentlogin' => $userdata['old']->currentlogin,
+        ];
+
+        // Check for lastaccess data for this course.
+        $lastaccessdata = [
+            'userid' => $data['userid'],
+            'courseid' => $data['courseid'],
+        ];
+
+        $lastaccessid = $DB->get_field('user_lastaccess', 'id', $lastaccessdata);
+
+        $dbdata = (object) $lastaccessdata;
+        $dbdata->timeaccess = $data['lastaccess'];
+
+        // Set the course last access time.
+        if ($lastaccessid) {
+            $dbdata->id = $lastaccessid;
+            $DB->update_record('user_lastaccess', $dbdata);
+        } else {
+            $DB->insert_record('user_lastaccess', $dbdata);
+        }
+
+        // Store changes to other user access times as needed.
+
+        // Update first access if this is the user's first login, or this access is earlier than their current first access.
+        if (empty($userdata['new']['firstaccess']) ||
+                $userdata['new']['firstaccess'] > $data['lastaccess']) {
+            $userdata['new']['firstaccess'] = $data['lastaccess'];
+        }
+
+        // Update last access if it is the user's most recent access.
+        if (empty($userdata['new']['lastaccess']) ||
+                $userdata['new']['lastaccess'] < $data['lastaccess']) {
+            $userdata['new']['lastaccess'] = $data['lastaccess'];
+        }
+
+        // Update last and current login if it is the user's most recent access.
+        if (empty($userdata['new']['lastlogin']) ||
+                $userdata['new']['lastlogin'] < $data['lastaccess']) {
+            $userdata['new']['lastlogin'] = $data['lastaccess'];
+            $userdata['new']['currentlogin'] = $data['lastaccess'];
+        }
+
+        $updatedata = [];
+
+        if ($userdata['new']['firstaccess'] != $userdata['old']->firstaccess) {
+            $updatedata['firstaccess'] = $userdata['new']['firstaccess'];
+        }
+
+        if ($userdata['new']['lastaccess'] != $userdata['old']->lastaccess) {
+            $updatedata['lastaccess'] = $userdata['new']['lastaccess'];
+        }
+
+        if ($userdata['new']['lastlogin'] != $userdata['old']->lastlogin) {
+            $updatedata['lastlogin'] = $userdata['new']['lastlogin'];
+        }
+
+        if ($userdata['new']['currentlogin'] != $userdata['old']->currentlogin) {
+            $updatedata['currentlogin'] = $userdata['new']['currentlogin'];
+        }
+
+        // Only update user access data if there have been any changes.
+        if (!empty($updatedata)) {
+            $updatedata['id'] = $data['userid'];
+            $updatedata = (object) $updatedata;
+            $DB->update_record('user', $updatedata);
+        }
+    }
 }
index 40d99ac..7013287 100644 (file)
@@ -997,7 +997,7 @@ class core_user {
         // Core components that may want to define their preferences.
         // List of core components implementing callback is hardcoded here for performance reasons.
         // TODO MDL-58184 cache list of core components implementing a function.
-        $corecomponents = ['core_message', 'core_calendar'];
+        $corecomponents = ['core_message', 'core_calendar', 'core_contentbank'];
         foreach ($corecomponents as $component) {
             if (($pluginpreferences = component_callback($component, 'user_preferences')) && is_array($pluginpreferences)) {
                 $preferences += $pluginpreferences;
index faeb9ac..1327d93 100644 (file)
@@ -3559,3 +3559,267 @@ function cron_bc_hack_plugin_functions($plugintype, $plugins) {
 
     return $plugins;
 }
+
+/**
+ * Returns the SQL used by the participants table.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access, 0 means any time
+ * @param int $roleid The role id, 0 means all roles and -1 no roles
+ * @param int $enrolid The enrolment id, 0 means all enrolment methods will be returned.
+ * @param int $statusid The user enrolment status, -1 means all enrolments regardless of the status will be returned, if allowed.
+ * @param string|array $search The search that was performed, empty means perform no search
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @return array
+ */
+function user_get_participants_sql($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
+                                   $search = '', $additionalwhere = '', $additionalparams = array()) {
+    global $DB, $USER, $CFG;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                 'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    // Get the context.
+    $context = \context_course::instance($courseid, MUST_EXIST);
+
+    $isfrontpage = ($courseid == SITEID);
+
+    // Default filter settings. We only show active by default, especially if the user has no capability to review enrolments.
+    $onlyactive = true;
+    $onlysuspended = false;
+    if (has_capability('moodle/course:enrolreview', $context) && (has_capability('moodle/course:viewsuspendedusers', $context))) {
+        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;
+        }
+    }
+
+    list($esql, $params) = get_enrolled_sql($context, null, $groupid, $onlyactive, $onlysuspended, $enrolid);
+
+    $joins = array('FROM {user} u');
+    $wheres = array();
+
+    $userfields = get_extra_user_fields($context);
+    $userfieldssql = user_picture::fields('u', $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'] = $courseid;
+        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;
+
+    // Limit list to users with some role only.
+    if ($roleid) {
+        // We want to query both the current context and parent contexts.
+        list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($context->get_parent_context_ids(true),
+            SQL_PARAMS_NAMED, 'relatedctx');
+
+        // Get users without any role.
+        if ($roleid == -1) {
+            $wheres[] = "u.id NOT IN (SELECT userid FROM {role_assignments} WHERE contextid $relatedctxsql)";
+            $params = array_merge($params, $relatedctxparams);
+        } else {
+            $wheres[] = "u.id IN (SELECT userid FROM {role_assignments} WHERE roleid = :roleid AND contextid $relatedctxsql)";
+            $params = array_merge($params, array('roleid' => $roleid), $relatedctxparams);
+        }
+    }
+
+    if (!empty($search)) {
+        if (!is_array($search)) {
+            $search = [$search];
+        }
+        foreach ($search 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 = array();
+            // 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', $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', $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, $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%";
+        }
+    }
+
+    if (!empty($additionalwhere)) {
+        $wheres[] = $additionalwhere;
+        $params = array_merge($params, $additionalparams);
+    }
+
+    $from = implode("\n", $joins);
+    if ($wheres) {
+        $where = 'WHERE ' . implode(' AND ', $wheres);
+    } else {
+        $where = '';
+    }
+
+    return array($select, $from, $where, $params);
+}
+
+/**
+ * Returns the total number of participants for a given course.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access, 0 means any time
+ * @param int $roleid The role id, 0 means all roles
+ * @param int $enrolid The applied filter for the user enrolment ID.
+ * @param int $status The applied filter for the user's enrolment status.
+ * @param string|array $search The search that was performed, empty means perform no search
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @return int
+ */
+function user_get_total_participants($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
+                                     $search = '', $additionalwhere = '', $additionalparams = array()) {
+    global $DB;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                      'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
+        $statusid, $search, $additionalwhere, $additionalparams);
+
+    return $DB->count_records_sql("SELECT COUNT(u.id) $from $where", $params);
+}
+
+/**
+ * Returns the participants for a given course.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access
+ * @param int $roleid The role id
+ * @param int $enrolid The applied filter for the user enrolment ID.
+ * @param int $status The applied filter for the user's enrolment status.
+ * @param string $search The search that was performed
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @param string $sort The 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
+ */
+function user_get_participants($courseid, $groupid = 0, $accesssince, $roleid, $enrolid = 0, $statusid, $search,
+                               $additionalwhere = '', $additionalparams = array(), $sort = '', $limitfrom = 0, $limitnum = 0) {
+    global $DB;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                      'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
+        $statusid, $search, $additionalwhere, $additionalparams);
+
+    return $DB->get_recordset_sql("$select $from $where $sort", $params, $limitfrom, $limitnum);
+}
index dd4a16a..1f21f76 100644 (file)
@@ -449,6 +449,41 @@ class grade_item extends grade_object {
         return true;
     }
 
+    /**
+     * Duplicate grade item.
+     *
+     * @return grade_item The duplicate grade item
+     */
+    public function duplicate() {
+        // Convert current object to array.
+        $copy = (array) $this;
+
+        if (empty($copy["id"])) {
+            throw new moodle_exception('invalidgradeitemid');
+        }
+
+        // Remove fields that will be either unique or automatically filled.
+        $removekeys = array();
+        $removekeys[] = 'id';
+        $removekeys[] = 'idnumber';
+        $removekeys[] = 'timecreated';
+        $removekeys[] = 'sortorder';
+        foreach ($removekeys as $key) {
+            unset($copy[$key]);
+        }
+
+        // Addendum to name.
+        $copy["itemname"] = get_string('duplicatedgradeitem', 'grades', $copy["itemname"]);
+
+        // Create new grade item.
+        $gradeitem = new grade_item($copy);
+
+        // Insert grade item into database.
+        $gradeitem->insert();
+
+        return $gradeitem;
+    }
+
     /**
      * In addition to perform parent::insert(), calls force_regrading() method too.
      *
index a420ceb..5c0b46b 100644 (file)
@@ -1050,4 +1050,78 @@ class core_grade_item_testcase extends grade_base_testcase {
         $this->assertEquals($gradeitem->itemmodule, $event->other['itemmodule']);
         $this->assertEquals('updatedname', $event->other['itemname']);
     }
+
+
+    /**
+     * Test grade item duplication expecting success.
+     */
+    public function test_grade_duplicate_grade_item_success() {
+        $cat = new grade_category();
+        $cat->courseid = $this->courseid;
+        $cat->fullname = 'Grade category';
+        $cat->insert();
+
+        // Method exists.
+        $gi = new grade_item();
+        $this->assertTrue(method_exists($gi, 'duplicate'));
+
+        // Grade item is inserted and valid for duplication.
+        $gi->courseid = $this->courseid;
+        $gi->categoryid = $cat->id;
+        $gi->itemtype = 'manual';
+        $gi->itemname = 'Grade Item 1';
+        $gi->idnumber = '1000';
+        $gi->insert();
+        $gi2 = $gi->duplicate();
+
+        $this->assertEquals($gi->courseid, $gi2->courseid);
+        $this->assertEquals($gi->categoryid, $gi2->categoryid);
+        $this->assertEquals($gi->itemtype, $gi2->itemtype);
+        $this->assertEquals($gi->gradetype, $gi2->gradetype);
+        $this->assertEquals($gi->grademax, $gi2->grademax);
+        $this->assertEquals($gi->grademin, $gi2->grademin);
+        $this->assertEquals($gi->gradepass, $gi2->gradepass);
+        $this->assertEquals($gi->display, $gi2->display);
+        $this->assertEquals($gi->decimals, $gi2->decimals);
+        $this->assertEquals($gi->hidden, $gi2->hidden);
+        $this->assertEquals($gi->weightoverride, $gi2->weightoverride);
+
+        $this->assertNotEquals($gi->id, $gi2->id);
+        $this->assertNotEquals($gi->idnumber, $gi2->idnumber);
+        $this->assertNotEquals($gi->sortorder, $gi2->sortorder);
+        $this->assertNotEquals($gi->itemname, $gi2->itemname);
+    }
+
+    /**
+     * Test grade item duplication exception expected with incomplete grade item.
+     */
+    public function test_grade_duplicate_grade_item_incomplete() {
+        // Grade item is not valid because it is empty.
+        $gi = new grade_item();
+        $gi->courseid = $this->courseid;
+        $this->expectException("moodle_exception");
+        $gi2 = $gi->duplicate();
+    }
+
+    /**
+     * Test grade item duplication exception expected because item must be in db.
+     */
+    public function test_grade_duplicate_grade_item_not_in_db() {
+        $cat = new grade_category();
+        $cat->courseid = $this->courseid;
+        $cat->fullname = 'Grade category';
+        $cat->insert();
+
+        // Grade item is valid for insertion but is not inserted into db.
+        // Duplicate method throws an exception.
+        $gi = new grade_item();
+        $gi->courseid = $this->courseid;
+        $gi->categoryid = $cat->id;
+        $gi->itemtype = 'manual';
+        $gi->itemname = 'Grade Item 1';
+        $gi->idnumber = '1000';
+
+        $this->expectException("moodle_exception");
+        $gi2 = $gi->duplicate();
+    }
 }
index 6c81e34..e08b3d3 100644 (file)
@@ -2765,6 +2765,12 @@ class html_table {
 
     /**
      * @var string Description of the contents for screen readers.
+     *
+     * The "summary" attribute on the "table" element is not supported in HTML5.
+     * Consider describing the structure of the table in a "caption" element or in a "figure" element containing the table;
+     * or, simplify the structure of the table so that no description is needed.
+     *
+     * @deprecated since Moodle 3.9.
      */
     public $summary;
 
index f4b2bdc..da081b3 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 822e013..1609714 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index 235dd5e..10abe96 100644 (file)
@@ -158,6 +158,18 @@ export const updateTable = (tableRoot, {
     }
 };
 
+/**
+ * Get the table dataset for the specified tableRoot, ensuring that the provided table is a dynamic table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {DOMStringMap}
+ */
+const getTableData = tableRoot => {
+    checkTableIsDynamic(tableRoot);
+
+    return tableRoot.dataset;
+};
+
 /**
  * Update the specified table using the new filters.
  *
@@ -169,6 +181,18 @@ export const updateTable = (tableRoot, {
 export const setFilters = (tableRoot, filters, refreshContent = true) =>
     updateTable(tableRoot, {filters}, refreshContent);
 
+/**
+ * Get the filter data for the specified table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Object}
+ */
+export const getFilters = tableRoot => {
+    checkTableIsDynamic(tableRoot);
+
+    return getFiltersetFromTable(tableRoot);
+};
+
 /**
  * Update the sort order.
  *
@@ -192,6 +216,14 @@ export const setSortOrder = (tableRoot, sortBy, sortOrder, refreshContent = true
 export const setPageNumber = (tableRoot, pageNumber, refreshContent = true) =>
     updateTable(tableRoot, {pageNumber}, refreshContent);
 
+/**
+ * Get the current page number.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Number}
+ */
+export const getPageNumber = tableRoot => getTableData(tableRoot).tablePageNumber;
+
 /**
  * Set the page size.
  *
@@ -203,6 +235,14 @@ export const setPageNumber = (tableRoot, pageNumber, refreshContent = true) =>
 export const setPageSize = (tableRoot, pageSize, refreshContent = true) =>
     updateTable(tableRoot, {pageSize, pageNumber: 0}, refreshContent);
 
+/**
+ * Get the current page size.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Number}
+ */
+export const getPageSize = tableRoot => getTableData(tableRoot).tablePageSize;
+
 /**
  * Update the first initial to show.
  *
@@ -214,6 +254,14 @@ export const setPageSize = (tableRoot, pageSize, refreshContent = true) =>
 export const setFirstInitial = (tableRoot, firstInitial, refreshContent = true) =>
     updateTable(tableRoot, {firstInitial}, refreshContent);
 
+/**
+ * Get the current first initial filter.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {String}
+ */
+export const getFirstInitial = tableRoot => getTableData(tableRoot).tableFirstInitial;
+
 /**
  * Update the last initial to show.
  *
@@ -225,6 +273,14 @@ export const setFirstInitial = (tableRoot, firstInitial, refreshContent = true)
 export const setLastInitial = (tableRoot, lastInitial, refreshContent = true) =>
     updateTable(tableRoot, {lastInitial}, refreshContent);
 
+/**
+ * Get the current last initial filter.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {String}
+ */
+export const getLastInitial = tableRoot => getTableData(tableRoot).tableLastInitial;
+
 /**
  * Hide a column in the participants table.
  *
index e6fc650..eda6039 100644 (file)
@@ -41,11 +41,8 @@ use Iterator;
  */
 class filter implements Countable, Iterator, JsonSerializable {
 
-    /**
-     * @var in The default filter type (ALL)
-     * Note: This is for backwards compatibility with the old UI behaviour and will be set to JOINTYPE_ANY as part of MDL-68612.
-     */
-    const JOINTYPE_DEFAULT = 2;
+    /** @var in The default filter type (ANY) */
+    const JOINTYPE_DEFAULT = 1;
 
     /** @var int None of the following match */
     const JOINTYPE_NONE = 0;
index d04eaef..0afddeb 100644 (file)
@@ -40,11 +40,8 @@ use moodle_exception;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 abstract class filterset implements JsonSerializable {
-    /**
-     * @var in The default filter type (ALL)
-     * Note: This is for backwards compatibility with the old UI behaviour and will be set to JOINTYPE_ANY as part of MDL-68612.
-     */
-    const JOINTYPE_DEFAULT = 2;
+    /** @var in The default filter type (ANY) */
+    const JOINTYPE_DEFAULT = 1;
 
     /** @var int None of the following match */
     const JOINTYPE_NONE = 0;
diff --git a/lib/templates/campaign_content.mustache b/lib/templates/campaign_content.mustache
new file mode 100644 (file)
index 0000000..dbba382
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    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/campaign_content
+
+    Moodle campaign content template.
+
+    The purpose of this template is to render an iframe that contains campaign content.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * lang User's language.
+
+    Example context (json):
+    { "lang": "en"}
+}}
+<div class="alert alert-secondary alert-block fade in alert-dismissible">
+    <button type="button" class="close" data-dismiss="alert">&times;</button>
+    <iframe id="campaign-content" class="w-100 border-0"></iframe>
+</div>
+{{#js}}
+(function() {
+    var iframe = document.getElementById('campaign-content');
+    iframe.src = 'https://campaign.moodle.org/current/lms/{{lang}}/';
+    window.addEventListener('message', function (event) {
+        if (event.origin === 'https://campaign.moodle.org') {
+            iframe.style.height = event.data + 'px';
+        }
+    });
+})();
+{{/js}}
index d207c37..5c728cc 100644 (file)
            href="#"
            title="{{#str}} markallread {{/str}}"
            data-action="mark-all-read"
-           role="button">
-            <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
+           role="button"
+           aria-label="{{#str}} markallread {{/str}}">
+            <span class="normal-icon">{{#pix}} t/markasread, core {{/pix}}</span>
             {{> core/loading }}
         </a>
         <a href="{{{urls.preferences}}}"
-           title="{{#str}} notificationpreferences, message {{/str}}">
-            {{#pix}} i/settings, core, {{#str}} notificationpreferences, message {{/str}} {{/pix}}
+           title="{{#str}} notificationpreferences, message {{/str}}"
+           aria-label="{{#str}} notificationpreferences, message {{/str}}">
+            {{#pix}} i/settings, core {{/pix}}
         </a>
     {{/headeractions}}
 
index d6b2c44..9814c6b 100644 (file)
@@ -64,15 +64,24 @@ class edit_renderer extends \plugin_renderer_base {
         $output .= $this->quiz_state_warnings($structure);
 
         $output .= html_writer::start_div('mod_quiz-edit-top-controls');
+
+        $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1');
+        $output .= html_writer::start_div('d-flex flex-column justify-content-around');
         $output .= $this->quiz_information($structure);
+        $output .= html_writer::end_tag('div');
         $output .= $this->maximum_grade_input($structure, $pageurl);
+        $output .= html_writer::end_tag('div');
 
+        $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1');
         $output .= html_writer::start_div('mod_quiz-edit-action-buttons btn-group edit-toolbar', ['role' => 'group']);
         $output .= $this->repaginate_button($structure, $pageurl);
         $output .= $this->selectmultiple_button($structure);
         $output .= html_writer::end_tag('div');
 
+        $output .= html_writer::start_div('d-flex flex-column justify-content-around');
         $output .= $this->total_marks($quizobj->get_quiz());
+        $output .= html_writer::end_tag('div');
+        $output .= html_writer::end_tag('div');
 
         $output .= $this->selectmultiple_controls($structure);
         $output .= html_writer::end_tag('div');
@@ -204,7 +213,7 @@ class edit_renderer extends \plugin_renderer_base {
             'name'  => 'repaginate',
             'id'    => 'repaginatecommand',
             'value' => get_string('repaginatecommand', 'quiz'),
-            'class' => 'btn btn-secondary mb-1',
+            'class' => 'btn btn-secondary',
             'data-header' => $header,
             'data-form'   => $form,
         );
@@ -229,7 +238,7 @@ class edit_renderer extends \plugin_renderer_base {
             'name'  => 'selectmultiple',
             'id'    => 'selectmultiplecommand',
             'value' => get_string('selectmultipleitems', 'quiz'),
-            'class' => 'btn btn-secondary mb-1'
+            'class' => 'btn btn-secondary'
         );
         if (!$structure->can_be_edited()) {
             $buttonoptions['disabled'] = 'disabled';
index 20a3811..474e202 100644 (file)
@@ -617,10 +617,6 @@ table.quizreviewsummary td.cell {
 }
 
 /** Mod quiz edit **/
-#page-mod-quiz-edit .statusbar {
-    margin: 0.6em 0.4em;
-}
-
 #page-mod-quiz-edit .statusdisplay {
     background-color: #ffc;
     clear: both;
@@ -640,16 +636,6 @@ table.quizreviewsummary td.cell {
     min-height: 2.85em;
 }
 
-@media (min-width: 576px) {
-    #page-mod-quiz-edit .maxgrade,
-    #page-mod-quiz-edit .totalpoints {
-        position: absolute;
-        right: 0;
-        margin: -2.85em 0 0;
-        padding: .2em;
-    }
-}
-
 @media (max-width: 576px) {
     #page-mod-quiz-edit .maxgrade {
         margin-bottom: 0.6em;
@@ -889,15 +875,13 @@ table.quizreviewsummary td.cell {
 }
 
 #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance {
-    display: block;
+    display: flex;
+    flex: 1 1 auto;
     min-height: 1.7em;
-    position: absolute;
-    top: 0;
-    left: 5em;
-    width: 100%;
 }
 
 #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer {
+    display: flex;
     padding-left: 22px;
 }
 
@@ -914,7 +898,6 @@ table.quizreviewsummary td.cell {
     white-space: nowrap;
     text-overflow: ellipsis;
     overflow: hidden;
-    width: 70%;
     display: inline-block;
     height: 20px;
 }
@@ -927,6 +910,9 @@ table.quizreviewsummary td.cell {
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questionname {
     font-weight: bold;
     color: #555;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
 }
 
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questiontext {
@@ -941,6 +927,10 @@ table.quizreviewsummary td.cell {
 
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .mod_quiz_random_qbank_link {
     font-size: 0.8em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    margin-left: 0.25rem;
 }
 
 #page-mod-quiz-edit ul.slots .activityinstance img.activityicon {
@@ -950,6 +940,7 @@ table.quizreviewsummary td.cell {
 }
 
 #page-mod-quiz-edit .section .activity .actions {
+    position: inherit;
     white-space: nowrap;
     background: #e6e6e6;
     padding: 0.1em 0;
@@ -1219,8 +1210,10 @@ table#categoryquestions {
     #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance {
         top: -30px;
         left: 0;
-    }
-    #page-mod-quiz-edit ul.slots .activityinstance span.instancename {
+        padding-right: 0;
+        overflow: hidden;
+        align-items: center;
+        position: absolute;
         width: 100%;
     }
 }
index fd0340d..ba9d231 100644 (file)
@@ -286,7 +286,6 @@ class moodle_content_writer implements content_writer {
         // This weird code is to look for a subcontext that contains a number and append an '_' to the front.
         // This is because there seems to be some weird problem with array_merge_recursive used in finalise_content().
         $subcontext = array_map(function($data) {
-            $data = clean_param($data, PARAM_PATH);
             if (stripos($data, DIRECTORY_SEPARATOR) !== false) {
                 $newpath = explode(DIRECTORY_SEPARATOR, $data);
                 $newpath = array_map(function($value) {
@@ -295,11 +294,18 @@ class moodle_content_writer implements content_writer {
                     }
                     return $value;
                 }, $newpath);
-                return implode(DIRECTORY_SEPARATOR, $newpath);
+                $data = implode(DIRECTORY_SEPARATOR, $newpath);
             } else if (is_numeric($data)) {
                 $data = '_' . $data;
             }
-            return $data;
+            // Because clean_param() normalises separators to forward-slashes
+            // and because there is code DIRECTORY_SEPARATOR dependent after
+            // this array_map(), we ensure we get the original separator.
+            // Note that maybe we could leave the clean_param() alone, but
+            // surely that means that the DIRECTORY_SEPARATOR dependent
+            // code is not needed at all. So better keep existing behavior
+            // until this is revisited.
+            return str_replace('/', DIRECTORY_SEPARATOR, clean_param($data, PARAM_PATH));
         }, $subcontext);
 
         // Combine the context path, and the subcontext data.
index 2bfa166..cc1ed11 100644 (file)
@@ -48,13 +48,7 @@ $gototop-bottom-position: 50px !default;
     opacity: 0;
     transition: opacity .7s ease 0s, visibility .1s ease .8s;
     display: block;
-    position: fixed; /* IE compatibility hack */
-    @supports (position: sticky) {
-        position: sticky;
-    }
-    @supports (-ms-ime-align:auto) {
-        position: fixed; /* Edge compatibility hack */
-    }
+    position: fixed;
     bottom: $gototop-bottom-position;
     right: 0;
     a {
index 38ac391..4ed5c9a 100644 (file)
@@ -424,6 +424,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 #page-mod-quiz-edit ul.slots .activityinstance {
     > a {
         display: flex;
+        max-width: 100%;
         align-items: center;
         text-indent: 0;
         padding-left: 0;
index 5dda75a..e7e4ed0 100644 (file)
@@ -9663,16 +9663,8 @@ input[disabled] {
   transition: opacity .7s ease 0s, visibility .1s ease .8s;
   display: block;
   position: fixed;
-  /* IE compatibility hack */
   bottom: 50px;
   right: 0; }
-  @supports (position: sticky) {
-    #goto-top-link {
-      position: sticky; } }
-  @supports (-ms-ime-align: auto) {
-    #goto-top-link {
-      position: fixed;
-      /* Edge compatibility hack */ } }
   #goto-top-link a {
     position: absolute;
     right: 0;
@@ -15678,6 +15670,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
+  max-width: 100%;
   align-items: center;
   text-indent: 0;
   padding-left: 0; }
index ab07c5f..97ff4c0 100644 (file)
@@ -9868,16 +9868,8 @@ input[disabled] {
   transition: opacity .7s ease 0s, visibility .1s ease .8s;
   display: block;
   position: fixed;
-  /* IE compatibility hack */
   bottom: 50px;
   right: 0; }
-  @supports (position: sticky) {
-    #goto-top-link {
-      position: sticky; } }
-  @supports (-ms-ime-align: auto) {
-    #goto-top-link {
-      position: fixed;
-      /* Edge compatibility hack */ } }
   #goto-top-link a {
     position: absolute;
     right: 0;
@@ -15901,6 +15893,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
+  max-width: 100%;
   align-items: center;
   text-indent: 0;
   padding-left: 0; }
index 3ef1d57..0f13ff2 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filter.min.js and b/user/amd/build/local/participantsfilter/filter.min.js differ
index e694bbf..1cc0896 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filter.min.js.map and b/user/amd/build/local/participantsfilter/filter.min.js.map differ
index 4e178f8..b9b0dda 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js differ
index 0c000b7..9a66bfa 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map differ
index 21be665..69b63a7 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/selectors.min.js and b/user/amd/build/local/participantsfilter/selectors.min.js differ
index 023c1a6..15c6dd7 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/selectors.min.js.map and b/user/amd/build/local/participantsfilter/selectors.min.js.map differ
index bbb20c6..1176f2b 100644 (file)
Binary files a/user/amd/build/participantsfilter.min.js and b/user/amd/build/participantsfilter.min.js differ
index 214f7dc..b4c9f89 100644 (file)
Binary files a/user/amd/build/participantsfilter.min.js.map and b/user/amd/build/participantsfilter.min.js.map differ
index fee4c4f..2eef9fc 100644 (file)
Binary files a/user/amd/build/unified_filter.min.js.map and b/user/amd/build/unified_filter.min.js.map differ
index ebe12c4..3ac811e 100644 (file)
Binary files a/user/amd/build/unified_filter_datasource.min.js.map and b/user/amd/build/unified_filter_datasource.min.js.map differ
index 7895ca0..6670c2e 100644 (file)
@@ -44,12 +44,13 @@ export default class {
      *
      * @param {String} filterType The type of filter that this relates to
      * @param {HTMLElement} rootNode The root node for the participants filterset
+     * @param {Array} initialValues The initial values for the selector
      */
-    constructor(filterType, rootNode) {
+    constructor(filterType, rootNode, initialValues) {
         this.filterType = filterType;
         this.rootNode = rootNode;
 
-        this.addValueSelector();
+        this.addValueSelector(initialValues);
     }
 
     /**
@@ -79,8 +80,10 @@ export default class {
 
     /**
      * Add the value selector to the filter row.
+     *
+     * @param {Array} initialValues
      */
-    async addValueSelector() {
+    async addValueSelector(initialValues = []) {
         const filterValueNode = this.getFilterValueNode();
 
         // Copy the data in place.
@@ -88,6 +91,21 @@ export default class {
 
         const dataSource = filterValueNode.querySelector('select');
 
+        // If there are any initial values then attempt to apply them.
+        initialValues.forEach(filterValue => {
+            let selectedOption = dataSource.querySelector(`option[value="${filterValue}"]`);
+            if (selectedOption) {
+                selectedOption.selected = true;
+            } else if (!this.showSuggestions) {
+                selectedOption = document.createElement('option');
+                selectedOption.value = filterValue;
+                selectedOption.innerHTML = filterValue;
+                selectedOption.selected = true;
+
+                dataSource.append(selectedOption);
+            }
+        });
+
         Autocomplete.enhance(
             // The source select element.
             dataSource,
index c7b7872..a1aa479 100644 (file)
@@ -25,10 +25,6 @@ import Filter from '../filter';
 import {get_string as getString} from 'core/str';
 
 export default class extends Filter {
-    constructor(filterType, filterSet) {
-        super(filterType, filterSet);
-    }
-
     /**
      * For keywords the final value is an Array of strings.
      *
index 3d0ef29..27c9419 100644 (file)
@@ -63,5 +63,6 @@ export default {
             all: `${getFilterRegion('filtertypedata')} [data-field-name]`,
         },
         typeList: getFilterRegion('filtertypelist'),
+        typeListSelect: `select${getFilterRegion('filtertypelist')}`,
     },
 };
index 3dcdb88..718e5ab 100644 (file)
@@ -25,6 +25,7 @@
 import CourseFilter from './local/participantsfilter/filtertypes/courseid';
 import * as DynamicTable from 'core_table/dynamic';
 import GenericFilter from './local/participantsfilter/filter';
+import {get_strings as getStrings} from 'core/str';
 import Notification from 'core/notification';
 import Selectors from './local/participantsfilter/selectors';
 import Templates from 'core/templates';
@@ -56,7 +57,8 @@ export const init = participantsRegionId => {
      * @return {Promise}
      */
     const addFilterRow = () => {
-        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
         .then(({html, js}) => {
             const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
 
@@ -104,8 +106,10 @@ export const init = participantsRegionId => {
      *
      * @param {HTMLElement} filterRow
      * @param {String} filterType
+     * @param {Array} initialFilterValues The initially selected values for the filter
+     * @returns {Filter}
      */
-    const addFilter = async(filterRow, filterType) => {
+    const addFilter = async(filterRow, filterType, initialFilterValues) => {
         // Name the filter on the filter row.
         filterRow.dataset.filterType = filterType;
 
@@ -116,14 +120,17 @@ export const init = participantsRegionId => {
         if (filterDataNode.dataset.filterTypeClass) {
             Filter = await import(filterDataNode.dataset.filterTypeClass);
         }
-        activeFilters[filterType] = new Filter(filterType, filterSet);
+        activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
 
         // Disable the select.
         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
+        typeField.value = filterType;
         typeField.disabled = 'disabled';
 
         // Update the list of available filter types.
         updateFiltersOptions();
+
+        return activeFilters[filterType];
     };
 
     /**
@@ -157,31 +164,40 @@ export const init = participantsRegionId => {
      *
      * @param {HTMLElement} filterRow
      */
-    const removeFilterRow = filterRow => {
+    const removeFilterRow = async filterRow => {
         // Remove the filter object.
         removeFilterObject(filterRow.dataset.filterType);
 
         // Remove the actual filter HTML.
         filterRow.remove();
 
+        // Update the list of available filter types.
+        updateFiltersOptions();
+
         // Refresh the table.
         updateTableFromFilter();
 
-        // Update the list of available filter types.
-        updateFiltersOptions();
+        // Update filter fieldset legends.
+        const filterLegends = await getAvailableFilterLegends();
+
+        getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
+            filterRow.querySelector('legend').innerText = filterLegends[index];
+        });
+
     };
 
     /**
      * Replace the specified filter row with a new one.
      *
      * @param {HTMLElement} filterRow
+     * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
      * @return {Promise}
      */
-    const replaceFilterRow = filterRow => {
+    const replaceFilterRow = (filterRow, rowNum = 1) => {
         // Remove the filter object.
         removeFilterObject(filterRow.dataset.filterType);
 
-        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
         .then(({html, js}) => {
             const newContentNodes = Templates.replaceNode(filterRow, html, js);
 
@@ -237,15 +253,28 @@ export const init = participantsRegionId => {
 
     /**
      * Remove all filters.
+     *
+     * @returns {Promise}
      */
-    const removeAllFilters = async() => {
+    const removeAllFilters = () => {
         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
-        filters.forEach((filterRow) => {
-            removeOrReplaceFilterRow(filterRow);
-        });
+        filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow));
 
         // Refresh the table.
-        updateTableFromFilter();
+        return updateTableFromFilter();
+    };
+
+    /**
+     * Remove any empty filters.
+     */
+    const removeEmptyFilters = () => {
+        const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+        filters.forEach(filterRow => {
+            const filterType = filterRow.querySelector(Selectors.filter.fields.type);
+            if (!filterType.value) {
+                removeOrReplaceFilterRow(filterRow);
+            }
+        });
     };
 
     /**
@@ -287,6 +316,49 @@ export const init = participantsRegionId => {
         }
     };
 
+    /**
+     * Set the current filter options based on a provided configuration.
+     *
+     * @param {Object} config
+     * @param {Number} config.jointype
+     * @param {Object} config.filters
+     */
+    const setFilterFromConfig = config => {
+        const filterConfig = Object.entries(config.filters);
+
+        if (!filterConfig.length) {
+            // There are no filters to set from.
+            return;
+        }
+
+        // Set the main join type.
+        filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
+
+        const filterPromises = filterConfig.map(([filterType, filterData]) => {
+            if (filterType === 'courseid') {
+                // The courseid is a special case.
+                return Promise.resolve();
+            }
+
+            const filterValues = filterData.values;
+
+            if (!filterValues.length) {
+                // There are no values for this filter.
+                // Skip it.
+                return Promise.resolve();
+            }
+
+            return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
+        });
+
+        Promise.all(filterPromises).then(() => {
+            return removeEmptyFilters();
+        })
+        .then(updateFiltersOptions)
+        .then(updateTableFromFilter)
+        .catch();
+    };
+
     /**
      * Update the Dynamic table based upon the current filter.
      *
@@ -302,6 +374,33 @@ export const init = participantsRegionId => {
         );
     };
 
+    /**
+     * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
+     *
+     * @return {array}
+     */
+    const getAvailableFilterLegends = async() => {
+        const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
+        let requests = [];
+
+        [...Array(maxFilters)].forEach((_, rowIndex) => {
+            requests.push({
+                "key": "filterrowlegend",
+                "component": "core_user",
+                // Add 1 since rows begin at 1 (index begins at zero).
+                "param": rowIndex + 1
+            });
+        });
+
+        const legendStrings = await getStrings(requests)
+        .then(fetchedStrings => {
+            return fetchedStrings;
+        })
+        .catch(Notification.exception);
+
+        return legendStrings;
+    };
+
     // Add listeners for the main actions.
     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
         if (e.target.closest(Selectors.filterset.actions.addRow)) {
@@ -345,4 +444,11 @@ export const init = participantsRegionId => {
     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
         filterSet.dataset.filterverb = e.target.value;
     });
+
+    const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
+    const initialFilters = DynamicTable.getFilters(tableRoot);
+    if (initialFilters) {
+        // Apply the initial filter configuration.
+        setFilterFromConfig(initialFilters);
+    }
 };
index a18d2fd..9248710 100644 (file)
@@ -16,6 +16,7 @@
 /**
  * Unified filter page JS module for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
  * @module     core_user/unified_filter
  * @package    core_user
  * @copyright  2017 Jun Pataleta
@@ -37,6 +38,7 @@ define(['jquery', 'core/form-autocomplete', 'core/str', 'core/notification'],
     /**
      * Init function.
      *
+     * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
      * @method init
      * @private
      */
@@ -117,6 +119,7 @@ define(['jquery', 'core/form-autocomplete', 'core/str', 'core/notification'],
     /**
      * Return the unified user filter form.
      *
+     * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
      * @method getForm
      * @return {DOMElement}
      */
index 60700ac..fdbb206 100644 (file)
@@ -15,6 +15,7 @@
 
 /**
  * Datasource for the core_user/unified_filter.
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
  *
  * This module is compatible with core/form-autocomplete.
  *
index c98fccb..443c5a2 100644 (file)
@@ -210,7 +210,8 @@ class participants_filter implements renderable, templatable {
             $groups = groups_get_all_groups($this->course->id, $USER->id);
         }
 
-        if (empty($groups)) {
+        // Return no data if no groups found (which includes if the only value is 'No group').
+        if (empty($groups) || (count($groups) === 1 && array_key_exists(-1, $groups))) {
             return null;
         }
 
@@ -349,6 +350,7 @@ class participants_filter implements renderable, templatable {
             'tableregionid' => $this->tableregionid,
             'courseid' => $this->context->instanceid,
             'filtertypes' => $this->get_filtertypes(),
+            'rownumber' => 1,
         ];
 
         return $data;
index 849a986..6a05f86 100644 (file)
@@ -17,6 +17,7 @@
 /**
  * Class containing the filter options data for rendering the unified filter autocomplete element for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - Please use \core_user\table\participants_search::class and table filtersets instead.
  * @package    core_user
  * @copyright  2017 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -34,8 +35,10 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * Class containing the filter options data for rendering the unified filter autocomplete element for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - Please use \core_user\table\participants_search::class and table filtersets instead.
  * @copyright  2017 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
  */
 class unified_filter implements renderable, templatable {
 
@@ -56,6 +59,10 @@ class unified_filter implements renderable, templatable {
      * @param string|moodle_url $baseurl The url with params needed to call up this page.
      */
     public function __construct($filteroptions, $selectedoptions, $baseurl = null) {
+        $deprecatedtext = __CLASS__ . ' class is deprecated. Please use \core\table\participants_search::class' .
+                                      ' with table filtersets instead.';
+        debugging($deprecatedtext, DEBUG_DEVELOPER);
+
         $this->filteroptions = $filteroptions;
         $this->selectedoptions = $selectedoptions;
         if (!empty($baseurl)) {
index d9aaccf..0d94483 100644 (file)
@@ -314,6 +314,8 @@ class participants_search {
      * @return array SQL query data in the format ['sql' => '', 'forcedsql' => '', 'params' => []].
      */
     protected function get_enrolled_sql(): array {
+        global $USER;
+
         $isfrontpage = ($this->context->instanceid == SITEID);
         $prefix = 'eu_';
         $filteruid = "{$prefix}u.id";
@@ -357,15 +359,43 @@ class participants_search {
             $params = array_merge($params, $methodparams, $statusparams);
         }
 
-        // Prepare any groups filtering.
         $groupids = [];
 
         if ($this->filterset->has_filter('groups')) {
             $groupids = $this->filterset->get_filter('groups')->get_filter_values();
         }
 
+        // Force additional groups filtering if required due to lack of capabilities.
+        // Note: This means results will always be limited to allowed groups, even if the user applies their own groups filtering.
+        $canaccessallgroups = has_capability('moodle/site:accessallgroups', $this->context);
+        $forcegroups = ($this->course->groupmode == SEPARATEGROUPS && !$canaccessallgroups);
+
+        if ($forcegroups) {
+            $allowedgroupids = array_keys(groups_get_all_groups($this->course->id, $USER->id));
+
+            // Users not in any group in a course with separate groups mode should not be able to access the participants filter.
+            if (empty($allowedgroupids)) {
+                // The UI does not support this, so it should not be reachable unless someone is trying to bypass the restriction.
+                throw new \coding_exception('User must be part of a group to filter by participants.');
+            }
+
+            $forceduid = "{$forcedprefix}u.id";
+            $forcedjointype = $this->get_groups_jointype(\core_table\local\filter\filter::JOINTYPE_ANY);
+            $forcedgroupjoin = groups_get_members_join($allowedgroupids, $forceduid, $this->context, $forcedjointype);
+
+            $forcedjoins[] = $forcedgroupjoin->joins;
+            $forcedwhere .= "AND ({$forcedgroupjoin->wheres})";
+
+            $params = array_merge($params, $forcedgroupjoin->params);
+
+            // Remove any filtered groups the user does not have access to.
+            $groupids = array_intersect($allowedgroupids, $groupids);
+        }
+
+        // Prepare any user defined groups filtering.
         if ($groupids) {
             $groupjoin = groups_get_members_join($groupids, $filteruid, $this->context, $this->get_groups_jointype());
+
             $joins[] = $groupjoin->joins;
             $params = array_merge($params, $groupjoin->params);
             if (!empty($groupjoin->wheres)) {
@@ -685,12 +715,28 @@ class participants_search {
      * Fetch the groups filter's grouplib jointype, based on its filterset jointype.
      * This mapping is to ensure compatibility between the two, should their values ever differ.
      *
+     * @param int|null $forcedjointype If set, specifies the join type to fetch mapping for (used when applying forced filtering).
+     *                            If null, then user defined filter join type is used.
      * @return int
      */
-    protected function get_groups_jointype(): int {
+    protected function get_groups_jointype(?int $forcedjointype = null): int {
+
+        // If applying forced groups filter and no manual groups filtering is applied, add an empty filter so we can map the join.
+        if (!is_null($forcedjointype) && !$this->filterset->has_filter('groups')) {
+            $this->filterset->add_filter(new \core_table\local\filter\integer_filter('groups'));
+        }
+
         $groupsfilter = $this->filterset->get_filter('groups');
 
-        switch ($groupsfilter->get_join_type()) {
+        if (is_null($forcedjointype)) {
+            // Fetch join type mapping for a user supplied groups filtering.
+            $filterjointype = $groupsfilter->get_join_type();
+        } else {
+            // Fetch join type mapping for forced groups filtering.
+            $filterjointype = $forcedjointype;
+        }
+
+        switch ($filterjointype) {
             case $groupsfilter::JOINTYPE_NONE:
                 $groupsjoin = GROUPS_JOIN_NONE;
                 break;
index 08169f4..29f7949 100644 (file)
@@ -43,7 +43,7 @@ $contextid    = optional_param('contextid', 0, PARAM_INT); // One of this or.
 $courseid     = optional_param('id', 0, PARAM_INT); // This are required.
 $newcourse    = optional_param('newcourse', false, PARAM_BOOL);
 $roleid       = optional_param('roleid', 0, PARAM_INT);
-$groupparam   = optional_param('group', 0, PARAM_INT);
+$urlgroupid   = optional_param('group', 0, PARAM_INT);
 
 $PAGE->set_url('/user/index.php', array(
         'page' => $page,
@@ -102,137 +102,47 @@ if ($node) {
 echo $OUTPUT->header();
 echo $OUTPUT->heading(get_string('participants'));
 
-// Get the currently applied filters.
-$filtersapplied = optional_param_array('unified-filters', [], PARAM_NOTAGS);
-$filterwassubmitted = optional_param('unified-filter-submitted', 0, PARAM_BOOL);
-
-// If they passed a role make sure they can view that role.
-if ($roleid) {
-    $viewableroles = get_profile_roles($context);
+$filterset = new \core_user\table\participants_filterset();
+$filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
 
-    // Check if the user can view this role.
-    if (array_key_exists($roleid, $viewableroles)) {
-        $filtersapplied[] = USER_FILTER_ROLE . ':' . $roleid;
-    } else {
-        $roleid = 0;
-    }
-}
+$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
 
-// Default group ID.
-$groupid = false;
 $canaccessallgroups = has_capability('moodle/site:accessallgroups', $context);
-if ($course->groupmode != NOGROUPS) {
-    if ($canaccessallgroups) {
-        // Change the group if the user can access all groups and has specified group in the URL.
-        if ($groupparam) {
-            $groupid = $groupparam;
-        }
-    } else {
-        // Otherwise, get the user's default group.
-        $groupid = groups_get_course_group($course, true);
-        if ($course->groupmode == SEPARATEGROUPS && !$groupid) {
-            // The user is not in the group so show message and exit.
-            echo $OUTPUT->notification(get_string('notingroup'));
-            echo $OUTPUT->footer();
-            exit;
-        }
-    }
-}
-$hasgroupfilter = false;
-$lastaccess = 0;
-$searchkeywords = [];
-$enrolid = 0;
+$filtergroupids = $urlgroupid ? [$urlgroupid] : [];
 
-$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
+// Force group filtering if user should only see a subset of groups' users.
+if ($course->groupmode == SEPARATEGROUPS && !$canaccessallgroups) {
+    $filtergroupids = array_keys(groups_get_all_groups($course->id, $USER->id));
 
-$filterset = new \core_user\table\participants_filterset();
-$filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
-$enrolfilter = new integer_filter('enrolments');
-$groupfilter = new integer_filter('groups');
-$keywordfilter = new string_filter('keywords');
-$lastaccessfilter = new integer_filter('accesssince');
-$rolefilter = new integer_filter('roles');
-$statusfilter = new integer_filter('status');
-
-foreach ($filtersapplied as $filter) {
-    $filtervalue = explode(':', $filter, 2);
-    $value = null;
-    if (count($filtervalue) == 2) {
-        $key = clean_param($filtervalue[0], PARAM_INT);
-        $value = clean_param($filtervalue[1], PARAM_INT);
-    } else {
-        // Search string.
-        $key = USER_FILTER_STRING;
-        $value = clean_param($filtervalue[0], PARAM_TEXT);
-    }
-
-    switch ($key) {
-        case USER_FILTER_ENROLMENT:
-            $enrolid = $value;
-            $enrolfilter->add_filter_value($value);
-            break;
-        case USER_FILTER_GROUP:
-            $groupid = $value;
-            $groupfilter->add_filter_value($value);
-            $hasgroupfilter = true;
-            break;
-        case USER_FILTER_LAST_ACCESS:
-            $lastaccess = $value;
-            $lastaccessfilter->add_filter_value($value);
-            break;
-        case USER_FILTER_ROLE:
-            $roleid = $value;
-            $rolefilter->add_filter_value($value);
-            break;
-        case USER_FILTER_STATUS:
-            // We only accept active/suspended statuses.
-            if ($value == ENROL_USER_ACTIVE || $value == ENROL_USER_SUSPENDED) {
-                $status = $value;
-                $statusfilter->add_filter_value($value);
-            }
-            break;
-        default:
-            // Search string.
-            $searchkeywords[] = $value;
-            $keywordfilter->add_filter_value($value);
-            break;
+    if (empty($filtergroupids)) {
+        // The user is not in a group so show message and exit.
+        echo $OUTPUT->notification(get_string('notingroup'));
+        echo $OUTPUT->footer();
+        exit();
     }
 }
-// If course supports groups we may need to set a default.
-if (!empty($groupid)) {
-    if ($canaccessallgroups) {
-        // User can access all groups, let them filter by whatever was selected.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$filterwassubmitted && $course->groupmode == VISIBLEGROUPS) {
-        // If we are in a course with visible groups and the user has not submitted anything and does not have
-        // access to all groups, then set a default group.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$hasgroupfilter && $course->groupmode != VISIBLEGROUPS) {
-        // The user can't access all groups and has not set a group filter in a course where the groups are not visible
-        // then apply a default group filter.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$hasgroupfilter) { // No need for the group id to be set.
-        $groupid = false;
-    }
+
+// Apply groups filter if included in URL or forced due to lack of capabilities.
+if (!empty($filtergroupids)) {
+    $filterset->add_filter(new integer_filter('groups', filter::JOINTYPE_DEFAULT, $filtergroupids));
 }
 
-if ($groupid > 0 && ($course->groupmode != SEPARATEGROUPS || $canaccessallgroups)) {
+// Display single group information if requested in the URL.
+if ($urlgroupid > 0 && ($course->groupmode != SEPARATEGROUPS || $canaccessallgroups)) {
     $grouprenderer = $PAGE->get_renderer('core_group');
-    $groupdetailpage = new \core_group\output\group_details($groupid);
+    $groupdetailpage = new \core_group\output\group_details($urlgroupid);
     echo $grouprenderer->group_details($groupdetailpage);
 }
 
-// Should use this variable so that we don't break stuff every time a variable is added or changed.
-$baseurl = new moodle_url('/user/index.php', array(
-        'contextid' => $context->id,
-        'id' => $course->id,
-        'perpage' => $perpage));
+// Filter by role if passed via URL (used on profile page).
+if ($roleid) {
+    $viewableroles = get_profile_roles($context);
 
-$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
-$participanttable->define_baseurl($baseurl);
+    // Apply filter if the user can view this role.
+    if (array_key_exists($roleid, $viewableroles)) {
+        $filterset->add_filter(new integer_filter('roles', filter::JOINTYPE_DEFAULT, [$roleid]));
+    }
+}
 
 // Manage enrolments.
 $manager = new course_enrolment_manager($PAGE, $course);
@@ -242,50 +152,18 @@ $enrolbuttonsout = '';
 foreach ($enrolbuttons as $enrolbutton) {
     $enrolbuttonsout .= $enrolrenderer->render($enrolbutton);
 }
+
 echo html_writer::div($enrolbuttonsout, 'd-flex justify-content-end', [
     'data-region' => 'wrapper',
     'data-table-uniqueid' => $participanttable->uniqueid,
 ]);
 
-// Render the unified filter.
-$renderer = $PAGE->get_renderer('core_user');
-echo $renderer->unified_filter($course, $context, $filtersapplied, $baseurl);
-
 // Render the user filters.
 $userrenderer = $PAGE->get_renderer('core_user');
 echo $userrenderer->participants_filter($context, $participanttable->uniqueid);
 
 echo '<div class="userlist">';
 
-// Add filters to the baseurl after creating unified_filter to avoid losing them.
-foreach (array_unique($filtersapplied) as $filterix => $filter) {
-    $baseurl->param('unified-filters[' . $filterix . ']', $filter);
-}
-
-if (count($groupfilter)) {
-    $filterset->add_filter($groupfilter);
-}
-
-if (count($lastaccessfilter)) {
-    $filterset->add_filter($lastaccessfilter);
-}
-
-if (count($rolefilter)) {
-    $filterset->add_filter($rolefilter);
-}
-
-if (count($enrolfilter)) {
-    $filterset->add_filter($enrolfilter);
-}
-
-if (count($statusfilter)) {
-    $filterset->add_filter($statusfilter);
-}
-
-if (count($keywordfilter)) {
-    $filterset->add_filter($keywordfilter);
-}
-
 // Do this so we can get the total number of rows.
 ob_start();
 $participanttable->set_filterset($filterset);
@@ -317,8 +195,10 @@ echo html_writer::tag(
 
 echo $participanttablehtml;
 
-$perpageurl = clone($baseurl);
-$perpageurl->remove_params('perpage');
+$perpageurl = new moodle_url('/user/index.php', [
+    'contextid' => $context->id,
+    'id' => $course->id,
+]);
 $perpagesize = DEFAULT_PAGE_SIZE;
 $perpagevisible = false;
 $perpagestring = '';
index 078aa0b..0134a2e 100644 (file)
@@ -1287,255 +1287,6 @@ function user_get_tagged_users($tag, $exclusivemode = false, $fromctx = 0, $ctx
             $exclusivemode, $fromctx, $ctx, $rec, $page, $totalpages);
 }
 
-/**
- * Returns the SQL used by the participants table.
- *
- * @param int $courseid The course id
- * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
- * @param int $accesssince The time since last access, 0 means any time
- * @param int $roleid The role id, 0 means all roles and -1 no roles
- * @param int $enrolid The enrolment id, 0 means all enrolment methods will be returned.
- * @param int $statusid The user enrolment status, -1 means all enrolments regardless of the status will be returned, if allowed.
- * @param string|array $search The search that was performed, empty means perform no search
- * @param string $additionalwhere Any additional SQL to add to where
- * @param array $additionalparams The additional params
- * @return array
- */
-function user_get_participants_sql($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
-                                   $search = '', $additionalwhere = '', $additionalparams = array()) {
-    global $DB, $USER, $CFG;
-
-    // Get the context.
-    $context = \context_course::instance($courseid, MUST_EXIST);
-
-    $isfrontpage = ($courseid == SITEID);
-
-    // Default filter settings. We only show active by default, especially if the user has no capability to review enrolments.
-    $onlyactive = true;
-    $onlysuspended = false;
-    if (has_capability('moodle/course:enrolreview', $context) && (has_capability('moodle/course:viewsuspendedusers', $context))) {
-        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;
-        }
-    }
-
-    list($esql, $params) = get_enrolled_sql($context, null, $groupid, $onlyactive, $onlysuspended, $enrolid);
-
-    $joins = array('FROM {user} u');
-    $wheres = array();
-
-    $userfields = get_extra_user_fields($context);
-    $userfieldssql = user_picture::fields('u', $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'] = $courseid;
-        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;
-
-    // Limit list to users with some role only.
-    if ($roleid) {
-        // We want to query both the current context and parent contexts.
-        list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($context->get_parent_context_ids(true),
-            SQL_PARAMS_NAMED, 'relatedctx');
-
-        // Get users without any role.
-        if ($roleid == -1) {
-            $wheres[] = "u.id NOT IN (SELECT userid FROM {role_assignments} WHERE contextid $relatedctxsql)";
-            $params = array_merge($params, $relatedctxparams);
-        } else {
-            $wheres[] = "u.id IN (SELECT userid FROM {role_assignments} WHERE roleid = :roleid AND contextid $relatedctxsql)";
-            $params = array_merge($params, array('roleid' => $roleid), $relatedctxparams);
-        }
-    }
-
-    if (!empty($search)) {
-        if (!is_array($search)) {
-            $search = [$search];
-        }
-        foreach ($search 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 = array();
-            // 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', $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', $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, $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%";
-        }
-    }
-
-    if (!empty($additionalwhere)) {
-        $wheres[] = $additionalwhere;
-        $params = array_merge($params, $additionalparams);
-    }
-
-    $from = implode("\n", $joins);
-    if ($wheres) {
-        $where = 'WHERE ' . implode(' AND ', $wheres);
-    } else {
-        $where = '';
-    }
-
-    return array($select, $from, $where, $params);
-}
-
-/**
- * Returns the total number of participants for a given course.
- *
- * @param int $courseid The course id
- * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
- * @param int $accesssince The time since last access, 0 means any time
- * @param int $roleid The role id, 0 means all roles
- * @param int $enrolid The applied filter for the user enrolment ID.
- * @param int $status The applied filter for the user's enrolment status.
- * @param string|array $search The search that was performed, empty means perform no search
- * @param string $additionalwhere Any additional SQL to add to where
- * @param array $additionalparams The additional params
- * @return int
- */
-function user_get_total_participants($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
-                                     $search = '', $additionalwhere = '', $additionalparams = array()) {
-    global $DB;
-
-    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
-        $statusid, $search, $additionalwhere, $additionalparams);
-
-    return $DB->count_records_sql("SELECT COUNT(u.id) $from $where", $params);
-}
-
-/**
- * Returns the participants for a given course.
- *
- * @param int $courseid The course id
- * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
- * @param int $accesssince The time since last access
- * @param int $roleid The role id
- * @param int $enrolid The applied filter for the user enrolment ID.
- * @param int $status The applied filter for the user's enrolment status.
- * @param string $search The search that was performed
- * @param string $additionalwhere Any additional SQL to add to where
- * @param array $additionalparams The additional params
- * @param string $sort The 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
- */
-function user_get_participants($courseid, $groupid = 0, $accesssince, $roleid, $enrolid = 0, $statusid, $search,
-                               $additionalwhere = '', $additionalparams = array(), $sort = '', $limitfrom = 0, $limitnum = 0) {
-    global $DB;
-
-    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
-        $statusid, $search, $additionalwhere, $additionalparams);
-
-    return $DB->get_recordset_sql("$select $from $where $sort", $params, $limitfrom, $limitnum);
-}
-
 /**
  * Returns SQL that can be used to limit a query to a period where the user last accessed / did not access a course.
  *
index 7be0ce5..b85ec06 100644 (file)
@@ -110,6 +110,7 @@ class core_user_renderer extends plugin_renderer_base {
 
     /**
      * Renders the unified filter element for the course participants page.
+     * @deprecated since Moodle 3.9 MDL-68612 - Please use participants_filter() instead.
      *
      * @param stdClass $course The course object.
      * @param context $context The context object.
@@ -120,6 +121,8 @@ class core_user_renderer extends plugin_renderer_base {
     public function unified_filter($course, $context, $filtersapplied, $baseurl = null) {
         global $CFG, $DB, $USER;
 
+        debugging('core_user_renderer->unified_filter() is deprecated. Please use participants_filter() instead.', DEBUG_DEVELOPER);
+
         require_once($CFG->dirroot . '/enrol/locallib.php');
         require_once($CFG->dirroot . '/lib/grouplib.php');
         $manager = new course_enrolment_manager($this->page, $course);
index 9f358b6..732bb23 100644 (file)
 {{#items}}
     <span role="listitem" data-value="{{value}}" aria-selected="true"
             class="badge badge-secondary clickable text-wrap text-break line-height-4 mr-2 my-1">
-        {{label}}<i class="icon fa fa-times pl-2 mr-0"></i>
+        {{label}}
+        <button class="btn btn-link text-reset p-0" aria-label='{{#str}}clearfilterselection, core_user, {{label}}{{/str}}'>
+            <i class="icon fa fa-times pl-2 mr-0"></i>
+        </button>
     </span>
 {{/items}}
 {{^items}}
index 9d2bdc6..2ae3c42 100644 (file)
                 "name": "status",
                 "title": "Status"
             }
-        ]
+        ],
+        "rownumber": 1
     }
 }}
 <div data-filterregion="filter">
-    <div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
-        <div class="d-flex flex-column flex-md-row align-items-md-center">
-            <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match, core_user{{/str}}</label>
-            <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
-                <option value="0">{{#str}}none{{/str}}</option>
-                <option selected=selected value="1">{{#str}}any{{/str}}</option>
-                <option value="2">{{#str}}all{{/str}}</option>
-            </select>
-        </div>
+    <fieldset>
+        <legend class="sr-only">{{#str}}filterrowlegend, core_user, {{rownumber}}{{/str}}</legend>
+        <div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
+            <div class="d-flex flex-column flex-md-row align-items-md-center">
+                <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match, core_user{{/str}}</label>
+                <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
+                    <option value="0">{{#str}}none{{/str}}</option>
+                    <option selected=selected value="1">{{#str}}any{{/str}}</option>
+                    <option value="2">{{#str}}all{{/str}}</option>
+                </select>
+            </div>
 
-        <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
-        <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
-            <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
-            {{#filtertypes}}
-            <option value="{{name}}">{{title}}</option>
-            {{/filtertypes}}
-        </select>
+            <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
+            <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
+                <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+                {{#filtertypes}}
+                <option value="{{name}}">{{title}}</option>
+                {{/filtertypes}}
+            </select>
 
-        <div data-filterregion="value" class="d-md-flex flex-column align-items-start flex-lg-row"></div>
+            <div data-filterregion="value" class="d-md-flex flex-column align-items-start flex-lg-row"></div>
 
-        <button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
-            <i class="icon fa fa-times-circle"></i>
-        </button>
-    </div>
-    <div data-filterregion="joinadverb" class="pl-1 text-uppercase font-weight-bold">
-        <div data-filterverbfor="0">{{#str}}adverbfor_andnot, core_user{{/str}}</div>
-        <div data-filterverbfor="1">{{#str}}adverbfor_or, core_user{{/str}}</div>
-        <div data-filterverbfor="2">{{#str}}adverbfor_and, core_user{{/str}}</div>
-    </div>
+            <button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
+                <i class="icon fa fa-times-circle"></i>
+            </button>
+        </div>
+        <div data-filterregion="joinadverb" class="pl-1 text-uppercase font-weight-bold">
+            <div data-filterverbfor="0">{{#str}}adverbfor_andnot, core_user{{/str}}</div>
+            <div data-filterverbfor="1">{{#str}}adverbfor_or, core_user{{/str}}</div>
+            <div data-filterverbfor="2">{{#str}}adverbfor_and, core_user{{/str}}</div>
+        </div>
+    </fieldset>
 </div>
index 243f085..30e7fe1 100644 (file)
@@ -16,6 +16,7 @@
 }}
 {{!
     @template core_user/unified_filter
+    @deprecated since Moodle 3.9 MDL-68612 - please use core_user/participantsfilter instead.
 
     Template for the unified filter element.
 
index c716a18..16839b0 100644 (file)
@@ -6,17 +6,17 @@ Feature: Course participants can be filtered
 
   Background:
     Given the following "courses" exist:
-      | fullname | shortname | groupmode |
-      | Course 1 | C1        |     1     |
-      | Course 2 | C2        |     0     |
-      | Course 3 | C3        |     0     |
+      | fullname | shortname | groupmode | startdate        |
+      | Course 1 | C1        |     1     | ##5 months ago## |
+      | Course 2 | C2        |     0     | ##4 months ago## |
+      | Course 3 | C3        |     0     | ##3 months ago## |
     And the following "users" exist:
       | username | firstname | lastname | email                | idnumber | country | city   | maildisplay |
       | student1 | Student   | 1        | student1@example.com | SID1     |         | SCITY1 | 0           |
       | student2 | Student   | 2        | student2@example.com | SID2     | GB      | SCITY2 | 1           |
       | student3 | Student   | 3        | student3@example.com | SID3     | AU      | SCITY3 | 0           |
-      | student4 | Student   | 4        | student4@example.com | SID4     | AT      | SCITY4 | 0           |
-      | teacher1 | Teacher   | 1        | teacher1@example.com | TID1     | US      | TCITY1 | 0           |
+      | student4 | Student   | 4        | student4@moodle.com  | SID4     | AT      | SCITY4 | 0           |
+      | teacher1 | Teacher   | 1        | teacher1@example.org | TID1     | US      | TCITY1 | 0           |
     And the following "course enrolments" exist:
       | user     | course | role           | status | timeend       |
       | student1 | C1     | student        |    0   |               |
@@ -32,6 +32,13 @@ Feature: Course participants can be filtered
       | teacher1 | C1     | editingteacher |    0   |               |
       | teacher1 | C2     | editingteacher |    0   |               |
       | teacher1 | C3     | editingteacher |    0   |               |
+    And the following "last access times" exist:
+      | user     | course | lastaccess      |
+      | student1 | C1     | ##yesterday##   |
+      | student1 | C2     | ##2 weeks ago## |
+      | student2 | C1     | ##4 days ago##  |
+      | student3 | C1     | ##2 weeks ago## |
+      | student4 | C1     | ##3 weeks ago## |
     And the following "groups" exist:
       | name    | course | idnumber |
       | Group 1 | C1     | G1       |
@@ -58,12 +65,15 @@ Feature: Course participants can be filtered
     And I should see "Teacher 1" in the "participants" "table"
 
   @javascript
-  Scenario Outline: Filter users for a course
+  Scenario Outline: Filter users for a course with a single value
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    When I open the autocomplete suggestions list
-    And I click on "<filter1>" item in the autocomplete list
+    And I set the field "Match" in the "Filter 1" "fieldset" to "<matchtype>"
+    And I set the field "type" in the "Filter 1" "fieldset" to "<filtertype>"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "<filtervalue>" "list_item"
+    When I click on "Apply filters" "button"
     Then I should see "<expected1>" in the "participants" "table"
     And I should see "<expected2>" in the "participants" "table"
     And I should see "<expected3>" in the "participants" "table"
@@ -72,33 +82,152 @@ Feature: Course participants can be filtered
     # Note the 'XX-IGNORE-XX' elements are for when there is less than 2 'not expected' items.
 
     Examples:
-      | filter1                         | expected1 | expected2 | expected3 | notexpected1 | notexpected2 |
-      | Group: No group                 | Student 1 | Student 4 | Teacher 1 | Student 2    | Student 3    |
-      | Group: Group 1                  | Student 2 |           |           | Student 1    | Student 3    |
-      | Group: Group 2                  | Student 2 | Student 3 |           | Student 1    | XX-IGNORE-XX |
-      | Role: Teacher                   | Teacher 1 |           |           | Student 1    | Student 2    |
-      | Status: Active                  | Teacher 1 | Student 1 | Student 3 | Student 2    | Student 4    |
-      | Status: Inactive                | Student 2 | Student 4 |           | Teacher 1    | Student 1    |
+      | matchtype | filtertype             | filtervalue | expected1 | expected2 | expected3 | notexpected1 | notexpected2 |
+      | Any       | Groups                 | No group    | Student 1 | Student 4 | Teacher 1 | Student 2    | Student 3    |
+      | All       | Groups                 | No group    | Student 1 | Student 4 | Teacher 1 | Student 2    | Student 3    |
+      | None      | Groups                 | No group    | Student 2 | Student 3 |           | Student 1    | Teacher 1    |
+      | Any       | Role                   | Student     | Student 1 | Student 2 | Student 3 | Teacher 1    | XX-IGNORE-XX |
+      | All       | Role                   | Student     | Student 1 | Student 2 | Student 3 | Teacher 1    | XX-IGNORE-XX |
+      | None      | Role                   | Student     | Teacher 1 |           |           | Student 1    | Student 2    |
+      | Any       | Status                 | Active      | Student 1 | Student 3 | Teacher 1 | Student 2    | Student 4    |
+      | All       | Status                 | Active      | Student 1 | Student 3 | Teacher 1 | Student 2    | Student 4    |
+      | None      | Status                 | Active      | Student 2 | Student 4 |           | Student 1    | Student 3    |
+      | Any       | Inactive for more than | 1 week      | Student 3 | Student 4 |           | Student 1    | Student 2    |
+      | All       | Inactive for more than | 1 week      | Student 3 | Student 4 |           | Student 1    | Student 2    |
+      | None      | Inactive for more than | 1 week      | Student 1 | Student 2 | Teacher 1 | Student 3    | XX-IGNORE-XX |
+
+  @javascript
+  Scenario Outline: Filter users for a course with multiple values for a single filter
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I set the field "Match" in the "Filter 1" "fieldset" to "<matchtype>"
+    And I set the field "type" in the "Filter 1" "fieldset" to "<filtertype>"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "<filtervalue1>" "list_item"
+    And I click on "<filtervalue2>" "list_item"
+    When I click on "Apply filters" "button"
+    Then I should see "<expected1>" in the "participants" "table"
+    And I should see "<expected2>" in the "participants" "table"
+    And I should see "<expected3>" in the "participants" "table"
+    And I should not see "<notexpected1>" in the "participants" "table"
+    And I should not see "<notexpected2>" in the "participants" "table"
+    # Note the 'XX-IGNORE-XX' elements are for when there is less than 2 'not expected' items.
+
+    Examples:
+      | matchtype | filtertype | filtervalue1 | filtervalue2 | expected1 | expected2 | expected3 | notexpected1 | notexpected2 |
+      | Any       | Groups     | Group 1      | Group 2      | Student 2 | Student 3 |           | Student 1    | XX-IGNORE-XX |
+      | All       | Groups     | Group 1      | Group 2      | Student 2 |           |           | Student 1    | Student 3    |
+      | None      | Groups     | Group 1      | Group 2      | Student 1 | Teacher 1 |           | Student 2    | Student 3    |
 
   @javascript
   Scenario Outline: Filter users which are group members in several courses
     Given I log in as "teacher1"
     And I am on "Course 3" course homepage
     And I navigate to course participants
-    When I open the autocomplete suggestions list
-    And I click on "<filter1>" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "<filtertype>"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "<filtervalue>" "list_item"
+    When I click on "Apply filters" "button"
     Then I should see "<expected1>" in the "participants" "table"
     And I should see "<expected2>" in the "participants" "table"
-    And I should see "<expected3>" in the "participants" "table"
     And I should not see "<notexpected1>" in the "participants" "table"
     And I should not see "<notexpected2>" in the "participants" "table"
     # Note the 'XX-IGNORE-XX' elements are for when there is less than 2 'not expected' items.
 
     Examples:
-      | filter1                         | expected1 | expected2 | expected3 | notexpected1 | notexpected2 |
-      | Group: No group                 | Student 3 |           |           | Student 1    | Student 2    |
-      | Group: Group A                  | Student 1 | Student 2 |           | Student 3    | XX-IGNORE-XX |
-      | Group: Group B                  | Student 2 |           |           | Student 1    | Student 3    |
+      | filtertype | filtervalue | expected1 | expected2 | notexpected1 | notexpected2 |
+      | Groups     | No group    | Student 3 |           | Student 1    | Student 2    |
+      | Groups     | Group A     | Student 1 | Student 2 | Student 3    | XX-IGNORE-XX |
+      | Groups     | Group B     | Student 2 |           | Student 1    | Student 3    |
+
+  @javascript
+  Scenario: In separate groups mode, a student in a single group can only view and filter by users in their own group
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    # Unsuspend student 2 for to improve coverage of this test.
+    And I click on "Edit enrolment" "icon" in the "Student 2" "table_row"
+    And I set the field "Status" to "Active"
+    And I click on "Save changes" "button"
+    And I log out
+    When I log in as "student3"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    # Default view should have groups filter pre-set.
+    Then I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should see "Group 2" in the "Filter 1" "fieldset"
+    And I should not see "Group 1" in the "Filter 1" "fieldset"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    # Testing result of removing groups filter row.
+    And I click on "Remove filter row" "button" in the "Filter 1" "fieldset"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    # Testing result of applying groups filter manually.
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Group 2" in the ".form-autocomplete-suggestions" "css_element"
+    And I should not see "Group 1" in the ".form-autocomplete-suggestions" "css_element"
+    And I click on "Group 2" "list_item"
+    And I click on "Apply filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    # Testing result of removing groups filter by clearing all filters.
+    And I click on "Clear filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+
+  @javascript
+  Scenario: In separate groups mode, a student in multiple groups can only view and filter by users in their own groups
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    # Unsuspend student 2 for to improve coverage of this test.
+    And I click on "Edit enrolment" "icon" in the "Student 2" "table_row"
+    And I set the field "Status" to "Active"
+    And I click on "Save changes" "button"
+    And I log out
+    When I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    # Default view should have groups filter pre-set.
+    Then I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should see "Group 1" in the "Filter 1" "fieldset"
+    And I should see "Group 2" in the "Filter 1" "fieldset"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    # Testing result of removing groups filter row.
+    And I click on "Remove filter row" "button" in the "Filter 1" "fieldset"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    # Testing result of applying groups filter manually.
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Group 1" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Group 2" in the ".form-autocomplete-suggestions" "css_element"
+    And I click on "Group 1" "list_item"
+    And I click on "Apply filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should not see "Student 3" in the "participants" "table"
+    # Testing result of removing groups filter by clearing all filters.
+    And I click on "Clear filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
 
   @javascript
   Scenario: Filter users who have no role in a course
@@ -109,8 +238,10 @@ Feature: Course participants can be filtered
     And I click on ".form-autocomplete-selection [aria-selected=true]" "css_element"
     And I press key "27" in the field "Student 1's role assignments"
     And I click on "Save changes" "link"
-    When I open the autocomplete suggestions list
-    And I click on "Role: No roles" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "No roles" "list_item"
+    When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
     And I should not see "Student 3" in the "participants" "table"
@@ -122,84 +253,168 @@ Feature: Course participants can be filtered
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    When I open the autocomplete suggestions list
-    And I click on "Role: Student" item in the autocomplete list
-    And I open the autocomplete suggestions list
-    And I click on "Status: Active" item in the autocomplete list
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    And I click on "Add condition" "button"
+    # Set filterset to match all.
+    And I set the field "Match" to "All"
+    And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Status"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Active" "list_item"
+    When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
     And I should not see "Student 4" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
     # Add more filters.
-    And I open the autocomplete suggestions list
-    And I click on "Enrolment methods: Manual enrolments" item in the autocomplete list
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group 2" item in the autocomplete list
+    And I click on "Add condition" "button"
+    And I set the field "Match" in the "Filter 3" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 3" "fieldset" to "Enrolment methods"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 3" "fieldset"
+    And I click on "Manual enrolments" "list_item"
+    And I click on "Add condition" "button"
+    And I set the field "Match" in the "Filter 4" "fieldset" to "All"
+    And I set the field "type" in the "Filter 4" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 4" "fieldset"
+    And I click on "Group 2" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 3" in the "participants" "table"
     But I should not see "Teacher 1" in the "participants" "table"
     And I should not see "Student 1" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
     And I should not see "Student 4" in the "participants" "table"
-    # Deselect the active status filter.
-    And I click on "Status: Active" "text" in the ".form-autocomplete-selection" "css_element"
-    # Apply Status: Inactive filter.
-    And I open the autocomplete suggestions list
-    And I click on "Status: Inactive" item in the autocomplete list
+    # Change the active status filter to inactive.
+    And I click on "Remove \"Active\" from filter" "button" in the "Filter 2" "fieldset"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Inactive" "list_item"
+    And I click on "Apply filters" "button"
     Then I should see "Student 2" in the "participants" "table"
     But I should not see "Student 4" in the "participants" "table"
     And I should not see "Student 1" in the "participants" "table"
     And I should not see "Student 3" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
+    # Set both statuses (match any).
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Active" "list_item"
+    And I click on "Apply filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    # Switch to match all.
+    And I set the field "Match" in the "Filter 2" "fieldset" to "All"
+    And I click on "Apply filters" "button"
+    And I should see "Nothing to display"
 
   @javascript
-  Scenario: Filter by keyword
+  Scenario: Filter match by one or more keywords and modified match types
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    # Note: This is the literal string "student", not the Role student.
-    When I set the field "Filters" to "student"
-    And I press key "13" in the field "Filters"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
+    And I set the field "Type..." to "1@example"
+    And I press key "13" in the field "Type..."
+    When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
+    And I should see "Teacher 1" in the "participants" "table"
+    And I should not see "Student 2" in the "participants" "table"
+    And I should not see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I click on "Apply filters" "button"
+    And I should see "Student 1" in the "participants" "table"
+    And I should see "Teacher 1" in the "participants" "table"
+    And I should not see "Student 2" in the "participants" "table"
+    And I should not see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "None"
+    And I click on "Apply filters" "button"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
     And I should see "Student 4" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
+    # Add a second keyword filter value
+    And I set the field "Type..." to "moodle"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I click on "Apply filters" "button"
+    And I should see "Student 1" in the "participants" "table"
+    And I should see "Teacher 1" in the "participants" "table"
+    And I should see "Student 4" in the "participants" "table"
+    And I should not see "Student 2" in the "participants" "table"
+    And I should not see "Student 3" in the "participants" "table"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I click on "Apply filters" "button"
+    And I should see "Nothing to display"
 
   @javascript
   Scenario: Reorder users without losing filter
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Role: Student" item in the autocomplete list
-    When I click on "Surname" "link"
-    Then I should see "Role: Student"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
     And I should see "Student 4" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
+    When I click on "Surname" "link"
+    Then I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should see "Student 4" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
+
+  @javascript
+  Scenario: Only possible to add filter rows for the number of filters available
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
+    And I click on "Add condition" "button"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Status"
+    And I click on "Add condition" "button"
+    And I set the field "type" in the "Filter 3" "fieldset" to "Roles"
+    And I click on "Add condition" "button"
+    And I set the field "type" in the "Filter 4" "fieldset" to "Enrolment methods"
+    And I click on "Add condition" "button"
+    And I set the field "type" in the "Filter 5" "fieldset" to "Groups"
+    And I click on "Add condition" "button"
+    And I set the field "type" in the "Filter 6" "fieldset" to "Inactive for more than"
+    And the "Add condition" "button" should be disabled
 
   @javascript
   Scenario: Rendering filter options for teachers in a course that don't support groups
     Given I log in as "teacher1"
     And I am on "Course 2" course homepage
-    And I navigate to course participants
-    When I open the autocomplete suggestions list
-    Then I should see "Role:" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Enrolment methods:" in the ".form-autocomplete-suggestions" "css_element"
-    But I should not see "Group:" in the ".form-autocomplete-suggestions" "css_element"
+    When I navigate to course participants
+    Then I should see "Roles" in the "type" "field"
+    And I should see "Enrolment methods" in the "type" "field"
+    But I should not see "Groups" in the "type" "field"
 
   @javascript
   Scenario: Rendering filter options for students who have limited privileges
     Given I log in as "student1"
     And I am on "Course 2" course homepage
-    And I navigate to course participants
-    When I open the autocomplete suggestions list
-    Then I should see "Role:" in the ".form-autocomplete-suggestions" "css_element"
-    But I should not see "Status:" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Enrolment methods:" in the ".form-autocomplete-suggestions" "css_element"
+    When I navigate to course participants
+    Then I should see "Roles" in the "type" "field"
+    But I should not see "Status" in the "type" "field"
+    And I should not see "Enrolment methods" in the "type" "field"
 
   @javascript
   Scenario: Filter by user identity fields
@@ -208,39 +423,45 @@ Feature: Course participants can be filtered
         | showuseridentity | idnumber,email,city,country |
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
     # Search by email (only).
-    When I set the field "Filters" to "student1@example.com"
-    And I press key "13" in the field "Filters"
+    And I set the field "Type..." to "student1@example.com"
+    And I press key "13" in the field "Type..."
+    When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
     # Search by idnumber (only).
-    And I click on "student1@example.com" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "SID"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"student1@example.com\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "SID"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
     And I should see "Student 4" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
     # Search by city (only).
-    And I click on "SID" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "SCITY"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"SID\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "SCITY"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
     And I should see "Student 4" in the "participants" "table"
     And I should not see "Teacher 1" in the "participants" "table"
     # Search by country text (only) - should not match.
-    And I click on "SCITY" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "GB"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"SCITY\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "GB"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Nothing to display"
     # Check no match.
-    And I click on "GB" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "NOTHING"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"GB\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "NOTHING"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Nothing to display"
 
   @javascript
@@ -255,27 +476,137 @@ Feature: Course participants can be filtered
     And I am on "Course 1" course homepage
     And I navigate to course participants
     # Search by email (only) - should only see visible email + own.
-    When I set the field "Filters" to "@example.com"
-    And I press key "13" in the field "Filters"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
+    And I set the field "Type..." to "@example."
+    And I press key "13" in the field "Type..."
+    When I click on "Apply filters" "button"
     Then I should not see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
     And I should not see "Student 3" in the "participants" "table"
     And I should not see "Student 4" in the "participants" "table"
     And I should see "Teacher 1" in the "participants" "table"
     # Search for other fields - should only see own results.
-    And I click on "@example.com" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "SID"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"@example.\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "SID"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Nothing to display"
-    And I click on "SID" "text" in the ".form-autocomplete-selection" "css_element"
-    And I set the field "Filters" to "TID"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"SID\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "TID"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Teacher 1" in the "participants" "table"
-    And I set the field "Filters" to "CITY"
-    And I press key "13" in the field "Filters"
+    And I should not see "Student 1" in the "participants" "table"
+    And I click on "Remove \"TID\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "CITY"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Teacher 1" in the "participants" "table"
     And I should not see "Student 1" in the "participants" "table"
     # Check no match.
-    And I set the field "Filters" to "NOTHING"
-    And I press key "13" in the field "Filters"
+    And I click on "Remove \"CITY\" from filter" "button" in the "Filter 1" "fieldset"
+    And I set the field "Type..." to "NOTHING"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
     And I should see "Nothing to display"
+
+  @javascript
+  Scenario: Individual filters can be removed, which will automatically refresh the participants list
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    And I click on "Add condition" "button"
+    # Set filterset to match all.
+    And I set the field "Match" to "All"
+    And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Keyword"
+    And I set the field "Type..." to "@example"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
+    And I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
+    When I click on "Remove filter row" "button" in the "Filter 1" "fieldset"
+    Then I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should see "Teacher 1" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+
+  @javascript
+  Scenario: All filters can be cleared at once
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    And I click on "Add condition" "button"
+    # Set filterset to match all.
+    And I set the field "Match" to "All"
+    And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Keyword"
+    And I set the field "Type..." to "@example"
+    And I press key "13" in the field "Type..."
+    And I click on "Apply filters" "button"
+    And I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
+    When I click on "Clear filters" "button"
+    Then I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should see "Student 4" in the "participants" "table"
+    And I should see "Teacher 1" in the "participants" "table"
+
+  @javascript
+  Scenario: Filterset match type is reset when reducing to a single filter
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
+    And I set the field "Type..." to "@example.com"
+    And I press key "13" in the field "Type..."
+    And I click on "Add condition" "button"
+    # Set filterset to match none.
+    And I set the field "Match" to "None"
+    And I set the field "Match" in the "Filter 2" "fieldset" to "All"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Student" "list_item"
+    # Match none of student role and @example.com keyword.
+    And I click on "Apply filters" "button"
+    And I should see "Teacher 1" in the "participants" "table"
+    And I should not see "Student 1" in the "participants" "table"
+    And I should not see "Student 2" in the "participants" "table"
+    And I should not see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    When I click on "Remove filter row" "button" in the "Filter 2" "fieldset"
+    # Filterset match type and role filter are removed, leaving keyword filter only.
+    Then I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should not see "Student 4" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
+    And I click on "Add condition" "button"
+    # Re-add a second filter and ensure the default (any) filterset match type is set.
+    And I set the field "Match" in the "Filter 2" "fieldset" to "All"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Role"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Student" "list_item"
+    And I click on "Apply filters" "button"
+    And I should see "Student 1" in the "participants" "table"
+    And I should see "Student 2" in the "participants" "table"
+    And I should see "Student 3" in the "participants" "table"
+    And I should see "Student 4" in the "participants" "table"
+    And I should not see "Teacher 1" in the "participants" "table"
index d50851f..9971222 100644 (file)
@@ -78,30 +78,43 @@ Feature: Course participants can be filtered to display all the users
       | student3 | G2    |
 
   @javascript
-  Scenario: Show all filtered users for a course
+  Scenario: Show all users in a course that match a single filter value
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    When I open the autocomplete suggestions list
-    And I click on "Role: Student" item in the autocomplete list
+    And I set the field "Match" in the "Filter 1" "fieldset" to "All"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    When I click on "Apply filters" "button"
+    Then I should see "24 participants found"
+    And I should see "Show all 24"
+    And I should not see "Show 20 per page"
+    And I should not see "of the following"
     And I click on "Show all 24" "link"
-    Then I should see "Role: Student"
-    And I should see "24 participants found"
     And I should see "Show 20 per page"
+    And I should not see "Show all 24"
 
   @javascript
-  Scenario: Apply more than one filter and show all users
+  Scenario: Apply one value for more than one filter and show all matching users
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    When I open the autocomplete suggestions list
-    And I click on "Role: Student" item in the autocomplete list
-    And I open the autocomplete suggestions list
-    And I click on "Status: Active" item in the autocomplete list
+    And I click on "Add condition" "button"
+    And I set the field "Match" to "All"
+    And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Student" "list_item"
+    And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
+    And I set the field "type" in the "Filter 2" "fieldset" to "Status"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 2" "fieldset"
+    And I click on "Active" "list_item"
+    When I click on "Apply filters" "button"
     And I click on "Show all 23" "link"
-    Then I should see "Role: Student"
-    And I should see "Status: Active"
-    And I should see "23 participants found"
+    Then I should see "23 participants found"
+    And I should see "Show 20 per page"
+    And I should see "of the following"
     And I should see "Student 1"
     And I should not see "Student 24"
-    And I should see "Show 20 per page"
+    And I should not see "Show all 23"
index 9479c5b..f15572e 100644 (file)
@@ -59,9 +59,10 @@ Feature: View course participants groups
     Then I should see "Group A"
     And I should see "Student 1x"
     And I should see "Student 2x"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group B" item in the autocomplete list
-    And I should see "Group B"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group B" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 3x"
     And I should see "Student 4x"
 
index 3b6fc75..66ce32c 100644 (file)
@@ -2008,6 +2008,407 @@ class participants_search_test extends advanced_testcase {
         return $finaltests;
     }
 
+    /**
+     * Ensure that the groups filter works as expected when separate groups mode is enabled, with the provided test cases.
+     *
+     * @param array $usersdata The list of users to create
+     * @param array $groupsavailable The names of groups that should be created in the course
+     * @param array $filtergroups The names of groups to filter by
+     * @param int $jointype The join type to use when combining filter values
+     * @param int $count The expected count
+     * @param array $expectedusers
+     * @param string $loginusername The user to login as for the tests
+     * @dataProvider groups_separate_provider
+     */
+    public function test_groups_filter_separate_groups(array $usersdata, array $groupsavailable, array $filtergroups, int $jointype,
+            int $count, array $expectedusers, string $loginusername): void {
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $users = [];
+
+        // Enable separate groups mode on the course.
+        $course->groupmode = SEPARATEGROUPS;
+        $course->groupmodeforce = true;
+        update_course($course);
+
+        // Prepare data for filtering by users in no groups.
+        $nogroupsdata = (object) [
+            'id' => USERSWITHOUTGROUP,
+        ];
+
+        // Map group names to group data.
+         $groupsdata = ['nogroups' => $nogroupsdata];
+        foreach ($groupsavailable as $groupname) {
+            $groupinfo = [
+                'courseid' => $course->id,
+                'name' => $groupname,
+            ];
+
+            $groupsdata[$groupname] = $this->getDataGenerator()->create_group($groupinfo);
+        }
+
+        foreach ($usersdata as $username => $userdata) {
+            $user = $this->getDataGenerator()->create_user(['username' => $username]);
+            $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+            if (array_key_exists('groups', $userdata)) {
+                foreach ($userdata['groups'] as $groupname) {
+                    $userinfo = [
+                        'userid' => $user->id,
+                        'groupid' => (int) $groupsdata[$groupname]->id,
+                    ];
+                    $this->getDataGenerator()->create_group_member($userinfo);
+                }
+            }
+
+            $users[$username] = $user;
+
+            if ($username == $loginusername) {
+                $loginuser = $user;
+            }
+        }
+
+        // Create a secondary course with users. We should not see these users.
+        $this->create_course_with_users(1, 1, 1, 1);
+
+        // Log in as the user to be tested.
+        $this->setUser($loginuser);
+
+        // Create the basic filter.
+        $filterset = new participants_filterset();
+        $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id]));
+
+        // Create the groups filter.
+        $groupsfilter = new integer_filter('groups');
+        $filterset->add_filter($groupsfilter);
+
+        // Configure the filter.
+        foreach ($filtergroups as $filtergroupname) {
+            $groupsfilter->add_filter_value((int) $groupsdata[$filtergroupname]->id);
+        }
+        $groupsfilter->set_join_type($jointype);
+
+        // Run the search.
+        $search = new participants_search($course, $coursecontext, $filterset);
+
+        // Tests on user in no groups should throw an exception as they are not supported (participants are not visible to them).
+        if (in_array('exception', $expectedusers)) {
+            $this->expectException(\coding_exception::class);
+            $rs = $search->get_participants();
+        } else {
+            // All other cases are tested as normal.
+            $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 groups filter tests.
+     *
+     * @return array
+     */
+    public function groups_separate_provider(): array {
+        $tests = [
+            'Users in different groups with separate groups mode enabled' => (object) [
+                'groupsavailable' => [
+                    'groupa',
+                    'groupb',
+                    'groupc',
+                ],
+                'users' => [
+                    'a' => [
+                        'groups' => ['groupa'],
+                    ],
+                    'b' => [
+                        'groups' => ['groupb'],
+                    ],
+                    'c' => [
+                        'groups' => ['groupa', 'groupb'],
+                    ],
+                    'd' => [
+                        'groups' => [],
+                    ],
+                ],
+                'expect' => [
+                    // Tests for jointype: ANY.
+                    'ANY: No filter, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ANY: No filter, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ANY: No filter, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'ANY: Filter on a single group, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Filter on a single group, user in multple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Filter on a single group, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'ANY: Filter on multiple groups, user in one group (ignore invalid groups)' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Filter on multiple groups, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ANY: Filter on multiple groups or no groups, user in multiple groups (ignore no groups)' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb', 'nogroups'],
+                        'jointype' => filter::JOINTYPE_ANY,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+
+                    // Tests for jointype: ALL.
+                    'ALL: No filter, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ALL: No filter, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'ALL: No filter, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'ALL: Filter on a single group, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ALL: Filter on a single group, user in multple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ALL: Filter on a single group, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'ALL: Filter on multiple groups, user in one group (ignore invalid groups)' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'ALL: Filter on multiple groups, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'c',
+                        ],
+                    ],
+                    'ALL: Filter on multiple groups or no groups, user in multiple groups (ignore no groups)' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb', 'nogroups'],
+                        'jointype' => filter::JOINTYPE_ALL,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'c',
+                        ],
+                    ],
+
+                    // Tests for jointype: NONE.
+                    'NONE: No filter, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 2,
+                        'expectedusers' => [
+                            'a',
+                            'c',
+                        ],
+                    ],
+                    'NONE: No filter, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 3,
+                        'expectedusers' => [
+                            'a',
+                            'b',
+                            'c',
+                        ],
+                    ],
+                    'NONE: No filter, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => [],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'NONE: Filter on a single group, user in one group' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                    'NONE: Filter on a single group, user in multple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 1,
+                        'expectedusers' => [
+                            'b',
+                        ],
+                    ],
+                    'NONE: Filter on a single group, user in no groups' => (object) [
+                        'loginuser' => 'd',
+                        'groups' => ['groupa'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => ['exception'],
+                    ],
+                    'NONE: Filter on multiple groups, user in one group (ignore invalid groups)' => (object) [
+                        'loginuser' => 'a',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                    'NONE: Filter on multiple groups, user in multiple groups' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                    'NONE: Filter on multiple groups or no groups, user in multiple groups (ignore no groups)' => (object) [
+                        'loginuser' => 'c',
+                        'groups' => ['groupa', 'groupb', 'nogroups'],
+                        'jointype' => filter::JOINTYPE_NONE,
+                        'count' => 0,
+                        'expectedusers' => [],
+                    ],
+                ],
+            ],
+        ];
+
+        $finaltests = [];
+        foreach ($tests as $testname => $testdata) {
+            foreach ($testdata->expect as $expectname => $expectdata) {
+                $finaltests["{$testname} => {$expectname}"] = [
+                    'users' => $testdata->users,
+                    'groupsavailable' => $testdata->groupsavailable,
+                    'filtergroups' => $expectdata->groups,
+                    'jointype' => $expectdata->jointype,
+                    'count' => $expectdata->count,
+                    'expectedusers' => $expectdata->expectedusers,
+                    'loginusername' => $expectdata->loginuser,
+                ];
+            }
+        }
+
+        return $finaltests;
+    }
+
+
     /**
      * Ensure that the last access filter works as expected with the provided test cases.
      *
index 684e22f..fcec1fe 100644 (file)
@@ -852,192 +852,4 @@ class core_userliblib_testcase extends advanced_testcase {
         self::assertSame('5', $got['timezone']);
         self::assertSame('0', $got['mailformat']);
     }
-
-    /**
-     * Test returning the total number of participants.
-     */
-    public function test_user_get_total_participants() {
-        global $DB;
-
-        $this->resetAfterTest();
-
-        // Create a course.
-        $course = self::getDataGenerator()->create_course();
-
-        // Create a teacher.
-        $teacher = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a bunch of students.
-        $student1 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-        $student2 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-        $student3 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a group.
-        $group = self::getDataGenerator()->create_group(array('courseid' => $course->id));
-
-        // Enrol the students.
-        self::getDataGenerator()->enrol_user($student1->id, $course->id);
-        self::getDataGenerator()->enrol_user($student2->id, $course->id);
-        self::getDataGenerator()->enrol_user($student3->id, $course->id);
-
-        // Enrol the teacher.
-        $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
-        self::getDataGenerator()->enrol_user($teacher->id, $course->id, $roleids['editingteacher']);
-
-        // Add the teacher and two of the students to the group.
-        groups_add_member($group->id, $teacher->id);
-        groups_add_member($group->id, $student1->id);
-        groups_add_member($group->id, $student2->id);
-
-        // Set it so the teacher and two of the students have not accessed the courses within the last day,
-        // but only one of the students is in the group.
-        $accesssince = time() - DAYSECS;
-        $lastaccess = new stdClass();
-        $lastaccess->userid = $teacher->id;
-        $lastaccess->courseid = $course->id;
-        $lastaccess->timeaccess = time() - DAYSECS;
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        $lastaccess->userid = $student1->id;
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        $lastaccess->userid = $student3->id;
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        // Now, when we perform the following search we should only return 2 users. Student who belong to
-        // the group and have the name 'searchforthis' and have not accessed the course in the last day.
-        $count = user_get_total_participants($course->id, $group->id, $accesssince + 1, $roleids['student'], 0, -1,
-            'searchforthis');
-
-        $this->assertEquals(2, $count);
-    }
-
-    /**
-     * Test returning the number of participants on the front page.
-     */
-    public function test_user_get_total_participants_on_front_page() {
-        $this->resetAfterTest();
-
-        // Set it so that only 3 users have not accessed the site within the last day (including one which has never accessed it).
-        $accesssince = time() - DAYSECS;
-
-        // Create a bunch of users.
-        $user1 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => $accesssince]);
-        $user2 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => $accesssince]);
-        $user3 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => time()]);
-        $user4 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a group.
-        $group = self::getDataGenerator()->create_group(array('courseid' => SITEID));
-
-        // Add 3 of the users to a group.
-        groups_add_member($group->id, $user1->id);
-        groups_add_member($group->id, $user2->id);
-        groups_add_member($group->id, $user3->id);
-
-        // Now, when we perform the following search we should only return 2 users. Users who belong to
-        // the group and have the name 'searchforthis' and have not accessed the site in the last day.
-        $count = user_get_total_participants(SITEID, $group->id, $accesssince + 1, 0, 0, -1, 'searchforthis');
-
-        $this->assertEquals(2, $count);
-    }
-
-    /**
-     * Test returning the participants.
-     */
-    public function test_user_get_participants() {
-        global $DB;
-
-        $this->resetAfterTest();
-
-        // Create a course.
-        $course = self::getDataGenerator()->create_course();
-
-        // Create a teacher.
-        $teacher = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a bunch of students.
-        $student1 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-        $student2 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-        $student3 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a group.
-        $group = self::getDataGenerator()->create_group(array('courseid' => $course->id));
-
-        // Enrol the students.
-        self::getDataGenerator()->enrol_user($student1->id, $course->id);
-        self::getDataGenerator()->enrol_user($student2->id, $course->id);
-        self::getDataGenerator()->enrol_user($student3->id, $course->id);
-
-        // Enrol the teacher.
-        $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
-        self::getDataGenerator()->enrol_user($teacher->id, $course->id, $roleids['editingteacher']);
-
-        // Add the teacher and two of the students to the group.
-        groups_add_member($group->id, $teacher->id);
-        groups_add_member($group->id, $student1->id);
-        groups_add_member($group->id, $student2->id);
-
-        // Set it so the teacher and two of the students have not accessed the course within the last day, but only one of
-        // the students is in the group (student 3 has never accessed the course).
-        $accesssince = time() - DAYSECS;
-        $lastaccess = new stdClass();
-        $lastaccess->userid = $teacher->id;
-        $lastaccess->courseid = $course->id;
-        $lastaccess->timeaccess = time() - DAYSECS;
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        $lastaccess->userid = $student1->id;
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        $lastaccess->userid = $student2->id;
-        $lastaccess->timeaccess = time();
-        $DB->insert_record('user_lastaccess', $lastaccess);
-
-        // Now, when we perform the following search we should only return 1 user. A student who belongs to
-        // the group and has the name 'searchforthis' and has not accessed the course in the last day.
-        $userset = user_get_participants($course->id, $group->id, $accesssince + 1, $roleids['student'], 0, -1, 'searchforthis');
-
-        $this->assertEquals($student1->id, $userset->current()->id);
-        $this->assertEquals(1, iterator_count($userset));
-
-        // Search for users without any group.
-        $userset = user_get_participants($course->id, USERSWITHOUTGROUP, 0, $roleids['student'], 0, -1, '');
-
-        $this->assertEquals($student3->id, $userset->current()->id);
-        $this->assertEquals(1, iterator_count($userset));
-    }
-
-    /**
-     * Test returning the participants on the front page.
-     */
-    public function test_user_get_participants_on_front_page() {
-        $this->resetAfterTest();
-
-        // Set it so that only 3 users have not accessed the site within the last day (user 4 has never accessed the site).
-        $accesssince = time() - DAYSECS;
-
-        // Create a bunch of users.
-        $user1 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => $accesssince]);
-        $user2 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => $accesssince]);
-        $user3 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis', 'lastaccess' => time()]);
-        $user4 = self::getDataGenerator()->create_user(['firstname' => 'searchforthis']);
-
-        // Create a group.
-        $group = self::getDataGenerator()->create_group(array('courseid' => SITEID));
-
-        // Add 3 of the users to a group.
-        groups_add_member($group->id, $user1->id);
-        groups_add_member($group->id, $user2->id);
-        groups_add_member($group->id, $user3->id);
-
-        // Now, when we perform the following search we should only return 2 users. Users who belong to
-        // the group and have the name 'searchforthis' and have not accessed the site in the last day.
-        $userset = user_get_participants(SITEID, $group->id, $accesssince + 1, 0, 0, -1, 'searchforthis', '', array(),
-            'ORDER BY id ASC');
-
-        $this->assertEquals($user1->id, $userset->current()->id);
-        $userset->next();
-        $this->assertEquals($user2->id, $userset->current()->id);
-    }
 }
index 56e4dd9..8553192 100644 (file)
@@ -1,5 +1,17 @@
 This files describes API changes for code that uses the user API.
 
+=== 3.9 ===
+
+* The unified filter has been replaced by the participants filter. The following have therefore been deprecated:
+  * Library functions:
+    * user_get_participants_sql
+    * user_get_total_participants
+    * user_get_participants
+  * Unified filter renderer (core_user_renderer::unified_filter)
+  * Unified filter renderable (\core_user\output\unified_filter)
+  * Unified filter JavaScript (core_user/unified_filter.js and core_user/unified_filter_datasource.js)
+  * Unified filter template (unified_filter.mustache)
+
 === 3.6 ===
 
 * The following functions have been finally deprecated and can not be used anymore: