Merge branch 'MDL-69278-39' of git://github.com/andrewnicols/moodle into MOODLE_39_STABLE
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Sat, 18 Jul 2020 09:56:01 +0000 (11:56 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Sat, 18 Jul 2020 09:56:01 +0000 (11:56 +0200)
45 files changed:
admin/settings/server.php
admin/tool/mobile/settings.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/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
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/moodlelib.php
lib/tests/behat/behat_data_generators.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/externallib.php
mod/assign/overrides.php
mod/assign/tests/behat/assign_user_override.feature
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/module.js
mod/quiz/tests/behat/quiz_user_override.feature
mod/quiz/tests/generator/behat_mod_quiz_generator.php
question/qengine.js
question/tests/generator/behat_core_question_generator.php
question/type/multichoice/tests/behat/clearanswers.feature
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 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 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..f875317 100644 (file)
@@ -637,20 +637,14 @@ function get_category_or_system_context($categoryid) {
 }
 
 /**
- * Returns full course categories trees to be used in html_writer::select()
+ * Returns the list of full course categories 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
+ * Calls {@see core_course_category::make_categories_list()} to build the list.
  *
  * @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;
+    return core_course_category::make_categories_list('', 0, ' / ');
 }
 
 /**
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 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..82b7b56 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.
@@ -205,21 +206,9 @@ class external extends external_api {
      * @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 +217,6 @@ 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();
     }
 }
-
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 dcc2f48..1df218e 100644 (file)
@@ -2486,5 +2486,16 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020061500.02);
     }
 
+    if ($oldversion < 2020061501.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, 2020061501.01);
+    }
+
     return true;
 }
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'.
      *
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 523e395..790def0 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
+=== 3.9.2 ===
+* A new web service `core_output_load_fontawesome_icon_system_map` has been created.
+  This replaces the existing `core_output_load_fontawesome_icon_map` web service, which will be deprecated in Moodle 3.10.
+
 === 3.9.1 ===
 * The `$CFG->behat_retart_browser_after` configuration setting has been removed.
   The browser session is now restarted between all tests.
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 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"
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 46f5ab8..8c5264d 100644 (file)
@@ -179,9 +179,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);
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 dc37efd..2b2298e 100644 (file)
@@ -127,10 +127,15 @@ M.core_question_engine.questionformalreadysubmitted = false;
  * @param slot the number of the question_attempt within the usage.
  */
 M.core_question_engine.init_submit_button = function(Y, button, slot) {
+    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 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 d84e55c..96548be 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020061501.00;              // 20200615      = branching date YYYYMMDD - do not modify!
+$version  = 2020061501.02;              // 20200615      = branching date YYYYMMDD - do not modify!
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9.1 (Build: 20200713)'; // Human-friendly version name
+$release  = '3.9.1+ (Build: 20200717)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_STABLE;             // This version's maturity level.