Merge branch 'MDL-67657-master' of git://github.com/andrewnicols/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Tue, 21 Jul 2020 06:47:14 +0000 (14:47 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Tue, 21 Jul 2020 06:47:14 +0000 (14:47 +0800)
80 files changed:
admin/settings/server.php
admin/tool/mobile/settings.php
analytics/classes/manager.php
backup/util/ui/base_moodleform.class.php
blocks/glossary_random/tests/behat/glossary_random_global.feature
completion/tests/behat/completion_other_courses.feature [new file with mode: 0644]
course/classes/category.php
course/classes/management/helper.php
course/classes/management_renderer.php
course/completion_form.php
course/lib.php
course/tests/behat/search_recommended_activities.feature
course/tests/services_content_item_service_test.php
course/upgrade.txt
enrol/ldap/settingslib.php
enrol/self/db/access.php
enrol/self/lang/en/enrol_self.php
enrol/self/lib.php
enrol/self/version.php
install/lang/zh_cn/langconfig.php
lib/adminlib.php
lib/amd/build/icon_system_fontawesome.min.js
lib/amd/build/icon_system_fontawesome.min.js.map
lib/amd/src/icon_system_fontawesome.js
lib/behat/classes/behat_core_generator.php
lib/behat/classes/behat_generator_base.php
lib/classes/external/output/icon_system/load_fontawesome_map.php [new file with mode: 0644]
lib/classes/output/external.php
lib/classes/output/icon_system.php
lib/classes/output/icon_system_standard.php
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/moodlelib.php
lib/tests/behat/behat_data_generators.php
lib/tests/behat/behat_hooks.php
lib/tests/external/output/icon_system/load_fontawesome_map_test.php [new file with mode: 0644]
lib/tests/output/icon_system_test.php [new file with mode: 0644]
lib/upgrade.txt
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_navigation.min.js.map
mod/assign/amd/src/grading_navigation.js
mod/assign/externallib.php
mod/assign/gradingoptionsform.php
mod/assign/overrides.php
mod/assign/tests/behat/assign_user_override.feature
mod/assign/tests/behat/grading_app_filters.feature [new file with mode: 0644]
mod/assign/tests/externallib_test.php
mod/lesson/overrides.php
mod/lesson/tests/behat/lesson_user_override.feature
mod/quiz/accessrule/seb/tests/generator/behat_quizaccess_seb_generator.php
mod/quiz/backup/moodle2/backup_quiz_stepslib.php
mod/quiz/db/install.xml
mod/quiz/db/upgrade.php
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/mod_form.php
mod/quiz/module.js
mod/quiz/tests/behat/completion_condition_minimum_attempts.feature [new file with mode: 0644]
mod/quiz/tests/behat/quiz_user_override.feature
mod/quiz/tests/generator/behat_mod_quiz_generator.php
mod/quiz/tests/lib_test.php
mod/quiz/version.php
question/behaviour/interactive/renderer.php
question/behaviour/rendererbase.php
question/behaviour/upgrade.txt
question/qengine.js
question/tests/generator/behat_core_question_generator.php
question/type/multichoice/tests/behat/clearanswers.feature
search/classes/engine.php
search/classes/manager.php
search/engine/solr/classes/engine.php
search/engine/solr/tests/engine_test.php
search/upgrade.txt
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/classes/table/participants_search.php
version.php

index d846bc6..c1b2056 100644 (file)
@@ -94,7 +94,7 @@ $options = array(
     GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR|GETREMOTEADDR_SKIP_HTTP_CLIENT_IP => 'REMOTE_ADDR');
 $temp->add(new admin_setting_configselect('getremoteaddrconf', new lang_string('getremoteaddrconf', 'admin'),
     new lang_string('configgetremoteaddrconf', 'admin'),
-    GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR|GETREMOTEADDR_SKIP_HTTP_CLIENT_IP, $options));
+    GETREMOTEADDR_SKIP_DEFAULT, $options));
 $temp->add(new admin_setting_configtext('reverseproxyignore', new lang_string('reverseproxyignore', 'admin'), new lang_string('configreverseproxyignore', 'admin'), ''));
 
 $temp->add(new admin_setting_heading('webproxy', new lang_string('webproxy', 'admin'), new lang_string('webproxyinfo', 'admin')));
index 90de327..c2cee8c 100644 (file)
@@ -48,125 +48,146 @@ if ($hassiteconfig) {
 
     $ADMIN->add('mobileapp', $temp);
 
-    // Show only mobile settings if the mobile service is enabled.
-    if (!empty($CFG->enablemobilewebservice)) {
+    $featuresnotice = null;
+    if (empty($CFG->disablemobileappsubscription)) {
+        // General notification about limited features due to app restrictions.
+        $subscriptionurl = (new moodle_url("/$CFG->admin/tool/mobile/subscription.php"))->out(false);
+        $notify = new \core\output\notification(
+            get_string('moodleappsportalfeatureswarning', 'tool_mobile', $subscriptionurl),
+            \core\output\notification::NOTIFY_WARNING);
+        $featuresnotice = $OUTPUT->render($notify);
+    }
+
+    $hideappsubscription = empty($CFG->enablemobilewebservice);
+    $hideappsubscription = $hideappsubscription || (isset($CFG->disablemobileappsubscription) && !empty($CFG->disablemobileappsubscription));
+
+    $ADMIN->add(
+        'mobileapp',
+        new admin_externalpage(
+            'mobileappsubscription',
+            new lang_string('mobileappsubscription', 'tool_mobile'),
+            "$CFG->wwwroot/$CFG->admin/tool/mobile/subscription.php",
+            'moodle/site:config',
+            $hideappsubscription
+        )
+    );
+
+    // Type of login.
+    $temp = new admin_settingpage(
+        'mobileauthentication',
+        new lang_string('mobileauthentication', 'tool_mobile'),
+        'moodle/site:config',
+        empty($CFG->enablemobilewebservice)
+    );
+
+    $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesauth', '', $featuresnotice));
+
+    $options = array(
+        tool_mobile\api::LOGIN_VIA_APP => new lang_string('loginintheapp', 'tool_mobile'),
+        tool_mobile\api::LOGIN_VIA_BROWSER => new lang_string('logininthebrowser', 'tool_mobile'),
+        tool_mobile\api::LOGIN_VIA_EMBEDDED_BROWSER => new lang_string('loginintheembeddedbrowser', 'tool_mobile'),
+    );
+    $temp->add(new admin_setting_configselect('tool_mobile/typeoflogin',
+                new lang_string('typeoflogin', 'tool_mobile'),
+                new lang_string('typeoflogin_desc', 'tool_mobile'), 1, $options));
+
+    $options = [
+        tool_mobile\api::QR_CODE_DISABLED => new lang_string('qrcodedisabled', 'tool_mobile'),
+        tool_mobile\api::QR_CODE_URL => new lang_string('qrcodetypeurl', 'tool_mobile'),
+        tool_mobile\api::QR_CODE_LOGIN => new lang_string('qrcodetypelogin', 'tool_mobile'),
+    ];
+    $temp->add(new admin_setting_configselect('tool_mobile/qrcodetype',
+                new lang_string('qrcodetype', 'tool_mobile'),
+                new lang_string('qrcodetype_desc', 'tool_mobile'), tool_mobile\api::QR_CODE_LOGIN, $options));
+
+    $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
+                new lang_string('forcedurlscheme_key', 'tool_mobile'),
+                new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_NOTAGS));
+
+    $temp->add(new admin_setting_configtext('tool_mobile/minimumversion',
+                new lang_string('minimumversion_key', 'tool_mobile'),
+                new lang_string('minimumversion', 'tool_mobile'), '', PARAM_NOTAGS));
+
+    $ADMIN->add('mobileapp', $temp);
+
+    // Appearance related settings.
+    $temp = new admin_settingpage(
+        'mobileappearance',
+        new lang_string('mobileappearance', 'tool_mobile'),
+        'moodle/site:config',
+        empty($CFG->enablemobilewebservice)
+    );
+
+    if (!empty($featuresnotice)) {
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
+    }
+
+    $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
+                new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
+
+    // Reference to Branded Mobile App.
+    if (empty($CFG->disableserviceads_branded)) {
+        $temp->add(new admin_setting_description('moodlebrandedappreference',
+            new lang_string('moodlebrandedapp', 'admin'),
+            new lang_string('moodlebrandedappreference', 'admin')
+        ));
+    }
+
+    $temp->add(new admin_setting_heading('tool_mobile/smartappbanners',
+                new lang_string('smartappbanners', 'tool_mobile'), ''));
 
-        $featuresnotice = null;
-        if (empty($CFG->disablemobileappsubscription)) {
-            // General notification about limited features due to app restrictions.
-            $subscriptionurl = (new moodle_url("/$CFG->admin/tool/mobile/subscription.php"))->out(false);
-            $notify = new \core\output\notification(
-                get_string('moodleappsportalfeatureswarning', 'tool_mobile', $subscriptionurl),
-                \core\output\notification::NOTIFY_WARNING);
-            $featuresnotice = $OUTPUT->render($notify);
-
-            $ADMIN->add('mobileapp', new admin_externalpage('mobileappsubscription',
-                new lang_string('mobileappsubscription', 'tool_mobile'),
-                "$CFG->wwwroot/$CFG->admin/tool/mobile/subscription.php"));
-        }
-
-        // Type of login.
-        $temp = new admin_settingpage('mobileauthentication', new lang_string('mobileauthentication', 'tool_mobile'));
-
-        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesauth', '', $featuresnotice));
-
-        $options = array(
-            tool_mobile\api::LOGIN_VIA_APP => new lang_string('loginintheapp', 'tool_mobile'),
-            tool_mobile\api::LOGIN_VIA_BROWSER => new lang_string('logininthebrowser', 'tool_mobile'),
-            tool_mobile\api::LOGIN_VIA_EMBEDDED_BROWSER => new lang_string('loginintheembeddedbrowser', 'tool_mobile'),
-        );
-        $temp->add(new admin_setting_configselect('tool_mobile/typeoflogin',
-                    new lang_string('typeoflogin', 'tool_mobile'),
-                    new lang_string('typeoflogin_desc', 'tool_mobile'), 1, $options));
-
-        $options = [
-            tool_mobile\api::QR_CODE_DISABLED => new lang_string('qrcodedisabled', 'tool_mobile'),
-            tool_mobile\api::QR_CODE_URL => new lang_string('qrcodetypeurl', 'tool_mobile'),
-            tool_mobile\api::QR_CODE_LOGIN => new lang_string('qrcodetypelogin', 'tool_mobile'),
-        ];
-        $temp->add(new admin_setting_configselect('tool_mobile/qrcodetype',
-                    new lang_string('qrcodetype', 'tool_mobile'),
-                    new lang_string('qrcodetype_desc', 'tool_mobile'), tool_mobile\api::QR_CODE_LOGIN, $options));
-
-        $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
-                    new lang_string('forcedurlscheme_key', 'tool_mobile'),
-                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_NOTAGS));
-
-        $temp->add(new admin_setting_configtext('tool_mobile/minimumversion',
-                    new lang_string('minimumversion_key', 'tool_mobile'),
-                    new lang_string('minimumversion', 'tool_mobile'), '', PARAM_NOTAGS));
-
-        $ADMIN->add('mobileapp', $temp);
-
-        // Appearance related settings.
-        $temp = new admin_settingpage('mobileappearance', new lang_string('mobileappearance', 'tool_mobile'));
-
-        if (!empty($featuresnotice)) {
-            $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
-        }
-
-        $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
-                    new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
-
-        // Reference to Branded Mobile App.
-        if (empty($CFG->disableserviceads_branded)) {
-            $temp->add(new admin_setting_description('moodlebrandedappreference',
-                new lang_string('moodlebrandedapp', 'admin'),
-                new lang_string('moodlebrandedappreference', 'admin')
-            ));
-        }
-
-        $temp->add(new admin_setting_heading('tool_mobile/smartappbanners',
-                    new lang_string('smartappbanners', 'tool_mobile'), ''));
-
-        $temp->add(new admin_setting_configcheckbox('tool_mobile/enablesmartappbanners',
-                    new lang_string('enablesmartappbanners', 'tool_mobile'),
-                    new lang_string('enablesmartappbanners_desc', 'tool_mobile'), 0));
-
-        $temp->add(new admin_setting_configtext('tool_mobile/iosappid', new lang_string('iosappid', 'tool_mobile'),
-                    new lang_string('iosappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_IOS_APP_ID, PARAM_ALPHANUM));
-
-        $temp->add(new admin_setting_configtext('tool_mobile/androidappid', new lang_string('androidappid', 'tool_mobile'),
-                    new lang_string('androidappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_ANDROID_APP_ID, PARAM_NOTAGS));
-
-        $temp->add(new admin_setting_configtext('tool_mobile/setuplink', new lang_string('setuplink', 'tool_mobile'),
-            new lang_string('setuplink_desc', 'tool_mobile'), 'https://download.moodle.org/mobile', PARAM_URL));
-
-        $ADMIN->add('mobileapp', $temp);
-
-        // Features related settings.
-        $temp = new admin_settingpage('mobilefeatures', new lang_string('mobilefeatures', 'tool_mobile'));
-
-        if (!empty($featuresnotice)) {
-            $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
-        }
-
-        $temp->add(new admin_setting_heading('tool_mobile/logout',
-                    new lang_string('logout'), ''));
-
-        $temp->add(new admin_setting_configcheckbox('tool_mobile/forcelogout',
-                    new lang_string('forcelogout', 'tool_mobile'),
-                    new lang_string('forcelogout_desc', 'tool_mobile'), 0));
-
-        $temp->add(new admin_setting_heading('tool_mobile/features',
-                    new lang_string('mobilefeatures', 'tool_mobile'), ''));
+    $temp->add(new admin_setting_configcheckbox('tool_mobile/enablesmartappbanners',
+                new lang_string('enablesmartappbanners', 'tool_mobile'),
+                new lang_string('enablesmartappbanners_desc', 'tool_mobile'), 0));
 
-        $options = tool_mobile\api::get_features_list();
-        $temp->add(new admin_setting_configmultiselect('tool_mobile/disabledfeatures',
-                    new lang_string('disabledfeatures', 'tool_mobile'),
-                    new lang_string('disabledfeatures_desc', 'tool_mobile'), array(), $options));
+    $temp->add(new admin_setting_configtext('tool_mobile/iosappid', new lang_string('iosappid', 'tool_mobile'),
+                new lang_string('iosappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_IOS_APP_ID, PARAM_ALPHANUM));
 
-        $temp->add(new admin_setting_configtextarea('tool_mobile/custommenuitems',
-                    new lang_string('custommenuitems', 'tool_mobile'),
-                    new lang_string('custommenuitems_desc', 'tool_mobile'), '', PARAM_RAW, '50', '10'));
+    $temp->add(new admin_setting_configtext('tool_mobile/androidappid', new lang_string('androidappid', 'tool_mobile'),
+                new lang_string('androidappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_ANDROID_APP_ID, PARAM_NOTAGS));
 
-        $temp->add(new admin_setting_heading('tool_mobile/language',
-                    new lang_string('language'), ''));
+    $temp->add(new admin_setting_configtext('tool_mobile/setuplink', new lang_string('setuplink', 'tool_mobile'),
+        new lang_string('setuplink_desc', 'tool_mobile'), 'https://download.moodle.org/mobile', PARAM_URL));
 
-        $temp->add(new admin_setting_configtextarea('tool_mobile/customlangstrings',
-                    new lang_string('customlangstrings', 'tool_mobile'),
-                    new lang_string('customlangstrings_desc', 'tool_mobile'), '', PARAM_RAW, '50', '10'));
+    $ADMIN->add('mobileapp', $temp);
+
+    // Features related settings.
+    $temp = new admin_settingpage(
+        'mobilefeatures',
+        new lang_string('mobilefeatures', 'tool_mobile'),
+        'moodle/site:config',
+        empty($CFG->enablemobilewebservice)
+    );
 
-        $ADMIN->add('mobileapp', $temp);
+    if (!empty($featuresnotice)) {
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
     }
+
+    $temp->add(new admin_setting_heading('tool_mobile/logout',
+                new lang_string('logout'), ''));
+
+    $temp->add(new admin_setting_configcheckbox('tool_mobile/forcelogout',
+                new lang_string('forcelogout', 'tool_mobile'),
+                new lang_string('forcelogout_desc', 'tool_mobile'), 0));
+
+    $temp->add(new admin_setting_heading('tool_mobile/features',
+                new lang_string('mobilefeatures', 'tool_mobile'), ''));
+
+    $options = tool_mobile\api::get_features_list();
+    $temp->add(new admin_setting_configmultiselect('tool_mobile/disabledfeatures',
+                new lang_string('disabledfeatures', 'tool_mobile'),
+                new lang_string('disabledfeatures_desc', 'tool_mobile'), array(), $options));
+
+    $temp->add(new admin_setting_configtextarea('tool_mobile/custommenuitems',
+                new lang_string('custommenuitems', 'tool_mobile'),
+                new lang_string('custommenuitems_desc', 'tool_mobile'), '', PARAM_RAW, '50', '10'));
+
+    $temp->add(new admin_setting_heading('tool_mobile/language',
+                new lang_string('language'), ''));
+
+    $temp->add(new admin_setting_configtextarea('tool_mobile/customlangstrings',
+                new lang_string('customlangstrings', 'tool_mobile'),
+                new lang_string('customlangstrings_desc', 'tool_mobile'), '', PARAM_RAW, '50', '10'));
+
+    $ADMIN->add('mobileapp', $temp);
 }
index 131c782..faea917 100644 (file)
@@ -624,9 +624,25 @@ class manager {
                         LEFT JOIN {context} ctx ON ap.contextid = ctx.id
                             WHERE ctx.id IS NULL)");
 
-        $contextsql = "SELECT id FROM {context} ctx";
-        $DB->delete_records_select('analytics_predictions', "contextid NOT IN ($contextsql)");
-        $DB->delete_records_select('analytics_indicator_calc', "contextid NOT IN ($contextsql)");
+        // Cleanup analaytics predictions/calcs with MySQL friendly sub-select.
+        $DB->execute("DELETE FROM {analytics_predictions} WHERE id IN (
+                        SELECT oldpredictions.id
+                        FROM (
+                            SELECT p.id
+                            FROM {analytics_predictions} p
+                            LEFT JOIN {context} ctx ON p.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldpredictions
+                    )");
+
+        $DB->execute("DELETE FROM {analytics_indicator_calc} WHERE id IN (
+                        SELECT oldcalcs.id FROM (
+                            SELECT c.id
+                            FROM {analytics_indicator_calc} c
+                            LEFT JOIN {context} ctx ON c.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldcalcs
+                    )");
 
         // Clean up stuff that depends on analysable ids that do not exist anymore.
 
index 5208258..b37aff2 100644 (file)
@@ -402,7 +402,8 @@ abstract class base_moodleform extends moodleform {
 
         // Get list of module types on course.
         $modinfo = get_fast_modinfo($COURSE);
-        $modnames = $modinfo->get_used_module_names(true);
+        $modnames = array_map('strval', $modinfo->get_used_module_names(true));
+        core_collator::asort($modnames);
         $PAGE->requires->yui_module('moodle-backup-backupselectall', 'M.core_backup.backupselectall',
                 array($modnames));
         $PAGE->requires->strings_for_js(array('select', 'all', 'none'), 'moodle');
index 3329702..e422d38 100644 (file)
@@ -9,9 +9,14 @@ Feature: Random glossary entry block linking to global glossary
       | fullname | shortname |
       | Course 1 | C1        |
       | Course 2 | C2        |
-    And the following "activities" exist:
-      | activity   | name             | intro                          | course               | idnumber  | globalglossary | defaultapproval |
-      | glossary   | Tips and Tricks  | Frontpage glossary description | C2 | glossary0 | 1              | 1               |
+    And the following "activity" exists:
+      | activity        | glossary                       |
+      | name            | Tips and Tricks                |
+      | intro           | Frontpage glossary description |
+      | course          | C2                             |
+      | idnumber        | glossary0                      |
+      | globalglossary  | 1                              |
+      | defaultapproval | 1                              |
     And the following "users" exist:
       | username | firstname | lastname | email             |
       | student1 | Sam1      | Student1 | student1@example.com |
diff --git a/completion/tests/behat/completion_other_courses.feature b/completion/tests/behat/completion_other_courses.feature
new file mode 100644 (file)
index 0000000..66765b0
--- /dev/null
@@ -0,0 +1,30 @@
+@core @core_completion
+Feature: Set completion of other courses as criteria for completion of current course
+  In order to set completion of other courses as criteria for completion of current course
+  As a user
+  I want to select the prerequisite courses in completion settings
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1        | 0        | 1                |
+      | Course 2 | C2        | 0        | 1                |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | One      | student1@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student1 | C1     | student |
+
+  @javascript
+  Scenario: Set completion of prerequisite course as completion criteria of current course
+    When I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I navigate to "Course completion" in current page administration
+    And I click on "Condition: Completion of other courses" "link"
+    And I set the field "Courses available" to "Course 2"
+    And I press "Save changes"
+    And I add the "Course completion status" block
+    And I click on "View course report" "link" in the "Course completion status" "block"
+    Then I should see "Course 2" in the "completion-progress" "table"
+    And I should see "Student One" in the "completion-progress" "table"
index cbe6878..abe5d56 100644 (file)
@@ -2592,8 +2592,6 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * moving categories, where you do not want to allow people to move a category
      * to be the child of itself.
      *
-     * See also {@link make_categories_options()}
-     *
      * @param string/array $requiredcapability if given, only categories where the current
      *      user has this capability will be returned. Can also be an array of capabilities,
      *      in which case they are all required.
index ff1a32f..80ba0e4 100644 (file)
@@ -201,13 +201,13 @@ class helper {
         if ($category->can_change_sortorder()) {
             $actions['moveup'] = array(
                 'url' => new \moodle_url($baseurl, array('action' => 'movecategoryup')),
-                'icon' => new \pix_icon('t/up', new \lang_string('up')),
-                'string' => new \lang_string('up')
+                'icon' => new \pix_icon('t/up', new \lang_string('moveup')),
+                'string' => new \lang_string('moveup')
             );
             $actions['movedown'] = array(
                 'url' => new \moodle_url($baseurl, array('action' => 'movecategorydown')),
-                'icon' => new \pix_icon('t/down', new \lang_string('down')),
-                'string' => new \lang_string('down')
+                'icon' => new \pix_icon('t/down', new \lang_string('movedown')),
+                'string' => new \lang_string('movedown')
             );
         }
 
@@ -359,7 +359,7 @@ class helper {
      *
      * @param \core_course_category $category
      * @param \core_course_list_element $course
-     * @return string
+     * @return array
      */
     public static function get_course_listitem_actions(\core_course_category $category, \core_course_list_element $course) {
         $baseurl = new \moodle_url(
@@ -408,12 +408,12 @@ class helper {
         if ($category->can_resort_courses()) {
             $actions[] = array(
                 'url' => new \moodle_url($baseurl, array('action' => 'movecourseup')),
-                'icon' => new \pix_icon('t/up', \get_string('up')),
+                'icon' => new \pix_icon('t/up', \get_string('moveup')),
                 'attributes' => array('data-action' => 'moveup', 'class' => 'action-moveup')
             );
             $actions[] = array(
                 'url' => new \moodle_url($baseurl, array('action' => 'movecoursedown')),
-                'icon' => new \pix_icon('t/down', \get_string('down')),
+                'icon' => new \pix_icon('t/down', \get_string('movedown')),
                 'attributes' => array('data-action' => 'movedown', 'class' => 'action-movedown')
             );
         }
index ada3a69..25340f8 100644 (file)
@@ -85,6 +85,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                     $categoryid = '';
                 }
                 $select = new single_select($this->page->url, 'categoryid', $categories, $categoryid, $nothing);
+                $select->attributes['aria-label'] = get_string('selectacategory');
                 $html .= $this->render($select);
             }
             $html .= html_writer::end_div();
@@ -264,8 +265,8 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::start_div('float-left ' . $checkboxclass);
         $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
         $html .= html_writer::empty_tag('input', $bcatinput);
-        $html .= html_writer::tag('label', '', array(
-            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+        $labeltext = html_writer::span(get_string('bulkactionselect', 'moodle', $text), 'sr-only');
+        $html .= html_writer::tag('label', $labeltext, array(
             'class' => 'custom-control-label',
             'for' => 'categorylistitem' . $category->id));
         $html .= html_writer::end_div();
@@ -540,7 +541,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::start_div('card-body');
         $html .= $this->course_listing_actions($category, $course, $perpage);
         $html .= $this->listing_pagination($category, $page, $perpage, false, $viewmode);
-        $html .= html_writer::start_tag('ul', array('class' => 'ml course-list', 'role' => 'group'));
+        $html .= html_writer::start_tag('ul', array('class' => 'ml course-list'));
         foreach ($category->get_courses($options) as $listitem) {
             $html .= $this->course_listitem($category, $listitem, $courseid);
         }
@@ -641,8 +642,8 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::start_div('float-left ' . $checkboxclass);
         $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
         $html .= html_writer::empty_tag('input', $bulkcourseinput);
-        $html .= html_writer::tag('label', '', array(
-            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+        $labeltext = html_writer::span(get_string('bulkactionselect', 'moodle', $text), 'sr-only');
+        $html .= html_writer::tag('label', $labeltext, array(
             'class' => 'custom-control-label',
             'for' => 'courselistitem' . $course->id));
         $html .= html_writer::end_div();
@@ -1215,8 +1216,8 @@ class core_course_management_renderer extends plugin_renderer_base {
         if ($bulkcourseinput) {
             $html .= html_writer::start_div('custom-control custom-checkbox mr-1');
             $html .= html_writer::empty_tag('input', $bulkcourseinput);
-            $html .= html_writer::tag('label', '', array(
-                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+            $labeltext = html_writer::span(get_string('bulkactionselect', 'moodle', $text), 'sr-only');
+            $html .= html_writer::tag('label', $labeltext, array(
                 'class' => 'custom-control-label',
                 'for' => 'coursesearchlistitem' . $course->id));
             $html .= html_writer::end_div();
@@ -1323,12 +1324,12 @@ class core_course_management_renderer extends plugin_renderer_base {
         $output .= html_writer::start_tag('form', array('class' => 'card', 'id' => $formid,
                 'action' => $searchurl, 'method' => 'get'));
         $output .= html_writer::start_tag('fieldset', array('class' => 'coursesearchbox invisiblefieldset'));
-        $output .= html_writer::tag('div', $this->output->heading($strsearchcourses.': ', 2, 'm-0'),
+        $output .= html_writer::tag('legend', $this->output->heading($strsearchcourses.': ', 2, 'm-0'),
                 array('class' => 'card-header'));
         $output .= html_writer::start_div('card-body');
         $output .= html_writer::start_div('input-group col-sm-6 col-lg-4 m-auto');
         $output .= html_writer::empty_tag('input', array('class' => 'form-control', 'type' => 'text', 'id' => $inputid,
-                'size' => $inputsize, 'name' => 'search', 'value' => s($value)));
+                'size' => $inputsize, 'name' => 'search', 'value' => s($value), 'aria-label' => get_string('searchcourses')));
         $output .= html_writer::start_tag('span', array('class' => 'input-group-btn'));
         $output .= html_writer::tag('button', get_string('go'), array('class' => 'btn btn-primary', 'type' => 'submit'));
         $output .= html_writer::end_tag('span');
index f850101..937a0ee 100644 (file)
@@ -128,14 +128,16 @@ class course_completion_form extends moodleform {
         }
 
         // Get applicable courses (prerequisites).
-        $selectedcourses = $DB->get_fieldset_sql("SELECT cc.courseinstance
-                  FROM {course_completion_criteria} cc WHERE cc.course = ?", [$course->id]);
         $hasselectablecourses = core_course_category::search_courses(['onlywithcompletion' => true], ['limit' => 2]);
         unset($hasselectablecourses[$course->id]);
         if ($hasselectablecourses) {
             // Show multiselect box.
             $mform->addElement('course', 'criteria_course', get_string('coursesavailable', 'completion'),
                 array('multiple' => 'multiple', 'onlywithcompletion' => true, 'exclude' => $course->id));
+            $mform->setType('criteria_course', PARAM_INT);
+
+            $selectedcourses = $DB->get_fieldset_select('course_completion_criteria', 'courseinstance',
+                'course = :course AND criteriatype = :type', ['course' => $course->id, 'type' => COMPLETION_CRITERIA_TYPE_COURSE]);
             $mform->setDefault('criteria_course', $selectedcourses);
 
             // Map aggregation methods to context-sensitive human readable dropdown menu.
index 98703cc..7408941 100644 (file)
@@ -636,23 +636,6 @@ function get_category_or_system_context($categoryid) {
     }
 }
 
-/**
- * Returns full course categories trees to be used in html_writer::select()
- *
- * Calls {@link core_course_category::make_categories_list()} to build the tree and
- * adds whitespace to denote nesting
- *
- * @return array array mapping course category id to the display name
- */
-function make_categories_options() {
-    $cats = core_course_category::make_categories_list('', 0, ' / ');
-    foreach ($cats as $key => $value) {
-        // Prefix the value with the number of spaces equal to category depth (number of separators in the value).
-        $cats[$key] = str_repeat('&nbsp;', substr_count($value, ' / ')). $value;
-    }
-    return $cats;
-}
-
 /**
  * Print the buttons relating to course requests.
  *
index 202839d..414e62c 100644 (file)
@@ -8,7 +8,7 @@ Feature: Search recommended activities
     And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     When I set the field "search" to "assign"
     And I click on "Submit search" "button"
-    Then I should see "Search results: 1"
+    Then I should see "Search results"
     And "Assignment" "table_row" should exist
     And "Book" "table_row" should not exist
 
index 51d2183..f1924fb 100644 (file)
@@ -153,9 +153,12 @@ class services_content_item_service_testcase extends \advanced_testcase {
         $matchingcontentitems1 = $cis->get_content_items_by_name_pattern($user, $pattern1);
         $matchingcontentitems2 = $cis->get_content_items_by_name_pattern($user, $pattern2);
 
-        // The pattern "assign" should return 1 content item ("Assignment").
-        $this->assertCount(1, $matchingcontentitems1);
-        $this->assertEquals("Assignment", $matchingcontentitems1[0]->title);
+        // The pattern "assign" should return at least 1 content item (ex. "Assignment").
+        $this->assertGreaterThanOrEqual(1, count($matchingcontentitems1));
+        // Verify the pattern "assign" can be found in the title of each returned content item.
+        foreach ($matchingcontentitems1 as $contentitem) {
+            $this->assertEquals(1, preg_match("/$pattern1/i", $contentitem->title));
+        }
         // The pattern "random string" should not return any content items.
         $this->assertEmpty($matchingcontentitems2);
     }
index 45dc3a1..16e84d3 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
+=== 4.0 ===
+
+* The function make_categories_options() has now been deprecated. Please use \core_course_category::make_categories_list() instead.
+
 === 3.9 ===
 
 * The function get_module_metadata is now deprecated. Please use \core_course\local\service\content_item_service instead.
index 79a237f..ce372c9 100644 (file)
@@ -200,7 +200,7 @@ class enrol_ldap_admin_setting_category extends admin_setting_configselect {
             return true;
         }
 
-        $this->choices = make_categories_options();
+        $this->choices = core_course_category::make_categories_list('', 0, ' / ');
         return true;
     }
 }
index 4d34810..df31d2d 100644 (file)
@@ -73,4 +73,13 @@ $capabilities = array(
         )
     ),
 
+    /* Ability to enrol self in courses. */
+    'enrol/self:enrolself' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'user' => CAP_ALLOW,
+        )
+    ),
+
 );
index 504b7ba..4479ae1 100644 (file)
@@ -98,6 +98,7 @@ $string['requirepassword'] = 'Require enrolment key';
 $string['requirepassword_desc'] = 'Require enrolment key in new courses and prevent removing of enrolment key from existing courses.';
 $string['role'] = 'Default assigned role';
 $string['self:config'] = 'Configure self enrol instances';
+$string['self:enrolself'] = 'Self enrol in course';
 $string['self:holdkey'] = 'Appear as the self enrolment key holder';
 $string['self:manage'] = 'Manage enrolled users';
 $string['self:unenrol'] = 'Unenrol users from course';
index f5beff8..4807f49 100644 (file)
@@ -248,6 +248,11 @@ class enrol_self_plugin extends enrol_plugin {
             return get_string('canntenrol', 'enrol_self');
         }
 
+        // Check if user has the capability to enrol in this context.
+        if (!has_capability('enrol/self:enrolself', context_course::instance($instance->courseid))) {
+            return get_string('canntenrol', 'enrol_self');
+        }
+
         if ($instance->enrolstartdate != 0 and $instance->enrolstartdate > time()) {
             return get_string('canntenrolearly', 'enrol_self', userdate($instance->enrolstartdate));
         }
index 2063b3f..355fc52 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2020061501;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2020060900;        // Requires this Moodle version
 $plugin->component = 'enrol_self';      // Full name of the plugin (used for diagnostics)
index 4c15df0..0bc12e9 100644 (file)
@@ -31,5 +31,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['parentlanguage'] = '';
-$string['thisdirection'] = '';
+$string['thisdirection'] = 'ltr';
 $string['thislanguage'] = '简体中文';
index 8a75d3c..1a0235c 100644 (file)
@@ -4980,7 +4980,7 @@ class admin_settings_coursecat_select extends admin_setting_configselect {
         if (is_array($this->choices)) {
             return true;
         }
-        $this->choices = make_categories_options();
+        $this->choices = core_course_category::make_categories_list('', 0, ' / ');
         return true;
     }
 }
index b446165..47c4244 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js and b/lib/amd/build/icon_system_fontawesome.min.js differ
index 179a881..1bb4e1d 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js.map and b/lib/amd/build/icon_system_fontawesome.min.js.map differ
index b2f8cee..25c749c 100644 (file)
@@ -58,8 +58,10 @@ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/locals
 
         if (fetchMap === null) {
             fetchMap = Ajax.call([{
-                methodname: 'core_output_load_fontawesome_icon_map',
-                args: []
+                methodname: 'core_output_load_fontawesome_icon_system_map',
+                args: {
+                    themename: M.cfg.theme,
+                },
             }], true, false, false, 0, M.cfg.themerev)[0];
         }
 
index 6e2dd2d..f36955c 100644 (file)
@@ -39,99 +39,119 @@ defined('MOODLE_INTERNAL') || die();
 class behat_core_generator extends behat_generator_base {
 
     protected function get_creatable_entities(): array {
-        return [
+        $entities = [
             'users' => [
+                'singular' => 'user',
                 'datagenerator' => 'user',
                 'required' => ['username'],
             ],
             'categories' => [
+                'singular' => 'category',
                 'datagenerator' => 'category',
                 'required' => ['idnumber'],
                 'switchids' => ['category' => 'parent'],
             ],
             'courses' => [
+                'singular' => 'course',
                 'datagenerator' => 'course',
                 'required' => ['shortname'],
                 'switchids' => ['category' => 'category'],
             ],
             'groups' => [
+                'singular' => 'group',
                 'datagenerator' => 'group',
                 'required' => ['idnumber', 'course'],
                 'switchids' => ['course' => 'courseid'],
             ],
             'groupings' => [
+                'singular' => 'grouping',
                 'datagenerator' => 'grouping',
                 'required' => ['idnumber', 'course'],
                 'switchids' => ['course' => 'courseid'],
             ],
             'course enrolments' => [
+                'singular' => 'course enrolment',
                 'datagenerator' => 'enrol_user',
                 'required' => ['user', 'course', 'role'],
                 'switchids' => ['user' => 'userid', 'course' => 'courseid', 'role' => 'roleid'],
             ],
             'custom field categories' => [
+                'singular' => 'custom field category',
                 'datagenerator' => 'custom_field_category',
                 'required' => ['name', 'component', 'area', 'itemid'],
                 'switchids' => [],
             ],
             'custom fields' => [
+                'singular' => 'custom field',
                 'datagenerator' => 'custom_field',
                 'required' => ['name', 'category', 'type', 'shortname'],
                 'switchids' => [],
             ],
             'permission overrides' => [
+                'singular' => 'permission override',
                 'datagenerator' => 'permission_override',
                 'required' => ['capability', 'permission', 'role', 'contextlevel', 'reference'],
                 'switchids' => ['role' => 'roleid'],
             ],
             'system role assigns' => [
+                'singular' => 'system role assignment',
                 'datagenerator' => 'system_role_assign',
                 'required' => ['user', 'role'],
                 'switchids' => ['user' => 'userid', 'role' => 'roleid'],
             ],
             'role assigns' => [
+                'singular' => 'role assignment',
                 'datagenerator' => 'role_assign',
                 'required' => ['user', 'role', 'contextlevel', 'reference'],
                 'switchids' => ['user' => 'userid', 'role' => 'roleid'],
             ],
             'activities' => [
+                'singular' => 'activity',
                 'datagenerator' => 'activity',
                 'required' => ['activity', 'idnumber', 'course'],
                 'switchids' => ['course' => 'course', 'gradecategory' => 'gradecat', 'grouping' => 'groupingid'],
             ],
             'blocks' => [
+                'singular' => 'block',
                 'datagenerator' => 'block_instance',
                 'required' => ['blockname', 'contextlevel', 'reference'],
             ],
             'group members' => [
+                'singular' => 'group member',
                 'datagenerator' => 'group_member',
                 'required' => ['user', 'group'],
                 'switchids' => ['user' => 'userid', 'group' => 'groupid'],
             ],
             'grouping groups' => [
+                'singular' => 'grouping group',
                 'datagenerator' => 'grouping_group',
                 'required' => ['grouping', 'group'],
                 'switchids' => ['grouping' => 'groupingid', 'group' => 'groupid'],
             ],
             'cohorts' => [
+                'singular' => 'cohort',
                 'datagenerator' => 'cohort',
                 'required' => ['idnumber'],
             ],
             'cohort members' => [
+                'singular' => 'cohort member',
                 'datagenerator' => 'cohort_member',
                 'required' => ['user', 'cohort'],
                 'switchids' => ['user' => 'userid', 'cohort' => 'cohortid'],
             ],
             'roles' => [
+                'singular' => 'role',
                 'datagenerator' => 'role',
                 'required' => ['shortname'],
             ],
             'grade categories' => [
+                'singular' => 'grade category',
                 'datagenerator' => 'grade_category',
                 'required' => ['fullname', 'course'],
                 'switchids' => ['course' => 'courseid', 'gradecategory' => 'parent'],
             ],
             'grade items' => [
+                'singular' => 'grade item',
                 'datagenerator' => 'grade_item',
                 'required' => ['course'],
                 'switchids' => [
@@ -142,30 +162,36 @@ class behat_core_generator extends behat_generator_base {
                 ],
             ],
             'grade outcomes' => [
+                'singular' => 'grade outcome',
                 'datagenerator' => 'grade_outcome',
                 'required' => ['shortname', 'scale'],
                 'switchids' => ['course' => 'courseid', 'gradecategory' => 'categoryid', 'scale' => 'scaleid'],
             ],
             'scales' => [
+                'singular' => 'scale',
                 'datagenerator' => 'scale',
                 'required' => ['name', 'scale'],
                 'switchids' => ['course' => 'courseid'],
             ],
             'question categories' => [
+                'singular' => 'question category',
                 'datagenerator' => 'question_category',
                 'required' => ['name', 'contextlevel', 'reference'],
                 'switchids' => ['questioncategory' => 'parent'],
             ],
             'questions' => [
+                'singular' => 'question',
                 'datagenerator' => 'question',
                 'required' => ['qtype', 'questioncategory', 'name'],
                 'switchids' => ['questioncategory' => 'category', 'user' => 'createdby'],
             ],
             'tags' => [
+                'singular' => 'tag',
                 'datagenerator' => 'tag',
                 'required' => ['name'],
             ],
             'events' => [
+                'singular' => 'event',
                 'datagenerator' => 'event',
                 'required' => ['name', 'eventtype'],
                 'switchids' => [
@@ -175,68 +201,83 @@ class behat_core_generator extends behat_generator_base {
                 ],
             ],
             'message contacts' => [
+                'singular' => 'message contact',
                 'datagenerator' => 'message_contacts',
                 'required' => ['user', 'contact'],
                 'switchids' => ['user' => 'userid', 'contact' => 'contactid'],
             ],
             'private messages' => [
+                'singular' => 'private message',
                 'datagenerator' => 'private_messages',
                 'required' => ['user', 'contact', 'message'],
                 'switchids' => ['user' => 'userid', 'contact' => 'contactid'],
             ],
             'favourite conversations' => [
+                'singular' => 'favourite conversation',
                 'datagenerator' => 'favourite_conversations',
                 'required' => ['user', 'contact'],
                 'switchids' => ['user' => 'userid', 'contact' => 'contactid'],
             ],
             'group messages' => [
+                'singular' => 'group message',
                 'datagenerator' => 'group_messages',
                 'required' => ['user', 'group', 'message'],
                 'switchids' => ['user' => 'userid', 'group' => 'groupid'],
             ],
             'muted group conversations' => [
+                'singular' => 'muted group conversation',
                 'datagenerator' => 'mute_group_conversations',
                 'required' => ['user', 'group', 'course'],
                 'switchids' => ['user' => 'userid', 'group' => 'groupid', 'course' => 'courseid'],
             ],
             'muted private conversations' => [
+                'singular' => 'muted private conversation',
                 'datagenerator' => 'mute_private_conversations',
                 'required' => ['user', 'contact'],
                 'switchids' => ['user' => 'userid', 'contact' => 'contactid'],
             ],
             'language customisations' => [
+                'singular' => 'language customisation',
                 'datagenerator' => 'customlang',
                 'required' => ['component', 'stringid', 'value'],
             ],
-            'analytics model' => [
+            'analytics models' => [
+                'singular' => 'analytics model',
                 'datagenerator' => 'analytics_model',
                 'required' => ['target', 'indicators', 'timesplitting', 'enabled'],
             ],
             'user preferences' => [
+                'singular' => 'user preference',
                 'datagenerator' => 'user_preferences',
                 'required' => array('user', 'preference', 'value'),
-                'switchids' => array('user' => 'userid')
+                'switchids' => array('user' => 'userid'),
             ],
-            'contentbank content' => [
+            'contentbank contents' => [
+                'singular' => 'contentbank content',
                 'datagenerator' => 'contentbank_content',
                 'required' => array('contextlevel', 'reference', 'contenttype', 'user', 'contentname'),
                 'switchids' => array('user' => 'userid')
             ],
-            'badge external backpack' => [
+            'badge external backpacks' => [
+                'singular' => 'badge external backpack',
                 'datagenerator' => 'badge_external_backpack',
                 'required' => ['backpackapiurl', 'backpackweburl', 'apiversion']
             ],
-            'setup backpack connected' => [
+            'setup backpacks connected' => [
+                'singular' => 'setup backpack connected',
                 'datagenerator' => 'setup_backpack_connected',
                 'required' => ['user', 'externalbackpack'],
                 'switchids' => ['user' => 'userid', 'externalbackpack' => 'externalbackpackid']
             ],
             'last access times' => [
+                'singular' => 'last access time',
                 'datagenerator' => 'last_access_times',
                 'required' => ['user', 'course', 'lastaccess'],
                 'switchids' => ['user' => 'userid', 'course' => 'courseid'],
             ],
         ];
+
+        return $entities;
     }
 
     /**
index 107c165..44ea79d 100644 (file)
@@ -171,13 +171,23 @@ abstract class behat_generator_base {
      *
      * @param string    $generatortype The name of the entity to create.
      * @param TableNode $data from the step.
+     * @param bool      $singular Whether there is only one record and it is pivotted
      */
-    public function generate_items(string $generatortype, TableNode $data) {
+    public function generate_items(string $generatortype, TableNode $data, bool $singular = false) {
         // Now that we need them require the data generators.
         require_once(__DIR__ . '/../../testing/generator/lib.php');
 
         $elements = $this->get_creatable_entities();
 
+        foreach ($elements as $key => $configuration) {
+            if (array_key_exists('singular', $configuration)) {
+                $singularverb = $configuration['singular'];
+                unset($configuration['singular']);
+                unset($elements[$key]['singular']);
+                $elements[$singularverb] = $configuration;
+            }
+        }
+
         if (!isset($elements[$generatortype])) {
             throw new PendingException($this->name_for_errors($generatortype) .
                     ' is not a known type of entity that can be generated.');
@@ -193,8 +203,17 @@ abstract class behat_generator_base {
 
         $generatortype = $entityinfo['datagenerator'];
 
-        foreach ($data->getHash() as $elementdata) {
+        if ($singular) {
+            // There is only one record to generate, and the table has been pivotted.
+            // The rows each represent a single field.
+            $rows = [$data->getRowsHash()];
+        } else {
+            // There are multiple records to generate.
+            // The rows represent an item to create.
+            $rows = $data->getHash();
+        }
 
+        foreach ($rows as $elementdata) {
             // Check if all the required fields are there.
             foreach ($entityinfo['required'] as $requiredfield) {
                 if (!isset($elementdata[$requiredfield])) {
diff --git a/lib/classes/external/output/icon_system/load_fontawesome_map.php b/lib/classes/external/output/icon_system/load_fontawesome_map.php
new file mode 100644 (file)
index 0000000..aafaa4d
--- /dev/null
@@ -0,0 +1,98 @@
+<?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/>.
+
+/**
+ * A web service to load the mapping of moodle pix names to fontawesome icon names.
+ *
+ * @package    core
+ * @category   external
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\output\icon_system;
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use core\output\icon_system_fontawesome;
+use theme_config;
+
+/**
+ * Web service to load font awesome icon maps.
+ *
+ * @package    core
+ * @category   external
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class load_fontawesome_map extends external_api {
+
+    /**
+     * Description of the parameters suitable for the `execute` function.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters() {
+        return new external_function_parameters([
+            'themename' => new external_value(PARAM_ALPHANUMEXT, 'The theme to fetch the map for'),
+        ]);
+    }
+
+    /**
+     * Return a mapping of icon names to icons.
+     *
+     * @param   string $themename The theme to fetch icons for
+     * @return  array the mapping
+     */
+    public static function execute(string $themename) {
+        [
+            'themename' => $themename,
+        ] = self::validate_parameters(self::execute_parameters(), [
+            'themename' => $themename,
+        ]);
+
+        $theme = theme_config::load($themename);
+        $instance = icon_system_fontawesome::instance($theme->get_icon_system());
+
+        $result = [];
+        foreach ($instance->get_icon_name_map() as $from => $to) {
+            [$component, $pix] = explode(':', $from);
+            $result[] = [
+                'component' => $component,
+                'pix' => $pix,
+                'to' => $to,
+            ];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Description of the return value for the `execute` function.
+     *
+     * @return external_description
+     */
+    public static function execute_returns() {
+        return new external_multiple_structure(new external_single_structure([
+            'component' => new external_value(PARAM_COMPONENT, 'The component for the icon.'),
+            'pix' => new external_value(PARAM_RAW, 'Value to map the icon from.'),
+            'to' => new external_value(PARAM_RAW, 'Value to map the icon to.'),
+        ]));
+    }
+}
index a8f2d6a..f9ee71b 100644 (file)
@@ -34,6 +34,7 @@ use core_component;
 use moodle_exception;
 use context_system;
 use theme_config;
+use core\external\output\icon_system\load_fontawesome_map;
 
 /**
  * This class contains a list of webservice functions related to output.
@@ -202,24 +203,13 @@ class external extends external_api {
     /**
      * Return a mapping of icon names to icons.
      *
+     * @deprecated since Moodle 3.10
      * @return array the mapping
      */
     public static function load_fontawesome_icon_map() {
-        $instance = icon_system::instance(icon_system::FONTAWESOME);
+        global $PAGE;
 
-        $map = $instance->get_icon_name_map();
-
-        $result = [];
-
-        foreach ($map as $from => $to) {
-            list($component, $pix) = explode(':', $from);
-            $one = [];
-            $one['component'] = $component;
-            $one['pix'] = $pix;
-            $one['to'] = $to;
-            $result[] = $one;
-        }
-        return $result;
+        return load_fontawesome_map::execute($PAGE->theme->name);
     }
 
     /**
@@ -228,13 +218,16 @@ class external extends external_api {
      * @return external_description
      */
     public static function load_fontawesome_icon_map_returns() {
-        return new external_multiple_structure(new external_single_structure(
-            array(
-                'component' => new external_value(PARAM_COMPONENT, 'The component for the icon.'),
-                'pix' => new external_value(PARAM_RAW, 'Value to map the icon from.'),
-                'to' => new external_value(PARAM_RAW, 'Value to map the icon to.')
-            )
-        ));
+        return load_fontawesome_map::execute_returns();
     }
-}
 
+    /**
+     * The `load_fontawesome_icon_map` function has been replaced with
+     * @see load_fontawesome_map::execute()
+     *
+     * @return bool
+     */
+    public static function load_fontawesome_icon_map_is_deprecated() {
+        return true;
+    }
+}
index 14e1075..eff2991 100644 (file)
@@ -79,15 +79,24 @@ abstract class icon_system {
         global $PAGE;
 
         if (empty(self::$instance)) {
-            $icontype = $PAGE->theme->get_icon_system();
-            self::$instance = new $icontype();
+            $iconsystem = $PAGE->theme->get_icon_system();
+            self::$instance = new $iconsystem();
         }
 
-        // If $type is specified we need to make sure that the theme icon system supports this type,
-        // if not, we will return a generic new instance of the $type.
-        if ($type === null || is_a(self::$instance, $type)) {
+        if ($type === null) {
+            // No type specified. Return the icon system for the current theme.
+            return self::$instance;
+        }
+
+        if (!static::is_valid_system($type)) {
+            throw new \coding_exception("Invalid icon system requested '{$type}'");
+        }
+
+        if (is_a(self::$instance, $type) && is_a($type, get_class(self::$instance), true)) {
+            // The requested type is an exact match for the current icon system.
             return self::$instance;
         } else {
+            // Return the requested icon system.
             return new $type();
         }
     }
@@ -99,7 +108,7 @@ abstract class icon_system {
      * @return boolean
      */
     public final static function is_valid_system($system) {
-        return class_exists($system) && is_subclass_of($system, self::class);
+        return class_exists($system) && is_a($system, static::class, true);
     }
 
     /**
@@ -153,4 +162,3 @@ abstract class icon_system {
         self::$instance = null;
     }
 }
-
index 8119a2d..c9a2f77 100644 (file)
@@ -38,7 +38,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2016 Damyon Wiese
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class icon_system_standard {
+class icon_system_standard extends icon_system {
 
     public function render_pix_icon(renderer_base $output, pix_icon $icon) {
         $data = $icon->export_for_template($output);
index 57038ef..0be2bf7 100644 (file)
@@ -1646,6 +1646,14 @@ $functions = array(
         'loginrequired' => false,
         'ajax' => true,
     ),
+    'core_output_load_fontawesome_icon_system_map' => array(
+        'classname' => 'core\external\output\icon_system\load_fontawesome_map',
+        'methodname' => 'execute',
+        'description' => 'Load the mapping of moodle pix names to fontawesome icon names',
+        'type' => 'read',
+        'loginrequired' => false,
+        'ajax' => true,
+    ),
     // Question related functions.
     'core_question_update_flag' => array(
         'classname'     => 'core_question_external',
index 977d7df..b0ccb2a 100644 (file)
@@ -2498,5 +2498,16 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020062600.01);
     }
 
+    if ($oldversion < 2020071100.01) {
+        // Clean up completion criteria records referring to NULL course prerequisites.
+        $select = 'criteriatype = :type AND courseinstance IS NULL';
+        $params = ['type' => 8]; // COMPLETION_CRITERIA_TYPE_COURSE.
+
+        $DB->delete_records_select('course_completion_criteria', $select, $params);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020071100.01);
+    }
+
     return true;
 }
index c28996f..48661cb 100644 (file)
@@ -3351,3 +3351,19 @@ function user_get_participants($courseid, $groupid = 0, $accesssince, $roleid, $
 
     return $DB->get_recordset_sql("$select $from $where $sort", $params, $limitfrom, $limitnum);
 }
+
+/**
+ * Returns the list of full course categories to be used in html_writer::select()
+ *
+ * Calls {@see core_course_category::make_categories_list()} to build the list.
+ *
+ * @deprecated since Moodle 4.0
+ * @todo This will be finally removed for Moodle 4.4 as part of MDL-69124.
+ * @return array array mapping course category id to the display name
+ */
+function make_categories_options() {
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. Please use \core_course_category::make_categories_list() instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    return core_course_category::make_categories_list('', 0, ' / ');
+}
index 1eb7f31..0418097 100644 (file)
@@ -365,6 +365,10 @@ define('PAGE_COURSE_VIEW', 'course-view');
 define('GETREMOTEADDR_SKIP_HTTP_CLIENT_IP', '1');
 /** Get remote addr constant */
 define('GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR', '2');
+/**
+ * GETREMOTEADDR_SKIP_DEFAULT defines the default behavior remote IP address validation.
+ */
+define('GETREMOTEADDR_SKIP_DEFAULT', GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR|GETREMOTEADDR_SKIP_HTTP_CLIENT_IP);
 
 // Blog access level constant declaration.
 define ('BLOG_USER_LEVEL', 1);
@@ -9243,7 +9247,7 @@ function getremoteaddr($default='0.0.0.0') {
     if (empty($CFG->getremoteaddrconf)) {
         // This will happen, for example, before just after the upgrade, as the
         // user is redirected to the admin screen.
-        $variablestoskip = 0;
+        $variablestoskip = GETREMOTEADDR_SKIP_DEFAULT;
     } else {
         $variablestoskip = $CFG->getremoteaddrconf;
     }
index 2b805ed..eb39c03 100644 (file)
@@ -75,7 +75,7 @@ class behat_data_generators extends behat_base {
     ];
 
     /**
-     * Creates the specified element.
+     * Creates the specified elements.
      *
      * See the class comment for an overview.
      *
@@ -92,6 +92,24 @@ class behat_data_generators extends behat_base {
         $this->get_instance_for_component($component)->generate_items($entity, $data);
     }
 
+    /**
+     * Creates the specified element.
+     *
+     * See the class comment for an overview.
+     *
+     * @Given the following :entitytype exists:
+     *
+     * @param string    $entitytype The name of the type entity to add
+     * @param TableNode $data
+     */
+    public function the_following_entity_exists($entitytype, TableNode $data) {
+        if (isset($this->movedentitytypes[$entitytype])) {
+            $entitytype = $this->movedentitytypes[$entitytype];
+        }
+        list($component, $entity) = $this->parse_entity_type($entitytype);
+        $this->get_instance_for_component($component)->generate_items($entity, $data, true);
+    }
+
     /**
      * Parse a full entity type like 'users' or 'mod_forum > subscription'.
      *
index 1ffcb14..ec007f1 100644 (file)
@@ -86,6 +86,17 @@ class behat_hooks extends behat_base {
      */
     protected static $currentstepexception = null;
 
+    /**
+     * If an Exception is thrown in the BeforeScenario hook it will cause the Scenario to be skipped, and the exit code
+     * to be non-zero triggering a potential rerun.
+     *
+     * To combat this the exception is stored and re-thrown when looking for exceptions.
+     * This allows the test to instead be failed and re-run correctly.
+     *
+     * @var null|Exception
+     */
+    protected static $currentscenarioexception = null;
+
     /**
      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
      *
@@ -173,7 +184,7 @@ class behat_hooks extends behat_base {
             $message = <<<EOF
 Your behat test site is outdated, please run the following command from your Moodle dirroot to drop, and reinstall the Behat test site.
 
-    {$comandpath}
+    {$commandpath}
 
 EOF;
             self::log_and_stop($message);
@@ -362,8 +373,13 @@ EOF;
             // The `before_first_scenario_start_session` function will have started the session instead.
             return;
         }
+        self::$currentscenarioexception = null;
 
-        $this->restart_session();
+        try {
+            $this->restart_session();
+        } catch (Exception $e) {
+            self::$currentscenarioexception = $e;
+        }
     }
 
     /**
@@ -374,6 +390,12 @@ EOF;
      */
     public function before_scenario_hook(BeforeScenarioScope $scope) {
         global $DB;
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         $suitename = $scope->getSuite()->getName();
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
@@ -526,6 +548,12 @@ EOF;
      * @BeforeStep
      */
     public function before_step_javascript(BeforeStepScope $scope) {
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         self::$currentstepexception = null;
 
         // Only run if JS.
@@ -742,6 +770,11 @@ EOF;
      * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
      */
     public function i_look_for_exceptions() {
+        // If the scenario already failed in a hook throw the exception.
+        if (!is_null(self::$currentscenarioexception)) {
+            throw self::$currentscenarioexception;
+        }
+
         // If the step already failed in a hook throw the exception.
         if (!is_null(self::$currentstepexception)) {
             throw self::$currentstepexception;
diff --git a/lib/tests/external/output/icon_system/load_fontawesome_map_test.php b/lib/tests/external/output/icon_system/load_fontawesome_map_test.php
new file mode 100644 (file)
index 0000000..752ac6c
--- /dev/null
@@ -0,0 +1,98 @@
+<?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/>.
+
+/**
+ * External functions test for record_feedback_action.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\output\icon_system;
+
+use externallib_advanced_testcase;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Class record_userfeedback_action_testcase
+ *
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass core\external\output\icon_system\load_fontawesome_map
+ */
+class load_fontawesome_map_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Perform setup before these tests are run.
+     */
+    public static function setUpBeforeClass(): void {
+        global $CFG;
+
+        // In normal operation the external_api classes will have been loaded by the caller.
+        // The load_fontawesome_map class should not need to supplement our lack of autoloading of these classes.
+        require_once($CFG->libdir . '/externallib.php');
+    }
+
+    /**
+     * Ensure that a valid theme which uses fontawesome returns a map.
+     *
+     * @covers ::execute_parameters
+     * @covers ::execute
+     * @covers ::execute_returns
+     * @dataProvider valid_fontawesome_theme_provider
+     * @param   string $themename
+     */
+    public function test_execute(string $themename): void {
+        $result = load_fontawesome_map::execute($themename);
+        $this->assertIsArray($result);
+
+        foreach ($result as $value) {
+            $this->assertArrayHasKey('component', $value);
+            $this->assertArrayHasKey('pix', $value);
+            $this->assertArrayHasKey('to', $value);
+        }
+    }
+
+    /**
+     * Ensure that an invalid theme cannot be loaded.
+     */
+    public function test_execute_invalid_themename(): void {
+        $result = load_fontawesome_map::execute('invalidtheme');
+        $this->assertDebuggingCalled(
+            'This page should be using theme invalidtheme which cannot be initialised. Falling back to the site theme boost'
+        );
+        $this->assertIsArray($result);
+    }
+
+    /**
+     * Data provider for valid themes to use with the execute function.
+     *
+     * @return  array
+     */
+    public function valid_fontawesome_theme_provider(): array {
+        return [
+            'Boost theme' => ['boost'],
+            'Classic theme (extends boost)' => ['classic'],
+        ];
+    }
+}
diff --git a/lib/tests/output/icon_system_test.php b/lib/tests/output/icon_system_test.php
new file mode 100644 (file)
index 0000000..d018ed6
--- /dev/null
@@ -0,0 +1,248 @@
+<?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/>.
+
+/**
+ * Unit tests for lib/outputcomponents.php.
+ *
+ * @package   core
+ * @category  test
+ * @copyright 2011 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\output;
+
+use advanced_testcase;
+use coding_exception;
+
+/**
+ * Unit tests for the `icon_system` class.
+ *
+ * @coversDefaultClass core\output\icon_system
+ */
+class icon_system_test extends advanced_testcase {
+    /**
+     * Check whether the supplied classes are valid icon subsystems of the supplied one.
+     *
+     * @covers ::is_valid_system
+     * @dataProvider is_valid_subsystem_provider
+     * @param   string $parent The class to call ::is_valid_system() on
+     * @param   string $system The class to request
+     * @param   bool $expected Whether the supplied relationship is valid
+     */
+    public function test_is_valid_subsystem(string $parent, string $system, bool $expected): void {
+        $this->assertEquals($expected, $parent::is_valid_system($system));
+    }
+
+    /**
+     * Ensure that the ::instance() function throws an appropriate Exception when an inappropriate relationship is
+     * specified.
+     *
+     * @covers ::instance
+     * @dataProvider invalid_instance_provider
+     * @param   string $parent The class to call ::instance() on
+     * @param   string $system The class to request
+     */
+    public function test_invalid_instance(string $parent, string $system): void {
+        $this->expectException(coding_exception::class);
+        $this->expectExceptionMessage("Invalid icon system requested '{$system}'");
+
+        $parent::instance($system);
+    }
+
+    /**
+     * Ensure that the ::instance() function returns an instance of the supplied system for a valid icon system
+     * relationship.
+     *
+     * @covers ::instance
+     * @dataProvider valid_instance_provider
+     * @param   string $parent The class to call ::instance() on
+     * @param   string $system The class to request
+     */
+    public function test_valid_instance(string $parent, string $system): void {
+        $instance = $parent::instance($system);
+        $this->assertInstanceOf($parent, $instance);
+        $this->assertInstanceOf($system, $instance);
+    }
+
+    /**
+     * Ensure that subsequent calls without arguments to ::instance() return the exact same instance.
+     *
+     * @covers ::instance
+     */
+    public function test_instance_singleton(): void {
+        $singleton = icon_system::instance();
+
+        // Calling instance() again returns the same singleton.
+        $this->assertSame($singleton, icon_system::instance());
+    }
+
+    /**
+     * Ensure thaat subsequent calls with an argument to ::instance() return the exact same instance.
+     *
+     * @covers ::instance
+     */
+    public function test_instance_singleton_named_default(): void {
+        global $PAGE;
+        $singleton = icon_system::instance();
+
+        $defaultsystem = $PAGE->theme->get_icon_system();
+        $this->assertSame($singleton, icon_system::instance($defaultsystem));
+    }
+
+    /**
+     * Ensure that ::instance() returns an instance of the correct icon system when requested on the core icon_system
+     * class.
+     *
+     * @covers ::instance
+     * @dataProvider valid_instance_provider
+     * @param   string $parent The class to call ::instance() on
+     * @param   string $child The class to request
+     */
+    public function test_instance_singleton_named(string $parent, string $child): void {
+        $iconsystem = icon_system::instance($child);
+        $this->assertInstanceOf($child, $iconsystem);
+    }
+
+    /**
+     * Ensure that ::instance() returns an instance of the correct icon system when called on a named parent class.
+     *
+     * @covers ::instance
+     * @dataProvider valid_instance_provider
+     * @param   string $parent The class to call ::instance() on
+     * @param   string $child The class to request
+     */
+    public function test_instance_singleton_named_child(string $parent, string $child): void {
+        $iconsystem = $parent::instance($child);
+        $this->assertInstanceOf($parent, $iconsystem);
+        $this->assertInstanceOf($child, $iconsystem);
+    }
+
+    /**
+     * Ensure that the ::reset_caches() function resets the stored instance such that ::instance() returns a new
+     * instance in subsequent calls.
+     *
+     * @covers ::instance
+     * @covers ::reset_caches
+     */
+    public function test_instance_singleton_reset(): void {
+        $singleton = icon_system::instance();
+
+        // Reset the cache.
+        icon_system::reset_caches();
+
+        // Calling instance() again returns a new singleton.
+        $newsingleton = icon_system::instance();
+        $this->assertNotSame($singleton, $newsingleton);
+
+        // Calling it again gets the new singleton.
+        $this->assertSame($newsingleton, icon_system::instance());
+    }
+
+    /**
+     * Returns data for data providers containing:
+     * - parent icon system
+     * - child icon system
+     * - whether it is a valid child
+     *
+     * @return array
+     */
+    public function icon_system_provider(): array {
+        return [
+            'icon_system => icon_system_standard' => [
+                icon_system::class,
+                icon_system_standard::class,
+                true,
+            ],
+            'icon_system => icon_system_fontawesome' => [
+                icon_system::class,
+                icon_system_fontawesome::class,
+                true,
+            ],
+            'icon_system => \theme_classic\output\icon_system_fontawesome' => [
+                icon_system::class,
+                \theme_classic\output\icon_system_fontawesome::class,
+                true,
+            ],
+            'icon_system => notification' => [
+                icon_system::class,
+                notification::class,
+                false,
+            ],
+
+            'icon_system_standard => icon_system_standard' => [
+                icon_system_standard::class,
+                icon_system_standard::class,
+                true,
+            ],
+            'icon_system_standard => icon_system_fontawesome' => [
+                icon_system_standard::class,
+                icon_system_fontawesome::class,
+                false,
+            ],
+            'icon_system_standard => \theme_classic\output\icon_system_fontawesome' => [
+                icon_system_standard::class,
+                \theme_classic\output\icon_system_fontawesome::class,
+                false,
+            ],
+            'icon_system_fontawesome => icon_system_standard' => [
+                icon_system_fontawesome::class,
+                icon_system_standard::class,
+                false,
+            ],
+        ];
+    }
+
+    /**
+     * Data provider for tests of `is_valid`.
+     *
+     * @return array
+     */
+    public function is_valid_subsystem_provider(): array {
+        return $this->icon_system_provider();
+    }
+
+    /**
+     * Data provider for tests of `instance` containing only invalid tests.
+     *
+     * @return array
+     */
+    public function invalid_instance_provider(): array {
+        return array_filter(
+            $this->icon_system_provider(),
+            function($data) {
+                return !$data[2];
+            },
+            ARRAY_FILTER_USE_BOTH
+        );
+    }
+
+    /**
+     * Data provider for tests of `instance` containing only valid tests.
+     *
+     * @return array
+     */
+    public function valid_instance_provider(): array {
+        return array_filter(
+            $this->icon_system_provider(),
+            function($data) {
+                return $data[2];
+            },
+            ARRAY_FILTER_USE_BOTH
+        );
+    }
+
+}
index aa64123..76e3577 100644 (file)
@@ -29,6 +29,8 @@ information provided here is intended especially for developers.
     - course_in_list (now: core_course_list_element)
     - coursecat (now: core_course_category)
 * The form element 'htmleditor', which was deprecated in 3.6, has been removed.
+* The `core_output_load_fontawesome_icon_map` web service has been deprecated and replaced by
+  `core_output_load_fontawesome_icon_system_map` which takes the name of the theme to generate the icon system map for.
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index d66df9f..6707bfe 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js and b/mod/assign/amd/build/grading_navigation.min.js differ
index 263adb4..798e324 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js.map and b/mod/assign/amd/build/grading_navigation.min.js.map differ
index 9f6a349..5c85fb9 100644 (file)
@@ -229,10 +229,13 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         // There are 3 types of filter right now.
         var filterPanel = this._region.find('[data-region="configure-filters"]');
         var filters = filterPanel.find('select');
+        var preferenceNames = [];
 
         this._filters = [];
         filters.each(function(idx, ele) {
-            this._filters.push($(ele).val());
+            var element = $(ele);
+            this._filters.push(element.val());
+            preferenceNames.push('assign_' + element.prop('name'));
         }.bind(this));
 
         // Update the active filter string.
@@ -250,7 +253,6 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
 
         var select = this._region.find('[data-action=change-user]');
         var currentUserID = select.data('currentuserid');
-        var preferenceNames = ['assign_filter', 'assign_workflowfilter', 'assign_markerfilter'];
         this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {
             // Reload the list of users to apply the new filters.
             if (!this._loadAllUsers()) {
index 140f3fb..907f7fa 100644 (file)
@@ -794,7 +794,11 @@ class mod_assign_external extends external_api {
                         'gradingstatus' => $assign->get_grading_status($submissionrecord->userid)
                     );
 
-                    if ($assign->can_view_submission($submissionrecord->userid)) {
+                    if (($assign->get_instance()->teamsubmission
+                        && $assign->can_view_group_submission($submissionrecord->groupid))
+                        || (!$assign->get_instance()->teamsubmission
+                        && $assign->can_view_submission($submissionrecord->userid))
+                    ) {
                         $submissions[] = $submission;
                     }
                 }
index 07a7b48..0804647 100644 (file)
@@ -46,7 +46,7 @@ class mod_assign_grading_options_form extends moodleform {
 
         $mform->addElement('header', 'general', get_string('gradingoptions', 'assign'));
         // Visible elements.
-        $options = array(-1 => get_string('all'), 10 => '10', 20 => '20', 50 => '50', 100 => '100');
+        $options = array(10 => '10', 20 => '20', 50 => '50', 100 => '100', -1 => get_string('all'));
         $maxperpage = get_config('assign', 'maxperpage');
         if (isset($maxperpage) && $maxperpage != -1) {
             unset($options[-1]);
index 1125b73..effc9df 100644 (file)
@@ -201,17 +201,15 @@ foreach ($overrides as $override) {
     // Icons.
     $iconstr = '';
 
-    if ($active) {
-        // Edit.
-        $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
-        $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
-                $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
-        // Duplicate.
-        $copyurlstr = $overrideediturl->out(true,
-                array('id' => $override->id, 'action' => 'duplicate'));
-        $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
-                $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
-    }
+    // Edit.
+    $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
+    $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
+            $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
+    // Duplicate.
+    $copyurlstr = $overrideediturl->out(true,
+            array('id' => $override->id, 'action' => 'duplicate'));
+    $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
+            $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
     // Delete.
     $deleteurlstr = $overridedeleteurl->out(true,
             array('id' => $override->id, 'sesskey' => sesskey()));
index ef542ce..9f1e039 100644 (file)
@@ -277,3 +277,28 @@ Feature: Assign user override
     And I navigate to "User overrides" in current page administration
     Then I should see "Student1" in the ".generaltable" "css_element"
     And I should not see "Student2" in the ".generaltable" "css_element"
+
+  @javascript
+  Scenario: Create a user override when the assignment is not available to the student
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test assignment name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Availability" to "Hide from students"
+    And I click on "Save and display" "button"
+    When I navigate to "User overrides" in current page administration
+    And I press "Add user override"
+    And I set the following fields to these values:
+      | Override user                       | Student1 |
+      | id_allowsubmissionsfromdate_enabled | 1       |
+      | allowsubmissionsfromdate[day]       | 1       |
+      | allowsubmissionsfromdate[month]     | January |
+      | allowsubmissionsfromdate[year]      | 2015    |
+      | allowsubmissionsfromdate[hour]      | 08      |
+      | allowsubmissionsfromdate[minute]    | 00      |
+    And I press "Save"
+    Then I should see "This override is inactive"
+    And "Edit" "icon" should exist in the "Sam1 Student1" "table_row"
+    And "copy" "icon" should exist in the "Sam1 Student1" "table_row"
+    And "Delete" "icon" should exist in the "Sam1 Student1" "table_row"
diff --git a/mod/assign/tests/behat/grading_app_filters.feature b/mod/assign/tests/behat/grading_app_filters.feature
new file mode 100644 (file)
index 0000000..92fd41f
--- /dev/null
@@ -0,0 +1,109 @@
+@mod @mod_assign
+Feature: In an assignment, teachers can change filters in the grading app
+  In order to manage submissions more easily
+  As a teacher
+  I need to preserve filter settings between the grader app and grading table.
+
+  @javascript
+  Scenario: Set filters in the grading table and see them in the grading app
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I set the field "filter" to "Not submitted"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
+
+  @javascript
+  Scenario: Set filters in the grading app and see them in the grading table
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "filter" to "Not submitted"
+    # The popup closes for some reason, so it needs to be reopened.
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "View all submissions" "link"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
index 4f83213..2bd4000 100644 (file)
@@ -454,6 +454,48 @@ class mod_assign_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(1, count($result['assignments']));
     }
 
+    public function test_get_submissions_group_submission() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $result = $this->create_assign_with_student_and_teacher(array(
+            'assignsubmission_onlinetext_enabled' => 1,
+            'teamsubmission' => 1
+        ));
+        $assignmodule = $result['assign'];
+        $student = $result['student'];
+        $teacher = $result['teacher'];
+        $course = $result['course'];
+        $context = context_course::instance($course->id);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+        $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $cm = get_coursemodule_from_instance('assign', $assignmodule->id);
+        $context = context_module::instance($cm->id);
+        $assign = new mod_assign_testable_assign($context, $cm, $course);
+
+        groups_add_member($group, $student);
+
+        $this->setUser($student);
+        $submission = $assign->get_group_submission($student->id, $group->id, true);
+        $sid = $submission->id;
+
+        $this->setUser($teacher);
+
+        $assignmentids[] = $assignmodule->id;
+        $result = mod_assign_external::get_submissions($assignmentids);
+        $result = external_api::clean_returnvalue(mod_assign_external::get_submissions_returns(), $result);
+
+        $this->assertEquals(1, count($result['assignments']));
+        $assignment = $result['assignments'][0];
+        $this->assertEquals($assignmodule->id, $assignment['assignmentid']);
+        $this->assertEquals(1, count($assignment['submissions']));
+        $submission = $assignment['submissions'][0];
+        $this->assertEquals($sid, $submission['id']);
+        $this->assertEquals($group->id, $submission['groupid']);
+        $this->assertEquals(0, $submission['userid']);
+    }
+
     /**
      * Test get_user_flags
      */
index 657dcfb..9ff3cdd 100644 (file)
@@ -218,17 +218,15 @@ foreach ($overrides as $override) {
     // Icons.
     $iconstr = '';
 
-    if ($active) {
-        // Edit.
-        $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
-        $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
-                $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
-        // Duplicate.
-        $copyurlstr = $overrideediturl->out(true,
-                array('id' => $override->id, 'action' => 'duplicate'));
-        $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
-                $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
-    }
+    // Edit.
+    $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
+    $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
+            $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
+    // Duplicate.
+    $copyurlstr = $overrideediturl->out(true,
+            array('id' => $override->id, 'action' => 'duplicate'));
+    $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
+            $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
     // Delete.
     $deleteurlstr = $overridedeleteurl->out(true,
             array('id' => $override->id, 'sesskey' => sesskey()));
index e6b9fd0..e33b5b4 100644 (file)
@@ -210,6 +210,7 @@ Feature: Lesson user override
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I follow "Test lesson"
+    And I wait until the page is ready
     Then I should see "This lesson closed on Saturday, 1 January 2000, 8:00"
     And I should not see "Cat is an amphibian"
     And I log out
@@ -248,7 +249,8 @@ Feature: Lesson user override
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I follow "Test lesson"
-    Then  I should see "This lesson will be open on Tuesday, 1 January 2030, 8:00"
+    And I wait until the page is ready
+    Then I should see "This lesson will be open on Tuesday, 1 January 2030, 8:00"
     And I should not see "Cat is an amphibian"
     And I log out
     And I log in as "student1"
@@ -383,3 +385,23 @@ Feature: Lesson user override
     And I navigate to "User overrides" in current page administration
     Then I should see "Student1" in the ".generaltable" "css_element"
     And I should not see "Student2" in the ".generaltable" "css_element"
+
+  @javascript
+  Scenario: Create a user override when the lesson is not available to the student
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Availability" to "Hide from students"
+    And I click on "Save and display" "button"
+    When I navigate to "User overrides" in current page administration
+    And I press "Add user override"
+    And I set the following fields to these values:
+      | Override user              | Student1 |
+      | Maximum number of attempts | 2 |
+    And I press "Save"
+    Then I should see "This override is inactive"
+    And "Edit" "icon" should exist in the "Sam1 Student1" "table_row"
+    And "copy" "icon" should exist in the "Sam1 Student1" "table_row"
+    And "Delete" "icon" should exist in the "Sam1 Student1" "table_row"
index 08e19a3..40077b4 100644 (file)
@@ -41,6 +41,7 @@ class behat_quizaccess_seb_generator extends behat_generator_base {
     protected function get_creatable_entities(): array {
         return [
             'seb templates' => [
+                'singular' => 'seb template',
                 'datagenerator' => 'template',
                 'required' => ['name'],
             ],
index ae65681..0e5e071 100644 (file)
@@ -50,7 +50,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
             'sumgrades', 'grade', 'timecreated',
             'timemodified', 'password', 'subnet', 'browsersecurity',
             'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'completionpass',
-            'allowofflineattempts'));
+            'completionminattempts', 'allowofflineattempts'));
 
         // Define elements for access rule subplugin settings.
         $this->add_subplugin_structure('quizaccess', $quiz, true);
index b3d09f6..8d7524e 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/quiz/db" VERSION="20180719" COMMENT="XMLDB file for Moodle mod/quiz"
+<XMLDB PATH="mod/quiz/db" VERSION="20200615" COMMENT="XMLDB file for Moodle mod/quiz"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -46,6 +46,7 @@
         <FIELD NAME="showblocks" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether blocks should be shown on the attempt.php and review.php pages."/>
         <FIELD NAME="completionattemptsexhausted" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="completionpass" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="completionminattempts" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="allowofflineattempts" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Whether to allow the quiz to be attempted offline in the mobile app"/>
       </FIELDS>
       <KEYS>
index ecfe6c8..fbd488a 100644 (file)
@@ -29,7 +29,8 @@ defined('MOODLE_INTERNAL') || die();
  * @param string $oldversion the version we are upgrading from.
  */
 function xmldb_quiz_upgrade($oldversion) {
-    global $CFG;
+    global $CFG, $DB;
+    $dbman = $DB->get_manager();
 
     // Automatically generated Moodle v3.5.0 release upgrade line.
     // Put any upgrade step following this.
@@ -46,5 +47,21 @@ function xmldb_quiz_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020061501) {
+
+        // Define field completionminattempts to be added to quiz.
+        $table = new xmldb_table('quiz');
+        $field = new xmldb_field('completionminattempts', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0',
+            'completionpass');
+
+        // Conditionally launch add field completionminattempts.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2020061501, 'quiz');
+    }
+
     return true;
 }
index 5ac9d24..b268444 100644 (file)
@@ -179,6 +179,9 @@ $string['comment'] = 'Comment';
 $string['commentorgrade'] = 'Make comment or override grade';
 $string['comments'] = 'Comments';
 $string['completedon'] = 'Completed on';
+$string['completionminattempts'] = 'Student must send attempts:';
+$string['completionminattemptsgroup'] = 'Require attempts';
+$string['completionminattemptserror'] = 'Minimum number of attempts must be lower or equal to attempts allowed.';
 $string['completionpass'] = 'Require passing grade';
 $string['completionpassdesc'] = 'Student must achieve a passing grade to complete this activity';
 $string['completionpass_help'] = 'If enabled, this activity is considered complete when the student receives a pass grade (as specified in the Grade section of the quiz settings) or higher.';
index 7330c3e..024ed1a 100644 (file)
@@ -1128,6 +1128,9 @@ function quiz_process_options($quiz) {
     if (empty($quiz->completionpass)) {
         $quiz->completionattemptsexhausted = 0;
     }
+    if (empty($quiz->completionminattemptsenabled)) {
+        $quiz->completionminattempts = 0;
+    }
 }
 
 /**
@@ -1894,6 +1897,74 @@ function quiz_get_navigation_options() {
     );
 }
 
+/**
+ * Internal function used in quiz_get_completion_state. Check passing grade (or no attempts left) requirement for completion.
+ *
+ * @param object $course
+ * @param object $cm
+ * @param int $userid
+ * @param object $quiz
+ * @return bool True if the passing grade (or no attempts left) requirement is disabled or met.
+ * @throws coding_exception
+ */
+function quiz_completion_check_passing_grade_or_all_attempts($course, $cm, $userid, $quiz) {
+    global $CFG;
+
+    if (!$quiz->completionpass) {
+        return true;
+    }
+
+    // Check for passing grade.
+    require_once($CFG->libdir . '/gradelib.php');
+    $item = grade_item::fetch(array('courseid' => $course->id, 'itemtype' => 'mod',
+        'itemmodule' => 'quiz', 'iteminstance' => $cm->instance, 'outcomeid' => null));
+    if ($item) {
+        $grades = grade_grade::fetch_users_grades($item, array($userid), false);
+        if (!empty($grades[$userid]) && $grades[$userid]->is_passed($item)) {
+            return true;
+        }
+    }
+
+    // If a passing grade is required and exhausting all available attempts is not accepted for completion,
+    // then this quiz is not complete.
+    if (!$quiz->completionattemptsexhausted) {
+        return false;
+    }
+
+    // Check if all attempts are used up.
+    $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished', true);
+    if (!$attempts) {
+        return false;
+    }
+    $lastfinishedattempt = end($attempts);
+    $context = context_module::instance($cm->id);
+    $quizobj = quiz::create($quiz->id, $userid);
+    $accessmanager = new quiz_access_manager($quizobj, time(),
+        has_capability('mod/quiz:ignoretimelimits', $context, $userid, false));
+
+    return $accessmanager->is_finished(count($attempts), $lastfinishedattempt);
+}
+
+/**
+ * Internal function used in quiz_get_completion_state. Check minimum attempts requirement for completion.
+ *
+ * @param int $userid
+ * @param object $quiz
+ * @return bool True if minimum attempts requirement is disabled or met.
+ * @throws coding_exception
+ */
+function quiz_completion_check_min_attempts($userid, $quiz) {
+    global $DB;
+
+    if (empty($quiz->completionminattempts)) {
+        return true;
+    }
+
+    // Check if the user has done enough attempts.
+    $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished', true);
+    return $quiz->completionminattempts <= count($attempts);
+}
+
 /**
  * Obtains the automatic completion state for this quiz on any conditions
  * in quiz settings, such as if all attempts are used or a certain grade is achieved.
@@ -1907,41 +1978,21 @@ function quiz_get_navigation_options() {
  */
 function quiz_get_completion_state($course, $cm, $userid, $type) {
     global $DB;
-    global $CFG;
 
     $quiz = $DB->get_record('quiz', array('id' => $cm->instance), '*', MUST_EXIST);
-    if (!$quiz->completionattemptsexhausted && !$quiz->completionpass) {
+    if (!$quiz->completionattemptsexhausted && !$quiz->completionpass && !$quiz->completionminattempts) {
         return $type;
     }
 
-    // Check if the user has used up all attempts.
-    if ($quiz->completionattemptsexhausted) {
-        $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished', true);
-        if ($attempts) {
-            $lastfinishedattempt = end($attempts);
-            $context = context_module::instance($cm->id);
-            $quizobj = quiz::create($quiz->id, $userid);
-            $accessmanager = new quiz_access_manager($quizobj, time(),
-                    has_capability('mod/quiz:ignoretimelimits', $context, $userid, false));
-            if ($accessmanager->is_finished(count($attempts), $lastfinishedattempt)) {
-                return true;
-            }
-        }
+    if (!quiz_completion_check_passing_grade_or_all_attempts($course, $cm, $userid, $quiz)) {
+        return false;
     }
 
-    // Check for passing grade.
-    if ($quiz->completionpass) {
-        require_once($CFG->libdir . '/gradelib.php');
-        $item = grade_item::fetch(array('courseid' => $course->id, 'itemtype' => 'mod',
-                'itemmodule' => 'quiz', 'iteminstance' => $cm->instance, 'outcomeid' => null));
-        if ($item) {
-            $grades = grade_grade::fetch_users_grades($item, array($userid), false);
-            if (!empty($grades[$userid])) {
-                return $grades[$userid]->is_passed($item);
-            }
-        }
+    if (!quiz_completion_check_min_attempts($userid, $quiz)) {
+        return false;
     }
-    return false;
+
+    return true;
 }
 
 /**
index a799e0f..7768db8 100644 (file)
@@ -1799,7 +1799,8 @@ function quiz_attempt_submitted_handler($event) {
 
     // Update completion state.
     $completion = new completion_info($course);
-    if ($completion->is_enabled($cm) && ($quiz->completionattemptsexhausted || $quiz->completionpass)) {
+    if ($completion->is_enabled($cm) &&
+        ($quiz->completionattemptsexhausted || $quiz->completionpass || $quiz->completionminattempts)) {
         $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid);
     }
     return quiz_send_notification_messages($course, $quiz, $attempt,
index daad6b9..9f3c90e 100644 (file)
@@ -481,6 +481,31 @@ class mod_quiz_mod_form extends moodleform_mod {
                 $toform[$name] = $value;
             }
         }
+
+        if (empty($toform['completionminattempts'])) {
+            $toform['completionminattempts'] = 1;
+        } else {
+            $toform['completionminattemptsenabled'] = $toform['completionminattempts'] > 0;
+        }
+    }
+
+    /**
+     * Allows module to modify the data returned by form get_data().
+     * This method is also called in the bulk activity completion form.
+     *
+     * Only available on moodleform_mod.
+     *
+     * @param stdClass $data the form data to be modified.
+     */
+    public function data_postprocessing($data) {
+        parent::data_postprocessing($data);
+        if (!empty($data->completionunlocked)) {
+            // Turn off completion settings if the checkboxes aren't ticked.
+            $autocompletion = !empty($data->completion) && $data->completion == COMPLETION_TRACKING_AUTOMATIC;
+            if (empty($data->completionminattemptsenabled) || !$autocompletion) {
+                $data->completionminattempts = 0;
+            }
+        }
     }
 
     public function validation($data, $files) {
@@ -513,6 +538,12 @@ class mod_quiz_mod_form extends moodleform_mod {
             }
         }
 
+        if (!empty($data['completionminattempts'])) {
+            if ($data['attempts'] > 0 && $data['completionminattempts'] > $data['attempts']) {
+                $errors['completionminattemptsgroup'] = get_string('completionminattemptserror', 'quiz');
+            }
+        }
+
         // Check the boundary value is a number or a percentage, and in range.
         $i = 0;
         while (!empty($data['feedbackboundaries'][$i] )) {
@@ -593,6 +624,17 @@ class mod_quiz_mod_form extends moodleform_mod {
         $mform->addGroup($group, 'completionpassgroup', get_string('completionpass', 'quiz'), ' &nbsp; ', false);
         $mform->addHelpButton('completionpassgroup', 'completionpass', 'quiz');
         $items[] = 'completionpassgroup';
+
+        $group = array();
+        $group[] = $mform->createElement('checkbox', 'completionminattemptsenabled', '',
+            get_string('completionminattempts', 'quiz'));
+        $group[] = $mform->createElement('text', 'completionminattempts', '', array('size' => 3));
+        $mform->setType('completionminattempts', PARAM_INT);
+        $mform->addGroup($group, 'completionminattemptsgroup', get_string('completionminattemptsgroup', 'quiz'), array(' '), false);
+        $mform->disabledIf('completionminattempts', 'completionminattemptsenabled', 'notchecked');
+
+        $items[] = 'completionminattemptsgroup';
+
         return $items;
     }
 
@@ -603,7 +645,9 @@ class mod_quiz_mod_form extends moodleform_mod {
      * @return bool True if one or more rules is enabled, false if none are.
      */
     public function completion_rule_enabled($data) {
-        return !empty($data['completionattemptsexhausted']) || !empty($data['completionpass']);
+        return  !empty($data['completionattemptsexhausted']) ||
+                !empty($data['completionpass']) ||
+                !empty($data['completionminattemptsenabled']);
     }
 
     /**
index 9ede921..8409058 100644 (file)
@@ -194,9 +194,9 @@ M.mod_quiz.nav.init = function(Y) {
                 pageno = 0;
             }
 
-            var questionidmatch = this.get('href').match(/#q(\d+)/);
+            var questionidmatch = this.get('href').match(/#question-(\d+)-(\d+)/);
             if (questionidmatch) {
-                form.set('action', form.get('action') + '#q' + questionidmatch[1]);
+                form.set('action', form.get('action') + questionidmatch[0]);
             }
 
             nav_to_page(pageno);
diff --git a/mod/quiz/tests/behat/completion_condition_minimum_attempts.feature b/mod/quiz/tests/behat/completion_condition_minimum_attempts.feature
new file mode 100644 (file)
index 0000000..3559a19
--- /dev/null
@@ -0,0 +1,56 @@
+@mod @mod_quiz
+Feature: Set a quiz to be marked complete when the student completes a minimum amount of attempts
+  In order to ensure a student has completed the quiz before being marked complete
+  As a teacher
+  I need to set a quiz to complete when the student completes a certain amount of attempts
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1        | 0        | 1                |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And the following config values are set as admin:
+      | grade_item_advanced | hiddenuntil |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name           | questiontext              |
+      | Test questions   | truefalse | First question | Answer the first question |
+    And the following "activities" exist:
+      | activity | name           | course | idnumber | completion | completionminattemptsenabled | completionminattempts |
+      | quiz     | Test quiz name | C1     | quiz1    | 2          | 1                            | 2                     |
+    And quiz "Test quiz name" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And user "student1" has attempted "Test quiz name" with responses:
+      | slot | response |
+      |   1  | False    |
+
+  Scenario: student1 uses up both attempts without passing
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And "Completed: Test quiz name" "icon" should not exist in the "Test quiz name" "list_item"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
+    And I follow "Test quiz name"
+    And I press "Re-attempt quiz"
+    And I set the field "False" to "1"
+    And I press "Finish attempt ..."
+    And I press "Submit all and finish"
+    And I am on "Course 1" course homepage
+    Then "Completed: Test quiz name" "icon" should exist in the "Test quiz name" "list_item"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Reports > Activity completion" in current page administration
+    And "Completed" "icon" should exist in the "Student 1" "table_row"
index 344488f..408345d 100644 (file)
@@ -63,7 +63,10 @@ Feature: Quiz user override
       | Override user    | Student1 |
       | Attempts allowed | 1        |
     And I press "Save"
-    Then "Edit" "icon" should exist in the "Student One" "table_row"
+    Then I should see "This override is inactive"
+    And "Edit" "icon" should exist in the "Student One" "table_row"
+    And "copy" "icon" should exist in the "Student One" "table_row"
+    And "Delete" "icon" should exist in the "Student One" "table_row"
 
   Scenario: A teacher without accessallgroups permission should only be able to add user override for users that he/she shares groups with,
         when the activity's group mode is to "separate groups"
index 55a8145..fc9342d 100644 (file)
@@ -37,11 +37,13 @@ class behat_mod_quiz_generator extends behat_generator_base {
     protected function get_creatable_entities(): array {
         return [
             'group overrides' => [
+                'singular' => 'group override',
                 'datagenerator' => 'override',
                 'required' => ['quiz', 'group'],
                 'switchids' => ['quiz' => 'quiz', 'group' => 'groupid'],
             ],
             'user overrides' => [
+                'singular' => 'user override',
                 'datagenerator' => 'override',
                 'required' => ['quiz', 'user'],
                 'switchids' => ['quiz' => 'quiz', 'user' => 'userid'],
index cccfed7..3605831 100644 (file)
@@ -130,105 +130,270 @@ class mod_quiz_lib_testcase extends advanced_testcase {
     }
 
     /**
-     * Test checking the completion state of a quiz.
+     * Setup function for all test_quiz_get_completion_state_* tests.
+     *
+     * @param array $completionoptions ['nbstudents'] => int, ['qtype'] => string, ['quizoptions'] => array
+     * @throws dml_exception
+     * @return array [$course, $students, $quiz, $cm]
      */
-    public function test_quiz_get_completion_state() {
+    private function setup_quiz_for_testing_completion(array $completionoptions) {
         global $CFG, $DB;
+
         $this->resetAfterTest(true);
 
         // Enable completion before creating modules, otherwise the completion data is not written in DB.
         $CFG->enablecompletion = true;
 
-        // Create a course and student.
-        $course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
-        $passstudent = $this->getDataGenerator()->create_user();
-        $failstudent = $this->getDataGenerator()->create_user();
-        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->assertNotEmpty($studentrole);
-
-        // Enrol students.
-        $this->assertTrue($this->getDataGenerator()->enrol_user($passstudent->id, $course->id, $studentrole->id));
-        $this->assertTrue($this->getDataGenerator()->enrol_user($failstudent->id, $course->id, $studentrole->id));
-
-        // Make a scale and an outcome.
-        $scale = $this->getDataGenerator()->create_scale();
-        $data = array('courseid' => $course->id,
-                      'fullname' => 'Team work',
-                      'shortname' => 'Team work',
-                      'scaleid' => $scale->id);
-        $outcome = $this->getDataGenerator()->create_grade_outcome($data);
+        // Create a course and students.
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => true]);
+        $students = [];
+        for ($i = 0; $i < $completionoptions['nbstudents']; $i++) {
+            $students[$i] = $this->getDataGenerator()->create_user();
+            $this->assertTrue($this->getDataGenerator()->enrol_user($students[$i]->id, $course->id, $studentrole->id));
+        }
 
-        // Make a quiz with the outcome on.
+        // Make a quiz.
         $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
-        $data = array('course' => $course->id,
-                      'outcome_'.$outcome->id => 1,
-                      'grade' => 100.0,
-                      'questionsperpage' => 0,
-                      'sumgrades' => 1,
-                      'completion' => COMPLETION_TRACKING_AUTOMATIC,
-                      'completionusegrade' => 1,
-                      'completionpass' => 1);
+        $data = array_merge([
+            'course' => $course->id,
+            'grade' => 100.0,
+            'questionsperpage' => 0,
+            'sumgrades' => 1,
+            'completion' => COMPLETION_TRACKING_AUTOMATIC
+        ], $completionoptions['quizoptions']);
         $quiz = $quizgenerator->create_instance($data);
         $cm = get_coursemodule_from_id('quiz', $quiz->cmid);
 
-        // Create a couple of questions.
+        // Create a question.
         $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 
         $cat = $questiongenerator->create_question_category();
-        $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
+        $question = $questiongenerator->create_question($completionoptions['qtype'], null, ['category' => $cat->id]);
         quiz_add_quiz_question($question->id, $quiz);
 
-        $quizobj = quiz::create($quiz->id, $passstudent->id);
-
         // Set grade to pass.
-        $item = grade_item::fetch(array('courseid' => $course->id, 'itemtype' => 'mod',
-                                        'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
+        $item = grade_item::fetch(['courseid' => $course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz',
+            'iteminstance' => $quiz->id, 'outcomeid' => null]);
         $item->gradepass = 80;
         $item->update();
 
+        return [
+            $course,
+            $students,
+            $quiz,
+            $cm
+        ];
+    }
+
+    /**
+     * Helper function for all test_quiz_get_completion_state_* tests.
+     * Starts an attempt, processes responses and finishes the attempt.
+     *
+     * @param $attemptoptions ['quiz'] => object, ['student'] => object, ['tosubmit'] => array, ['attemptnumber'] => int
+     */
+    private function do_attempt_quiz($attemptoptions) {
+        $quizobj = quiz::create($attemptoptions['quiz']->id);
+
         // Start the passing attempt.
         $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
         $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 
         $timenow = time();
-        $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $passstudent->id);
-        quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
+        $attempt = quiz_create_attempt($quizobj, $attemptoptions['attemptnumber'], false, $timenow, false,
+            $attemptoptions['student']->id);
+        quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptoptions['attemptnumber'], $timenow);
         quiz_attempt_save_started($quizobj, $quba, $attempt);
 
-        // Process some responses from the student.
+        // Process responses from the student.
         $attemptobj = quiz_attempt::create($attempt->id);
-        $tosubmit = array(1 => array('answer' => '3.14'));
-        $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
+        $attemptobj->process_submitted_actions($timenow, false, $attemptoptions['tosubmit']);
 
         // Finish the attempt.
         $attemptobj = quiz_attempt::create($attempt->id);
         $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
         $attemptobj->process_finish($timenow, false);
+    }
 
-        // Start the failing attempt.
-        $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
-        $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
-
-        $timenow = time();
-        $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $failstudent->id);
-        quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
-        quiz_attempt_save_started($quizobj, $quba, $attempt);
+    /**
+     * Test checking the completion state of a quiz.
+     * The quiz requires a passing grade to be completed.
+     */
+    public function test_quiz_get_completion_state_completionpass() {
+
+        list($course, $students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([
+            'nbstudents' => 2,
+            'qtype' => 'numerical',
+            'quizoptions' => [
+                'completionusegrade' => 1,
+                'completionpass' => 1
+            ]
+        ]);
 
-        // Process some responses from the student.
-        $attemptobj = quiz_attempt::create($attempt->id);
-        $tosubmit = array(1 => array('answer' => '0'));
-        $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
+        list($passstudent, $failstudent) = $students;
 
-        // Finish the attempt.
-        $attemptobj = quiz_attempt::create($attempt->id);
-        $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
-        $attemptobj->process_finish($timenow, false);
+        // Do a passing attempt.
+        $this->do_attempt_quiz([
+           'quiz' => $quiz,
+           'student' => $passstudent,
+           'attemptnumber' => 1,
+           'tosubmit' => [1 => ['answer' => '3.14']]
+        ]);
 
         // Check the results.
         $this->assertTrue(quiz_get_completion_state($course, $cm, $passstudent->id, 'return'));
+
+        // Do a failing attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $failstudent,
+            'attemptnumber' => 1,
+            'tosubmit' => [1 => ['answer' => '0']]
+        ]);
+
+        // Check the results.
         $this->assertFalse(quiz_get_completion_state($course, $cm, $failstudent->id, 'return'));
     }
 
+    /**
+     * Test checking the completion state of a quiz.
+     * To be completed, this quiz requires either a passing grade or for all attempts to be used up.
+     */
+    public function test_quiz_get_completion_state_completionexhausted() {
+
+        list($course, $students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([
+            'nbstudents' => 2,
+            'qtype' => 'numerical',
+            'quizoptions' => [
+                'attempts' => 2,
+                'completionusegrade' => 1,
+                'completionpass' => 1,
+                'completionattemptsexhausted' => 1
+            ]
+        ]);
+
+        list($passstudent, $exhauststudent) = $students;
+
+        // Start a passing attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $passstudent,
+            'attemptnumber' => 1,
+            'tosubmit' => [1 => ['answer' => '3.14']]
+        ]);
+
+        // Check the results. Quiz is completed by $passstudent because of passing grade.
+        $this->assertTrue(quiz_get_completion_state($course, $cm, $passstudent->id, 'return'));
+
+        // Do a failing attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $exhauststudent,
+            'attemptnumber' => 1,
+            'tosubmit' => [1 => ['answer' => '0']]
+        ]);
+
+        // Check the results. Quiz is not completed by $exhauststudent yet because of failing grade and of remaining attempts.
+        $this->assertFalse(quiz_get_completion_state($course, $cm, $exhauststudent->id, 'return'));
+
+        // Do a second failing attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $exhauststudent,
+            'attemptnumber' => 2,
+            'tosubmit' => [1 => ['answer' => '0']]
+        ]);
+
+        // Check the results. Quiz is completed by $exhauststudent because there are no remaining attempts.
+        $this->assertTrue(quiz_get_completion_state($course, $cm, $exhauststudent->id, 'return'));
+    }
+
+    /**
+     * Test checking the completion state of a quiz.
+     * To be completed, this quiz requires a minimum number of attempts.
+     */
+    public function test_quiz_get_completion_state_completionminattempts() {
+
+        list($course, $students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([
+            'nbstudents' => 1,
+            'qtype' => 'essay',
+            'quizoptions' => [
+                'completionminattemptsenabled' => 1,
+                'completionminattempts' => 2
+            ]
+        ]);
+
+        list($student) = $students;
+
+        // Do a first attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $student,
+            'attemptnumber' => 1,
+            'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']]
+        ]);
+
+        // Check the results. Quiz is not completed yet because only one attempt was done.
+        $this->assertFalse(quiz_get_completion_state($course, $cm, $student->id, 'return'));
+
+        // Do a second attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $student,
+            'attemptnumber' => 2,
+            'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']]
+        ]);
+
+        // Check the results. Quiz is completed by $student because two attempts were done.
+        $this->assertTrue(quiz_get_completion_state($course, $cm, $student->id, 'return'));
+    }
+
+    /**
+     * Test checking the completion state of a quiz.
+     * To be completed, this quiz requires a minimum number of attempts AND a passing grade.
+     * This is somewhat of an edge case as it is hard to imagine a scenario in which these precise settings are useful.
+     * Nevertheless, this test makes sure these settings interact as intended.
+     */
+    public function  test_quiz_get_completion_state_completionminattempts_pass() {
+
+        list($course, $students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([
+            'nbstudents' => 1,
+            'qtype' => 'numerical',
+            'quizoptions' => [
+                'attempts' => 2,
+                'completionusegrade' => 1,
+                'completionpass' => 1,
+                'completionminattemptsenabled' => 1,
+                'completionminattempts' => 2
+            ]
+        ]);
+
+        list($student) = $students;
+
+        // Start a first attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $student,
+            'attemptnumber' => 1,
+            'tosubmit' => [1 => ['answer' => '3.14']]
+        ]);
+
+        // Check the results. Even though one requirement is met (passing grade) quiz is not completed yet because only
+        // one attempt was done.
+        $this->assertFalse(quiz_get_completion_state($course, $cm, $student->id, 'return'));
+
+        // Start a second attempt.
+        $this->do_attempt_quiz([
+            'quiz' => $quiz,
+            'student' => $student,
+            'attemptnumber' => 2,
+            'tosubmit' => [1 => ['answer' => '42']]
+        ]);
+
+        // Check the results. Quiz is completed by $student because two attempts were done AND a passing grade was obtained.
+        $this->assertTrue(quiz_get_completion_state($course, $cm, $student->id, 'return'));
+    }
+
     public function test_quiz_get_user_attempts() {
         global $DB;
         $this->resetAfterTest();
index b77bc51..6ec52e2 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061500;
+$plugin->version   = 2020061501;
 $plugin->requires  = 2020060900;
 $plugin->component = 'mod_quiz';
index 86681d5..91a76bc 100644 (file)
@@ -66,7 +66,7 @@ class qbehaviour_interactive_renderer extends qbehaviour_renderer {
         $output = html_writer::empty_tag('input', $attributes);
         if (empty($attributes['disabled'])) {
             $this->page->requires->js_init_call('M.core_question_engine.init_submit_button',
-                    array($attributes['id'], $qa->get_slot()));
+                    array($attributes['id']));
         }
         return $output;
     }
index c2a8a8a..76cc1a7 100644 (file)
@@ -243,7 +243,7 @@ abstract class qbehaviour_renderer extends plugin_renderer_base {
         $output = html_writer::empty_tag('input', $attributes);
         if (!$options->readonly) {
             $this->page->requires->js_init_call('M.core_question_engine.init_submit_button',
-                    array($attributes['id'], $qa->get_slot()));
+                    array($attributes['id']));
         }
         return $output;
     }
index 235e953..e29055e 100644 (file)
@@ -1,5 +1,11 @@
 This files describes API changes for question behaviour plugins.
 
+=== 4.0 ===
+
+1) The slot parameter of method M.core_question_engine.init_submit_button now removed.
+   The method will get the unique id by using the 'Check' button element.
+
+
 === 3.1 ===
 
 1) The standard behaviours that use a 'Check' button have all been changed so
index dc37efd..e14f918 100644 (file)
@@ -123,14 +123,18 @@ M.core_question_engine.questionformalreadysubmitted = false;
 /**
  * Initialise a question submit button. This saves the scroll position and
  * sets the fragment on the form submit URL so the page reloads in the right place.
- * @param id the id of the button in the HTML.
- * @param slot the number of the question_attempt within the usage.
+ * @param button the id of the button in the HTML.
  */
-M.core_question_engine.init_submit_button = function(Y, button, slot) {
+M.core_question_engine.init_submit_button = function(Y, button) {
+    var totalQuestionsInPage = document.querySelectorAll('div.que').length;
     var buttonel = document.getElementById(button);
+    var outeruniqueid = buttonel.closest('.que').id;
     Y.on('click', function(e) {
         M.core_scroll_manager.save_scroll_pos(Y, button);
-        buttonel.form.action = buttonel.form.action + '#q' + slot;
+        if (totalQuestionsInPage > 1) {
+            // Only change the form action if the page have more than one question.
+            buttonel.form.action = buttonel.form.action + '#' + outeruniqueid;
+        }
     }, buttonel);
 }
 
index 0dbd430..3a0eced 100644 (file)
@@ -36,6 +36,7 @@ class behat_core_question_generator extends behat_generator_base {
         // are generated by behat_core_generator.
         return [
             'Tags' => [
+                'singular' => 'Tag',
                 'datagenerator' => 'question_tag',
                 'required' => ['question', 'tag'],
                 'switchids' => ['question' => 'questionid'],
index 3f78277..83effee 100644 (file)
@@ -8,21 +8,31 @@ Feature: Clear my answers
     Given the following "users" exist:
       | username | firstname | lastname | email               |
       | student1 | S1        | Student1 | student1@moodle.com |
-    And the following "courses" exist:
-      | fullname | shortname | category |
-      | Course 1 | C1        | 0        |
+    And the following "course" exists:
+      | fullname  | Course 1  |
+      | shortname | C1        |
+      | category  | 0         |
     And the following "course enrolments" exist:
       | user     | course | role           |
       | student1 | C1     | student        |
-    And the following "question categories" exist:
-      | contextlevel | reference | name           |
-      | Course       | C1        | Test questions |
-    And the following "questions" exist:
-      | questioncategory | qtype       | name             | template    | questiontext    |
-      | Test questions   | multichoice | Multi-choice-001 | one_of_four | Question One    |
-    And the following "activities" exist:
-      | activity   | name   | intro              | course | idnumber | preferredbehaviour | canredoquestions |
-      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 1                |
+    And the following "question category" exists:
+      | contextlevel  | Course          |
+      | reference     | C1              |
+      | name          | Test questions  |
+    And the following "question" exists:
+      |  questioncategory  |  Test questions    |
+      |  qtype             |  multichoice       |
+      |  name              |  Multi-choice-001  |
+      |  template          |  one_of_four       |
+      |  questiontext      |  Question One      |
+    And the following "activity" exists:
+      |  activity            |  quiz                |
+      |  name                |  Quiz 1              |
+      |  intro               |  Quiz 1 description  |
+      |  course              |  C1                  |
+      |  idnumber            |  quiz1               |
+      |  preferredbehaviour  |  immediatefeedback   |
+      |  canredoquestions    |  1                   |
     And quiz "Quiz 1" contains the following questions:
       | question         | page |
       | Multi-choice-001 | 1    |
index 204ddeb..5854c02 100644 (file)
@@ -218,8 +218,8 @@ abstract class engine {
      * and and have the search engine back end add them
      * to the index.
      *
-     * @param iterator $iterator the iterator of documents to index
-     * @param searcharea $searcharea the area for the documents to index
+     * @param \iterator $iterator the iterator of documents to index
+     * @param base $searcharea the area for the documents to index
      * @param array $options document indexing options
      * @return array Processed document counts
      */
@@ -227,11 +227,15 @@ abstract class engine {
         $numrecords = 0;
         $numdocs = 0;
         $numdocsignored = 0;
+        $numbatches = 0;
         $lastindexeddoc = 0;
         $firstindexeddoc = 0;
         $partial = false;
         $lastprogress = manager::get_current_time();
 
+        $batchmode = $this->supports_add_document_batch();
+        $currentbatch = [];
+
         foreach ($iterator as $document) {
             // Stop if we have exceeded the time limit (and there are still more items). Always
             // do at least one second's worth of documents otherwise it will never make progress.
@@ -255,10 +259,22 @@ abstract class engine {
                 $searcharea->attach_files($document);
             }
 
-            if ($this->add_document($document, $options['indexfiles'])) {
-                $numdocs++;
+            if ($batchmode && strlen($document->get('content')) <= $this->get_batch_max_content()) {
+                $currentbatch[] = $document;
+                if (count($currentbatch) >= $this->get_batch_max_documents()) {
+                    [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']);
+                    $numdocs += $processed;
+                    $numdocsignored += $failed;
+                    $numbatches += $batches;
+                    $currentbatch = [];
+                }
             } else {
-                $numdocsignored++;
+                if ($this->add_document($document, $options['indexfiles'])) {
+                    $numdocs++;
+                } else {
+                    $numdocsignored++;
+                }
+                $numbatches++;
             }
 
             $lastindexeddoc = $document->get('modified');
@@ -279,7 +295,15 @@ abstract class engine {
             }
         }
 
-        return array($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial);
+        // Add remaining documents from batch.
+        if ($batchmode && $currentbatch) {
+            [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']);
+            $numdocs += $processed;
+            $numdocsignored += $failed;
+            $numbatches += $batches;
+        }
+
+        return [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $numbatches];
     }
 
     /**
@@ -370,6 +394,8 @@ abstract class engine {
      */
     public function optimize() {
         // Nothing by default.
+        mtrace('The ' . get_string('pluginname', $this->get_plugin_name()) .
+                ' search engine does not require automatic optimization.');
     }
 
     /**
@@ -473,6 +499,27 @@ abstract class engine {
      */
     abstract function add_document($document, $fileindexing = false);
 
+    /**
+     * Adds multiple documents to the search engine.
+     *
+     * It should return the number successfully processed, and the number of batches they were
+     * processed in (for example if you add 100 documents and there is an error processing one of
+     * those documents, and it took 4 batches, it would return [99, 1, 4]).
+     *
+     * If the engine implements this, it should return true to {@see supports_add_document_batch}.
+     *
+     * The system will only call this function with up to {@see get_batch_max_documents} documents,
+     * and each document in the batch will have content no larger than specified by
+     * {@see get_batch_max_content}.
+     *
+     * @param document[] $documents Documents to add
+     * @param bool $fileindexing True if file indexing is to be used
+     * @return int[] Array of three elements, successfully processed, failed processed, batch count
+     */
+    public function add_document_batch(array $documents, bool $fileindexing = false): array {
+        throw new \coding_exception('add_document_batch not supported by this engine');
+    }
+
     /**
      * Executes the query on the engine.
      *
@@ -653,4 +700,44 @@ abstract class engine {
     public function supports_users() {
         return false;
     }
+
+    /**
+     * Checks if the search engine supports adding documents in a batch.
+     *
+     * If it returns true to this function, the search engine must implement the add_document_batch
+     * function.
+     *
+     * @return bool True if the search engine supports adding documents in a batch
+     */
+    public function supports_add_document_batch(): bool {
+        return false;
+    }
+
+    /**
+     * Gets the maximum number of documents to send together in batch mode.
+     *
+     * Only relevant if the engine returns true to {@see supports_add_document_batch}.
+     *
+     * Can be overridden by search engine if required.
+     *
+     * @var int Number of documents to send together in batch mode, default 100.
+     */
+    public function get_batch_max_documents(): int {
+        return 100;
+    }
+
+    /**
+     * Gets the maximum size of document content to be included in a shared batch (if the
+     * document is bigger then it will be sent on its own; batching does not provide a performance
+     * improvement for big documents anyway).
+     *
+     * Only relevant if the engine returns true to {@see supports_add_document_batch}.
+     *
+     * Can be overridden by search engine if required.
+     *
+     * @return int Max size in bytes, default 1MB
+     */
+    public function get_batch_max_content(): int {
+        return 1024 * 1024;
+    }
 }
index 95e9733..458dad7 100644 (file)
@@ -1152,8 +1152,20 @@ class manager {
                     $recordset, array($searcharea, 'get_document'), $options));
             $result = $this->engine->add_documents($iterator, $searcharea, $options);
             $recordset->close();
-            if (count($result) === 5) {
-                list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
+            $batchinfo = '';
+            if (count($result) === 6) {
+                [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result;
+                // Only show the batch count if we actually batched any requests.
+                if ($batches !== $numdocs + $numdocsignored) {
+                    $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')';
+                }
+            } else if (count($result) === 5) {
+                // Backward compatibility for engines that don't return a batch count.
+                [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result;
+                // Deprecated since Moodle 4.0 MDL-68690.
+                // TODO: MDL-68776 This will be deleted in Moodle 4.4.
+                debugging('engine::add_documents() should return $batches (5-value return is deprecated)',
+                        DEBUG_DEVELOPER);
             } else {
                 throw new coding_exception('engine::add_documents() should return $partial (4-value return is deprecated)');
             }
@@ -1168,7 +1180,7 @@ class manager {
                 }
 
                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
-                        ' documents, in ' . $elapsed . ' seconds' . $partialtext . '.', 1);
+                        ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . $partialtext . '.', 1);
             } else {
                 $progress->output('No new documents to index.', 1);
             }
@@ -1305,8 +1317,20 @@ class manager {
 
             // Use this iterator to add documents.
             $result = $this->engine->add_documents($iterator, $searcharea, $options);
-            if (count($result) === 5) {
-                list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
+            $batchinfo = '';
+            if (count($result) === 6) {
+                [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result;
+                // Only show the batch count if we actually batched any requests.
+                if ($batches !== $numdocs + $numdocsignored) {
+                    $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')';
+                }
+            } else if (count($result) === 5) {
+                // Backward compatibility for engines that don't return a batch count.
+                [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result;
+                // Deprecated since Moodle 4.0 MDL-68690.
+                // TODO: MDL-68776 This will be deleted in Moodle 4.4 (as should the below bit).
+                debugging('engine::add_documents() should return $batches (5-value return is deprecated)',
+                        DEBUG_DEVELOPER);
             } else {
                 // Backward compatibility for engines that don't support partial adding.
                 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result;
@@ -1318,7 +1342,7 @@ class manager {
             if ($numdocs > 0) {
                 $elapsed = round((self::get_current_time() - $elapsed), 3);
                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
-                        ' documents, in ' . $elapsed . ' seconds' .
+                        ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' .
                         ($partial ? ' (not complete)' : '') . '.', 1);
             } else {
                 $progress->output('No documents to index.', 1);
index d047694..0d433ab 100644 (file)
@@ -753,6 +753,32 @@ class engine extends \core_search\engine {
         return true;
     }
 
+    /**
+     * Adds a batch of documents to the engine at once.
+     *
+     * @param \core_search\document[] $documents Documents to add
+     * @param bool $fileindexing If true, indexes files (these are done one at a time)
+     * @return int[] Array of three elements: successfully processed, failed processed, batch count
+     */
+    public function add_document_batch(array $documents, bool $fileindexing = false): array {
+        $docdatabatch = [];
+        foreach ($documents as $document) {
+            $docdatabatch[] = $document->export_for_engine();
+        }
+
+        $resultcounts = $this->add_solr_documents($docdatabatch);
+
+        // Files are processed one document at a time (if there are files it's slow anyway).
+        if ($fileindexing) {
+            foreach ($documents as $document) {
+                // This will take care of updating all attached files in the index.
+                $this->process_document_files($document);
+            }
+        }
+
+        return $resultcounts;
+    }
+
     /**
      * Replaces underlines at edges of words in the content with spaces.
      *
@@ -771,12 +797,12 @@ class engine extends \core_search\engine {
     }
 
     /**
-     * Adds a text document to the search engine.
+     * Creates a Solr document object.
      *
-     * @param array $doc
-     * @return bool
+     * @param array $doc Array of document fields
+     * @return \SolrInputDocument Created document
      */
-    protected function add_solr_document($doc) {
+    protected function create_solr_document(array $doc): \SolrInputDocument {
         $solrdoc = new \SolrInputDocument();
 
         // Replace underlines in the content with spaces. The reason for this is that for italic
@@ -786,10 +812,23 @@ class engine extends \core_search\engine {
             $doc['content'] = self::replace_underlines($doc['content']);
         }
 
+        // Set all the fields.
         foreach ($doc as $field => $value) {
             $solrdoc->addField($field, $value);
         }
 
+        return $solrdoc;
+    }
+
+    /**
+     * Adds a text document to the search engine.
+     *
+     * @param array $doc
+     * @return bool
+     */
+    protected function add_solr_document($doc) {
+        $solrdoc = $this->create_solr_document($doc);
+
         try {
             $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN);
             return true;
@@ -804,6 +843,50 @@ class engine extends \core_search\engine {
         return false;
     }
 
+    /**
+     * Adds multiple text documents to the search engine.
+     *
+     * @param array $docs Array of documents (each an array of fields) to add
+     * @return int[] Array of success, failure, batch count
+     * @throws \core_search\engine_exception
+     */
+    protected function add_solr_documents(array $docs): array {
+        $solrdocs = [];
+        foreach ($docs as $doc) {
+            $solrdocs[] = $this->create_solr_document($doc);
+        }
+
+        try {
+            // Add documents in a batch and report that they all succeeded.
+            $this->get_search_client()->addDocuments($solrdocs, true, static::AUTOCOMMIT_WITHIN);
+            return [count($solrdocs), 0, 1];
+        } catch (\SolrClientException $e) {
+            // If there is an exception, fall through...
+            $donothing = true;
+        } catch (\SolrServerException $e) {
+            // If there is an exception, fall through...
+            $donothing = true;
+        }
+
+        // When there is an error, we fall back to adding them individually so that we can report
+        // which document(s) failed. Since it overwrites, adding the successful ones multiple
+        // times won't hurt.
+        $success = 0;
+        $failure = 0;
+        $batches = 0;
+        foreach ($docs as $doc) {
+            $result = $this->add_solr_document($doc);
+            $batches++;
+            if ($result) {
+                $success++;
+            } else {
+                $failure++;
+            }
+        }
+
+        return [$success, $failure, $batches];
+    }
+
     /**
      * Index files attached to the docuemnt, ensuring the index matches the current document files.
      *
@@ -1120,15 +1203,6 @@ class engine extends \core_search\engine {
         return (bool)$this->config->fileindexing;
     }
 
-    /**
-     * Defragments the index.
-     *
-     * @return void
-     */
-    public function optimize() {
-        $this->get_search_client()->optimize(1, true, false);
-    }
-
     /**
      * Deletes the specified document.
      *
@@ -1446,6 +1520,15 @@ class engine extends \core_search\engine {
         return true;
     }
 
+    /**
+     * Solr supports adding documents in a batch.
+     *
+     * @return bool True
+     */
+    public function supports_add_document_batch(): bool {
+        return true;
+    }
+
     /**
      * Solr supports deleting the index for a context.
      *
index 7deb8d0..0bdae77 100644 (file)
@@ -1297,6 +1297,148 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assert_raw_solr_query_result('content:xyzzy', []);
     }
 
+    /**
+     * Specific test of the add_document_batch function (also used in many other tests).
+     */
+    public function test_add_document_batch() {
+        // Get a default document.
+        $area = new core_mocksearch\search\mock_search_area();
+        $record = $this->generator->create_record();
+        $doc = $area->get_document($record);
+        $originalid = $doc->get('id');
+
+        // Now create 5 similar documents.
+        $docs = [];
+        for ($i = 1; $i <= 5; $i++) {
+            $doc = $area->get_document($record);
+            $doc->set('id', $originalid . '-' . $i);
+            $doc->set('title', 'Batch ' . $i);
+            $docs[$i] = $doc;
+        }
+
+        // Document 3 has a file attached.
+        $fs = get_file_storage();
+        $filerecord = new \stdClass();
+        $filerecord->content = 'Some FileContents';
+        $file = $this->generator->create_file($filerecord);
+        $docs[3]->add_stored_file($file);
+
+        // Add all these documents to the search engine.
+        $this->assertEquals([5, 0, 1], $this->engine->add_document_batch($docs, true));
+        $this->engine->area_index_complete($area->get_area_id());
+
+        // Check all documents were indexed.
+        $querydata = new stdClass();
+        $querydata->q = 'Batch';
+        $results = $this->search->search($querydata);
+        $this->assertCount(5, $results);
+
+        // Check it also finds based on the file.
+        $querydata->q = 'FileContents';
+        $results = $this->search->search($querydata);
+        $this->assertCount(1, $results);
+    }
+
+    /**
+     * Tests the batching logic, specifically the limit to 100 documents per
+     * batch, and not batching very large documents.
+     */
+    public function test_batching() {
+        $area = new core_mocksearch\search\mock_search_area();
+        $record = $this->generator->create_record();
+        $doc = $area->get_document($record);
+        $originalid = $doc->get('id');
+
+        // Up to 100 documents in 1 batch.
+        $docs = [];
+        for ($i = 1; $i <= 100; $i++) {
+            $doc = $area->get_document($record);
+            $doc->set('id', $originalid . '-' . $i);
+            $docs[$i] = $doc;
+        }
+        [, , , , , $batches] = $this->engine->add_documents(
+                new ArrayIterator($docs), $area, ['indexfiles' => true]);
+        $this->assertEquals(1, $batches);
+
+        // More than 100 needs 2 batches.
+        $docs = [];
+        for ($i = 1; $i <= 101; $i++) {
+            $doc = $area->get_document($record);
+            $doc->set('id', $originalid . '-' . $i);
+            $docs[$i] = $doc;
+        }
+        [, , , , , $batches] = $this->engine->add_documents(
+                new ArrayIterator($docs), $area, ['indexfiles' => true]);
+        $this->assertEquals(2, $batches);
+
+        // Small number but with some large documents that aren't batched.
+        $docs = [];
+        for ($i = 1; $i <= 10; $i++) {
+            $doc = $area->get_document($record);
+            $doc->set('id', $originalid . '-' . $i);
+            $docs[$i] = $doc;
+        }
+        // This one is just small enough to fit.
+        $docs[3]->set('content', str_pad('xyzzy ', 1024 * 1024, 'x'));
+        // These two don't fit.
+        $docs[5]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x'));
+        $docs[6]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x'));
+        [, , , , , $batches] = $this->engine->add_documents(
+                new ArrayIterator($docs), $area, ['indexfiles' => true]);
+        $this->assertEquals(3, $batches);
+
+        // Check that all 3 of the large documents (added as batch or not) show up in results.
+        $this->engine->area_index_complete($area->get_area_id());
+        $querydata = new stdClass();
+        $querydata->q = 'xyzzy';
+        $results = $this->search->search($querydata);
+        $this->assertCount(3, $results);
+    }
+
+    /**
+     * Tests with large documents. The point of this test is that we stop batching
+     * documents if they are bigger than 1MB, and the maximum batch count is 100,
+     * so the maximum size batch will be about 100 1MB documents.
+     */
+    public function test_add_document_batch_large() {
+        // This test is a bit slow and not that important to run every time...
+        if (!PHPUNIT_LONGTEST) {
+            $this->markTestSkipped('PHPUNIT_LONGTEST is not defined');
+        }
+
+        // Get a default document.
+        $area = new core_mocksearch\search\mock_search_area();
+        $record = $this->generator->create_record();
+        $doc = $area->get_document($record);
+        $originalid = $doc->get('id');
+
+        // Now create 100 large documents.
+        $size = 1024 * 1024;
+        $docs = [];
+        for ($i = 1; $i <= 100; $i++) {
+            $doc = $area->get_document($record);
+            $doc->set('id', $originalid . '-' . $i);
+            $doc->set('title', 'Batch ' . $i);
+            $doc->set('content', str_pad('', $size, 'Long text ' . $i . '. ', STR_PAD_RIGHT) . ' xyzzy');
+            $docs[$i] = $doc;
+        }
+
+        // Add all these documents to the search engine.
+        $this->engine->add_document_batch($docs, true);
+        $this->engine->area_index_complete($area->get_area_id());
+
+        // Check all documents were indexed, searching for text at end.
+        $querydata = new stdClass();
+        $querydata->q = 'xyzzy';
+        $results = $this->search->search($querydata);
+        $this->assertCount(100, $results);
+
+        // Search for specific text that's only in one.
+        $querydata->q = '42';
+        $results = $this->search->search($querydata);
+        $this->assertCount(1, $results);
+    }
+
     /**
      * Carries out a raw Solr query using the Solr basic query syntax.
      *
index bb0d8f3..3566bc8 100644 (file)
@@ -1,6 +1,14 @@
 This files describes API changes in /search/*,
 information provided here is intended especially for developers.
 
+=== 4.0 ===
+
+* Search indexing now supports sending multiple documents to the server in a batch. This is implemented
+  for the Solr search engine, where it significantly increases performance. For this to work, engines
+  should implement add_document_batch() function and return true to supports_add_document_batch().
+  There is also an additional parameter returned from add_documents() with the number of batches
+  sent, which is used for the log display. Existing engines should continue to work unmodified.
+
 === 3.8 ===
 
 * Search indexing supports time limits to make the scheduled task run more neatly since 3.4. In order for
index e548f33..1349003 100644 (file)
@@ -870,7 +870,7 @@ span.editinstructions {
     .listitem {
 
         &[data-selected='1'] {
-            border-left: calc(#{$list-group-border-width} + 5px) solid map-get($theme-colors, 'info');
+            border-left: calc(#{$list-group-border-width} + 5px) solid map-get($theme-colors, 'primary');
             padding-left: calc(#{$list-group-item-padding-x} - 5px);
         }
     }
index 588a0de..93f02cb 100644 (file)
@@ -13740,7 +13740,7 @@ span.editinstructions {
     #course-category-listings ul.ml ul.ml {
       margin: 0; }
   #course-category-listings .listitem[data-selected='1'] {
-    border-left: calc(1px + 5px) solid #5bc0de;
+    border-left: calc(1px + 5px) solid #1177d1;
     padding-left: calc(1.25rem - 5px); }
   #course-category-listings .item-actions {
     margin-right: 1em;
index e9b5b03..02d840a 100644 (file)
@@ -13957,7 +13957,7 @@ span.editinstructions {
     #course-category-listings ul.ml ul.ml {
       margin: 0; }
   #course-category-listings .listitem[data-selected='1'] {
-    border-left: calc(1px + 5px) solid #5bc0de;
+    border-left: calc(1px + 5px) solid #1177d1;
     padding-left: calc(1.25rem - 5px); }
   #course-category-listings .item-actions {
     margin-right: 1em;
index 7946247..79a7137 100644 (file)
@@ -392,7 +392,7 @@ class participants_search {
             $forcedgroupjoin = groups_get_members_join($allowedgroupids, $forceduid, $this->context, $forcedjointype);
 
             $forcedjoins[] = $forcedgroupjoin->joins;
-            $forcedwhere .= "AND ({$forcedgroupjoin->wheres})";
+            $forcedwhere .= " AND ({$forcedgroupjoin->wheres})";
 
             $params = array_merge($params, $forcedgroupjoin->params);
 
index 7805185..553b503 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020071100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020071700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '4.0dev (Build: 20200711)'; // Human-friendly version name
+$release  = '4.0dev (Build: 20200717)'; // Human-friendly version name
 $branch   = '40';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.