Merge branch 'MDL-57678-39' of git://github.com/junpataleta/moodle into MOODLE_39_STABLE
authorSara Arjona <sara@moodle.com>
Wed, 15 Jul 2020 10:56:20 +0000 (12:56 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 15 Jul 2020 10:56:20 +0000 (12:56 +0200)
90 files changed:
.travis.yml
admin/tasklogs.php
admin/tool/customlang/lang/en/tool_customlang.php
admin/tool/lp/amd/build/dialogue.min.js
admin/tool/lp/amd/build/dialogue.min.js.map
admin/tool/lp/amd/build/grade_dialogue.min.js
admin/tool/lp/amd/build/grade_dialogue.min.js.map
admin/tool/lp/amd/build/grade_user_competency_inline.min.js
admin/tool/lp/amd/build/grade_user_competency_inline.min.js.map
admin/tool/lp/amd/src/dialogue.js
admin/tool/lp/amd/src/grade_dialogue.js
admin/tool/lp/amd/src/grade_user_competency_inline.js
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
auth/oauth2/classes/auth.php
blocks/blog_menu/block_blog_menu.php
blocks/glossary_random/tests/behat/glossary_random_global.feature
calendar/amd/build/calendar_filter.min.js
calendar/amd/build/calendar_filter.min.js.map
calendar/amd/src/calendar_filter.js
completion/tests/behat/completion_other_courses.feature [new file with mode: 0644]
config-dist.php
course/classes/management/helper.php
course/classes/management_renderer.php
course/completion_form.php
course/tests/behat/search_recommended_activities.feature
course/tests/services_content_item_service_test.php
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
enrol/manual/ajax.php
grade/tests/behat/behat_grade.php
install/lang/ko/moodle.php
install/lang/zh_cn/langconfig.php
install/lang/zh_cn/moodle.php
lang/en/badges.php
lang/en/h5p.php
lang/en/mnet.php
lib/behat/behat_base.php
lib/behat/classes/behat_core_generator.php
lib/behat/classes/behat_generator_base.php
lib/classes/output/icon_system.php
lib/classes/output/icon_system_fontawesome.php
lib/db/upgrade.php
lib/editor/atto/plugins/image/lang/en/atto_image.php
lib/setuplib.php
lib/tablelib.php
lib/tests/behat/behat_data_generators.php
lib/tests/behat/behat_hooks.php
lib/tests/behat/behat_navigation.php
lib/tests/setuplib_test.php
lib/upgrade.txt
message/output/airnotifier/requestaccesskey.php
message/output/airnotifier/settings.php
mod/assign/feedback/editpdf/classes/task/convert_submissions.php
mod/choice/lang/en/choice.php
mod/lti/templates/tool_card.mustache
mod/quiz/accessrule/seb/tests/generator/behat_quizaccess_seb_generator.php
mod/quiz/tests/generator/behat_mod_quiz_generator.php
mod/scorm/report/basic/classes/report.php
mod/scorm/report/graphs/classes/report.php
mod/scorm/report/interactions/classes/report.php
mod/scorm/report/objectives/classes/report.php
mod/scorm/tests/behat/behat_mod_scorm.php [deleted file]
question/export_form.php
question/tests/generator/behat_core_question_generator.php
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/build/question.min.js.map
question/type/ddimageortext/amd/src/question.js
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/build/question.min.js.map
question/type/ddmarker/amd/src/question.js
question/type/ddwtos/amd/build/ddwtos.min.js
question/type/ddwtos/amd/build/ddwtos.min.js.map
question/type/ddwtos/amd/src/ddwtos.js
question/type/multichoice/tests/behat/clearanswers.feature
report/competency/amd/build/grading_popup.min.js
report/competency/amd/build/grading_popup.min.js.map
report/competency/amd/build/user_course_navigation.min.js
report/competency/amd/build/user_course_navigation.min.js.map
report/competency/amd/src/grading_popup.js
report/competency/amd/src/user_course_navigation.js
report/competency/index.php
theme/boost/amd/build/pending.min.js
theme/boost/amd/build/pending.min.js.map
theme/boost/amd/src/pending.js
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
theme/yui_combo.php
version.php

index d6b8a96..67d70d5 100644 (file)
@@ -15,6 +15,7 @@ dist: xenial
 
 services:
     - mysql
+    - docker
 
 php:
     # We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
@@ -61,8 +62,9 @@ cache:
       - $HOME/.npm
 
 before_install:
+    - docker run -d -p 127.0.0.1:8080:80 --name exttests moodlehq/moodle-exttests
     # Avoid IPv6 default binding as service (causes redis not to start).
-    sudo service redis-server start --bind 127.0.0.1
+    sudo service redis-server start --bind 127.0.0.1
 
 install:
     - >
@@ -169,6 +171,10 @@ before_script:
           -e "/require_once/i \\\$CFG->phpunit_dataroot = '\/home\/travis\/roots\/phpunit';" \
           -e "/require_once/i \\\$CFG->phpunit_prefix = 'p_';" \
           config.php ;
+        # Enable test external resources
+        sed -i \
+          -e "/require_once/i \\define('TEST_EXTERNAL_FILES_HTTP_URL', 'http://127.0.0.1:8080');" \
+          config.php ;
         # Redis cache store tests
         sed -i \
           -e "/require_once/i \\define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');" \
@@ -248,6 +254,8 @@ script:
       if [ "$TASK" = 'PHPUNIT' ];
       then
         vendor/bin/phpunit --fail-on-risky --disallow-test-output --verbose;
+        EXTTESTS_HITS=$(docker logs exttests 2>&1 | grep -Fv -e 'AH00558' -e '[pid 1]' | wc -l)
+        echo -e "\nTest local resources number of hits: ${EXTTESTS_HITS}.\n"
       fi
 
     - >
index 796f323..a91faa3 100644 (file)
@@ -66,7 +66,7 @@ echo $OUTPUT->header();
 // Output the search form.
 echo $OUTPUT->render_from_template('core_admin/tasklogs', (object) [
     'action' => $pageurl->out(),
-    'filter' => $filter,
+    'filter' => htmlentities($filter),
     'resultfilteroptions' => [
         (object) [
             'value' => -1,
index 9e7a2d6..a7375f1 100644 (file)
@@ -36,7 +36,7 @@ $string['customlang:view'] = 'View local translation';
 $string['filter'] = 'Filter strings';
 $string['filtercomponent'] = 'Show strings of these components';
 $string['filtercustomized'] = 'Customised only';
-$string['filtermodified'] = 'Modified only';
+$string['filtermodified'] = 'Modified in this session only';
 $string['filteronlyhelps'] = 'Help only';
 $string['filtershowstrings'] = 'Show strings';
 $string['filterstringid'] = 'String identifier';
index b2f77e0..6e0c86c 100644 (file)
Binary files a/admin/tool/lp/amd/build/dialogue.min.js and b/admin/tool/lp/amd/build/dialogue.min.js differ
index 7045a6f..881dda1 100644 (file)
Binary files a/admin/tool/lp/amd/build/dialogue.min.js.map and b/admin/tool/lp/amd/build/dialogue.min.js.map differ
index 806eeeb..9637bc9 100644 (file)
Binary files a/admin/tool/lp/amd/build/grade_dialogue.min.js and b/admin/tool/lp/amd/build/grade_dialogue.min.js differ
index 6af7b51..472ffa8 100644 (file)
Binary files a/admin/tool/lp/amd/build/grade_dialogue.min.js.map and b/admin/tool/lp/amd/build/grade_dialogue.min.js.map differ
index ade6899..45c0800 100644 (file)
Binary files a/admin/tool/lp/amd/build/grade_user_competency_inline.min.js and b/admin/tool/lp/amd/build/grade_user_competency_inline.min.js differ
index 1095c0f..4cc60b6 100644 (file)
Binary files a/admin/tool/lp/amd/build/grade_user_competency_inline.min.js.map and b/admin/tool/lp/amd/build/grade_user_competency_inline.min.js.map differ
index 04a570f..b96e262 100644 (file)
@@ -35,6 +35,8 @@ define(['core/yui'], function(Y) {
      * @param {Boolean} wide Specify we want an extra wide dialogue (the size is standard, but wider than the default).
      */
     var dialogue = function(title, content, afterShow, afterHide, wide) {
+        M.util.js_pending('tool_lp/dialogue:dialogue');
+
         this.yuiDialogue = null;
         var parent = this;
 
@@ -59,6 +61,10 @@ define(['core/yui'], function(Y) {
                 width: width
             });
 
+            parent.yuiDialogue.before('visibleChange', function() {
+                M.util.js_pending('tool_lp/dialogue:before:visibleChange');
+            });
+
             parent.yuiDialogue.after('visibleChange', function(e) {
                 if (e.newVal) {
                     // Delay the callback call to the next tick, otherwise it can happen that it is
@@ -67,18 +73,25 @@ define(['core/yui'], function(Y) {
                         Y.soon(function() {
                             afterShow(parent);
                             parent.yuiDialogue.centerDialogue();
+                            M.util.js_complete('tool_lp/dialogue:before:visibleChange');
                         });
+                    } else {
+                        M.util.js_complete('tool_lp/dialogue:before:visibleChange');
                     }
                 } else {
                     if ((typeof afterHide !== 'undefined')) {
                         Y.soon(function() {
                             afterHide(parent);
+                            M.util.js_complete('tool_lp/dialogue:before:visibleChange');
                         });
+                    } else {
+                        M.util.js_complete('tool_lp/dialogue:before:visibleChange');
                     }
                 }
             });
 
             parent.yuiDialogue.show();
+            M.util.js_complete('tool_lp/dialogue:dialogue');
         });
     };
 
index 80ef97c..3b92add 100644 (file)
@@ -102,6 +102,7 @@ define(['jquery',
      * @return {Promise}
      */
     Grade.prototype.display = function() {
+        M.util.js_pending('tool_lp/grade_dialogue:display');
         return $.when(
             Str.get_string('rate', 'tool_lp'),
             this._render()
@@ -110,7 +111,10 @@ define(['jquery',
             this._popup = new Dialogue(
                 title,
                 templateResult[0],
-                this._afterRender.bind(this)
+                function() {
+                    this._afterRender();
+                    M.util.js_complete('tool_lp/grade_dialogue:display');
+                }.bind(this)
             );
 
             return this._popup;
index 81e4f35..9b32017 100644 (file)
@@ -94,6 +94,7 @@ define(['jquery',
         var options = [],
             self = this;
 
+        M.util.js_pending('tool_lp/grade_user_competency_inline:_setUp');
         var promise = ScaleValues.get_values(self._scaleId);
         promise.then(function(scalevalues) {
             options.push({
@@ -134,6 +135,7 @@ define(['jquery',
         .then(function(dialogue) {
             self._dialogue = dialogue;
 
+            M.util.js_complete('tool_lp/grade_user_competency_inline:_setUp');
             return;
         })
         .fail(notification.exception);
index a9dc790..07a51d9 100644 (file)
@@ -489,6 +489,7 @@ class api {
                 '$mmSideMenuDelegate_mmaFiles' => new lang_string('files'),
                 '$mmSideMenuDelegate_website' => new lang_string('webpage'),
                 '$mmSideMenuDelegate_help' => new lang_string('help'),
+                'CoreMainMenuDelegate_QrReader' => new lang_string('scanqrcode', 'tool_mobile'),
             ),
             "$course" => array(
                 'NoDelegate_CourseBlocks' => new lang_string('blocks'),
@@ -527,6 +528,12 @@ class api {
             $features["$remoteaddons"] = $remoteaddonslist;
         }
 
+        if (!empty($availablemods['lti'])) {
+            $ltidisplayname = $availablemods['lti']->displayname;
+            $features["$ltidisplayname"]['CoreCourseModuleDelegate_AddonModLti:openInAppBrowser'] =
+                new lang_string('openusingembeddedbrowser', 'tool_mobile');
+        }
+
         // Display OAuth 2 identity providers.
         if (is_enabled_auth('oauth2')) {
             $identityproviderslist = array();
index e56f280..bc7fc89 100644 (file)
@@ -105,6 +105,7 @@ $string['notificationssentnotifications'] = 'Notifications sent';
 $string['notificationscurrentactivedevices'] = 'Devices receiving notifications this month';
 $string['oauth2identityproviders'] = 'OAuth 2 identity providers';
 $string['offlineuse'] = 'Offline use';
+$string['openusingembeddedbrowser'] = 'Open using embedded browser';
 $string['pluginname'] = 'Moodle app tools';
 $string['pluginnotenabledorconfigured'] = 'Plugin not enabled or configured.';
 $string['qrcodedisabled'] = 'Access via QR code disabled';
@@ -118,6 +119,7 @@ $string['qrcodetypeurl'] = 'QR code with site URL';
 $string['qrcodetypelogin'] = 'QR code with automatic login';
 $string['readingthisemailgettheapp'] = 'Reading this in an email? <a href="{$a}">Download the mobile app and receive notifications on your mobile device</a>.';
 $string['remoteaddons'] = 'Remote add-ons';
+$string['scanqrcode'] = 'Scan QR code';
 $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.';
 $string['setuplink'] = 'App download page';
 $string['setuplink_desc'] = 'URL of page with options to download the mobile app from the App Store and Google Play. The app download page link is displayed in the page footer and in a user\'s profile. Leave blank to not display a link.';
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 1d1ff6f..cf6fcfd 100644 (file)
@@ -455,8 +455,9 @@ class auth extends \auth_plugin_base {
             }
         }
 
+        $issuer = $client->get_issuer();
         // First we try and find a defined mapping.
-        $linkedlogin = api::match_username_to_user($userinfo['username'], $client->get_issuer());
+        $linkedlogin = api::match_username_to_user($userinfo['username'], $issuer);
 
         if (!empty($linkedlogin) && empty($linkedlogin->get('confirmtoken'))) {
             $mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
@@ -474,7 +475,7 @@ class auth extends \auth_plugin_base {
                 $SESSION->loginerrormsg = get_string('invalidlogin');
                 $client->log_out();
                 redirect(new moodle_url('/login/index.php'));
-            } else if ($mappeduser && $mappeduser->confirmed) {
+            } else if ($mappeduser && ($mappeduser->confirmed || !$issuer->get('requireconfirmation'))) {
                 // Update user fields.
                 $userinfo = $this->update_user($userinfo, $mappeduser);
                 $userwasmapped = true;
@@ -503,7 +504,7 @@ class auth extends \auth_plugin_base {
             redirect(new moodle_url('/login/index.php'));
         }
 
-        $issuer = $client->get_issuer();
+
         if (!$issuer->is_valid_login_domain($oauthemail)) {
             // Trigger login failed event.
             $failurereason = AUTH_LOGIN_UNAUTHORISED;
index 71fd422..7bc53b1 100644 (file)
@@ -99,10 +99,14 @@ class block_blog_menu extends block_base {
         // Prepare the footer for this block
         if (has_capability('moodle/blog:search', context_system::instance())) {
             // Full-text search field
-            $form  = html_writer::tag('label', get_string('search', 'admin'), array('for'=>'blogsearchquery', 'class'=>'accesshide'));
-            $form .= html_writer::empty_tag('input', array('id'=>'blogsearchquery', 'type'=>'text', 'name'=>'search'));
-            $form .= html_writer::empty_tag('input', array('type'=>'submit', 'value'=>get_string('search')));
-            $this->content->footer = html_writer::tag('form', html_writer::tag('div', $form), array('class'=>'blogsearchform', 'method'=>'get', 'action'=>new moodle_url('/blog/index.php')));
+            $form  = html_writer::tag('label', get_string('search', 'admin'), array('for' => 'blogsearchquery',
+                'class' => 'accesshide'));
+            $form .= html_writer::empty_tag('input', array('id' => 'blogsearchquery', 'class' => 'form-control mr-1',
+                'type' => 'text', 'name' => 'search'));
+            $form .= html_writer::empty_tag('input', array('type' => 'submit', 'class' => 'btn btn-secondary',
+                'value' => get_string('search')));
+            $this->content->footer = html_writer::tag('form', html_writer::tag('div', $form), array(
+                'class' => 'blogsearchform form-inline', 'method' => 'get', 'action' => new moodle_url('/blog/index.php')));
         } else {
             // No footer to display
             $this->content->footer = '';
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 |
index 49ead88..9f72034 100644 (file)
Binary files a/calendar/amd/build/calendar_filter.min.js and b/calendar/amd/build/calendar_filter.min.js differ
index 91dd103..5fd1103 100644 (file)
Binary files a/calendar/amd/build/calendar_filter.min.js.map and b/calendar/amd/build/calendar_filter.min.js.map differ
index 41dd448..415b3b3 100644 (file)
@@ -64,6 +64,7 @@ function(
         // Toggle the hidden. We need to render the template before we change the value.
         data.hidden = !data.hidden;
 
+        M.util.js_pending("core_calendar/calendar_filter:toggleFilter");
         return Str.get_string('eventtype' + data.eventtype, 'calendar')
         .then(function(nameStr) {
             data.name = nameStr;
@@ -81,6 +82,7 @@ function(
         })
         .then(function() {
             fireFilterChangedEvent(data);
+            M.util.js_complete("core_calendar/calendar_filter:toggleFilter");
             return;
         });
     };
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 995e0df..ecd56b6 100644 (file)
@@ -898,13 +898,6 @@ $CFG->admin = 'admin';
 //     ),
 // );
 //
-// You can force the browser session (not user's sessions) to restart after N seconds. This could
-// be useful if you are using a cloud-based service with time restrictions in the browser side.
-// Setting this value the browser session that Behat is using will be restarted. Set the time in
-// seconds. Is not recommended to use this setting if you don't explicitly need it.
-// Example:
-//   $CFG->behat_restart_browser_after = 7200;     // Restarts the browser session after 2 hours
-//
 // All this page's extra Moodle settings are compared against a white list of allowed settings
 // (the basic and behat_* ones) to avoid problems with production environments. This setting can be
 // used to expand the default white list with an array of extra settings.
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 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 f1c3cdb..d9c1ada 100644 (file)
@@ -32,7 +32,7 @@ $string['categoryseparator'] = 'Category separator character';
 $string['categoryseparator_desc'] = 'Required when "Category idnumber" is enabled. Character to separate the category name and idnumber.';
 $string['coursesettings'] = 'Course data options';
 $string['createnewcategories'] = 'Create new (hidden) course categories if not found in Moodle';
-$string['createnewcategories_desc'] = 'If the <org><orgunit> element is present in a course\'s incoming data, its content will be used to specify a category if the course is to be created from scratch. The plugin will NOT re-categorise existing courses.
+$string['createnewcategories_desc'] = 'If the &lt;org&gt;&lt;orgunit&gt; element is present in a course\'s incoming data, its content will be used to specify a category if the course is to be created from scratch. The plugin will NOT re-categorise existing courses.
 
 If no category exists with the desired name, then a hidden category will be created.';
 $string['createnewcourses'] = 'Create new (hidden) courses if not found in Moodle';
index 29cb40e..22ccef2 100644 (file)
@@ -100,6 +100,13 @@ switch ($action) {
 
         if (empty($roleid)) {
             $roleid = null;
+        } else {
+            if (!has_capability('moodle/role:assign', $context)) {
+                throw new enrol_ajax_exception('assignnotpermitted');
+            }
+            if (!array_key_exists($roleid, get_assignable_roles($context, ROLENAME_ALIAS, false))) {
+                throw new enrol_ajax_exception('invalidrole');
+            }
         }
 
         if (empty($startdate)) {
index b5e6dde..02a4d1e 100644 (file)
@@ -226,7 +226,7 @@ class behat_grade extends behat_base {
             $inputxpath = "//input[@class='idnumber'][" .
                     "parent::li[@class='item'][text()='" . $gradeitem . "']" .
                     " | " .
-                    "parent::li[@class='categoryitem' | @class='courseitem']" .
+                    "parent::li[@class='categoryitem' or @class='courseitem']" .
                     "/parent::ul/parent::li[starts-with(text(),'" . $gradeitem . "')]" .
                     "]";
             $this->execute('behat_forms::i_set_the_field_with_xpath_to', array($inputxpath, $idnumber));
index 358febe..392abd3 100644 (file)
@@ -31,6 +31,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = '언어';
+$string['moodlelogo'] = '무들 로고';
 $string['next'] = '다음';
 $string['previous'] = '이전으로';
 $string['reload'] = '다시 로딩';
index a0956b4..0bc12e9 100644 (file)
@@ -31,5 +31,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['parentlanguage'] = '';
-$string['thisdirection'] = '符号(ltr)';
+$string['thisdirection'] = 'ltr';
 $string['thislanguage'] = '简体中文';
index 10a3c0e..db525cd 100644 (file)
@@ -32,6 +32,6 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = '语言';
 $string['moodlelogo'] = 'Moodle图标';
-$string['next'] = '向后';
-$string['previous'] = '向前';
+$string['next'] = '下一个';
+$string['previous'] = '上一个';
 $string['reload'] = '重新载入';
index 85abc8a..f0343ee 100644 (file)
@@ -82,7 +82,7 @@ $string['awardoncron'] = 'Access to the badges was successfully enabled. Too man
 $string['awards'] = 'Recipients';
 $string['backpackavailability'] = 'External badge verification';
 $string['backpackconnectionok'] = 'Backpack connection successfully established';
-$string['backpackconnectionnottested'] = 'Connection can not be tested for this backpack because only OBv2.0 backpacks support it.';
+$string['backpackconnectionnottested'] = 'The connection cannot be tested for this backpack because only Open Badges v2.0 backpacks support it.';
 $string['backpackneedsupdate'] = 'The backpack connected to this profile does not match the backpack for the site. You need to disconnect and reconnect the backpack.';
 $string['backpackavailability_help'] = 'For badge recipients to be able to prove they earned their badges from you, an external backpack service should be able to access your site and verify badges issued from it. Your site does not currently appear to be accessible, which means that badges you have already issued or will issue in the future cannot be verified.
 
index f2d73f2..f4b6a8f 100644 (file)
@@ -92,7 +92,7 @@ $string['h5ptitle'] = 'Visit h5p.org to check out more content.';
 $string['h5pfilenotfound'] = 'H5P file not found';
 $string['h5pinvalidurl'] = 'Invalid H5P content URL.';
 $string['h5plibraryhandler'] = 'H5P framework handler';
-$string['h5plibraryhandler_help'] = 'The H5P framework used to display any H5P content.';
+$string['h5plibraryhandler_help'] = 'The H5P framework used to display H5P content. The latest version is recommended.';
 $string['h5pprivatefile'] = 'This H5P content can\'t be displayed because you don\'t have access to the .h5p file.';
 $string['h5pmanage'] = 'Manage H5P content types';
 $string['h5poverview'] = 'H5P overview';
index bb57111..b73fd12 100644 (file)
@@ -61,7 +61,7 @@ $string['enterausername'] = 'Please enter a username, or a list of usernames sep
 $string['error7020'] = 'This error normally occurs if the remote site has created a record for you with the wrong wwwroot, for example, https://yoursite.com instead of https://www.yoursite.com. Please contact the administrator of the remote site with your wwwroot (as specified in config.php) and ask them to update the record for your host.';
 $string['error7022'] = 'The message you sent to the remote site was encrypted properly, but not signed. This is very unexpected; you should probably file a bug if this occurs (giving as much information as possible about the application versions in question etc).';
 $string['error7023'] = 'The remote site has tried to decrypt your message with all the keys it has on record for your site. They have all failed. You might be able to fix this problem by manually re-keying with the remote site. This is unlikely to occur unless you\'ve been out of communication with the remote site for a few months.';
-$string['error7024'] = 'You send an unencrypted message to the remote site, but the remote site doesn\'t accept unencrypted communication from your site. This is very unexpected; you should probably file a bug if this occurs (giving as much information as possible about the application versions in question, etc.';
+$string['error7024'] = 'You send an unencrypted message to the remote site, but the remote site doesn\'t accept unencrypted communication from your site. This is very unexpected; you should probably file a bug if this occurs (giving as much information as possible about the application versions in question, etc).';
 $string['error7026'] = 'The key that your message was signed with differs from the key that the remote host has on file for your server. Further, the remote host attempted to fetch your current key and failed to do so. Please manually re-key with the remote host and try again.';
 $string['error709'] = 'The remote site failed to obtain a SSL key from you.';
 $string['eventaccesscontrolcreated'] = 'Access control created';
index 729ac1e..c97f492 100644 (file)
@@ -119,6 +119,11 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
      * @return NodeElement
      */
     protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
+        if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) {
+            // Support a NodeElement being passed in for use in step chaining.
+            return $locator;
+        }
+
         // Returns the first match.
         $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
         return count($items) ? reset($items) : null;
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])) {
index 08e88b5..14e1075 100644 (file)
@@ -72,22 +72,22 @@ abstract class icon_system {
     /**
      * Factory method
      *
-     * @param $type Either a specific type, or null to get the default type.
+     * @param string $type Either a specific type, or null to get the default type.
      * @return \core\output\icon_system
      */
     public final static function instance($type = null) {
         global $PAGE;
 
-        if ($type == null) {
-            if (!empty(self::$instance)) {
-                return self::$instance;
-            }
-            $type = $PAGE->theme->get_icon_system();
-            self::$instance = new $type();
-            // Default one is a singleton.
+        if (empty(self::$instance)) {
+            $icontype = $PAGE->theme->get_icon_system();
+            self::$instance = new $icontype();
+        }
+
+        // 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)) {
             return self::$instance;
         } else {
-            // Not a singleton.
             return new $type();
         }
     }
index ba4ccdb..38ad233 100644 (file)
@@ -440,7 +440,10 @@ class icon_system_fontawesome extends icon_system_font {
         if ($this->map === []) {
             $cache = \cache::make('core', 'fontawesomeiconmapping');
 
-            $this->map = $cache->get('mapping');
+            // Create different mapping keys for different icon system classes, there may be several different
+            // themes on the same site.
+            $mapkey = 'mapping_'.preg_replace('/[^a-zA-Z0-9_]/', '_', get_class($this));
+            $this->map = $cache->get($mapkey);
 
             if (empty($this->map)) {
                 $this->map = $this->get_core_icon_map();
@@ -454,7 +457,7 @@ class icon_system_fontawesome extends icon_system_font {
                         }
                     }
                 }
-                $cache->set('mapping', $this->map);
+                $cache->set($mapkey, $this->map);
             }
 
         }
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 624247e..b5e540d 100644 (file)
@@ -38,7 +38,7 @@ $string['height'] = 'Height';
 $string['imageproperties'] = 'Image properties';
 $string['presentation'] = 'This image is decorative only';
 $string['pluginname'] = 'Insert or edit image';
-$string['presentationoraltrequired'] = 'Images must have a description, except if the description is marked as not necessary.';
+$string['presentationoraltrequired'] = 'An image must have a description, unless it is marked as decorative only.';
 $string['preview'] = 'Preview';
 $string['saveimage'] = 'Save image';
 $string['size'] = 'Size';
index 4aa3b9a..2131587 100644 (file)
@@ -551,7 +551,7 @@ function get_exception_info($ex) {
     // Remove some absolute paths from message and debugging info.
     $searches = array();
     $replaces = array();
-    $cfgnames = array('tempdir', 'cachedir', 'localcachedir', 'themedir', 'dataroot', 'dirroot');
+    $cfgnames = array('backuptempdir', 'tempdir', 'cachedir', 'localcachedir', 'themedir', 'dataroot', 'dirroot');
     foreach ($cfgnames as $cfgname) {
         if (property_exists($CFG, $cfgname)) {
             $searches[] = $CFG->$cfgname;
index 8e28ab4..78bade9 100644 (file)
@@ -1306,8 +1306,7 @@ class flexible_table {
 
             if (array_key_exists($sortby, $sortdata)) {
                 // This key already exists somewhere. Change its sortorder and bring it to the top.
-                //$sortorder = $sortdata[$sortby] = $sortorder;
-                unset($sortdata['sortby'][$sortby]);
+                unset($sortdata[$sortby]);
             }
             $sortdata = array_merge([$sortby => $sortorder], $sortdata);
         }
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 115765a..1ffcb14 100644 (file)
@@ -63,16 +63,14 @@ use Behat\Testwork\Hook\Scope\BeforeSuiteScope,
  */
 class behat_hooks extends behat_base {
 
-    /**
-     * @var Last browser session start time.
-     */
-    protected static $lastbrowsersessionstart = 0;
-
     /**
      * @var For actions that should only run once.
      */
     protected static $initprocessesfinished = false;
 
+    /** @var bool Whether the first javascript scenario has been seen yet */
+    protected static $firstjavascriptscenarioseen = false;
+
     /**
      * @var bool Scenario running
      */
@@ -115,39 +113,22 @@ class behat_hooks extends behat_base {
     protected static $scenariotags;
 
     /**
-     * Hook to capture BeforeSuite event so as to give access to moodle codebase.
-     * This will try and catch any exception and exists if anything fails.
+     * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
+     *
+     * Includes config.php to use moodle codebase with $CFG->behat_* instead of $CFG->prefix and $CFG->dataroot, called
+     * once per suite.
      *
-     * @param BeforeSuiteScope $scope scope passed by event fired before suite.
      * @BeforeSuite
+     * @param BeforeSuiteScope $scope scope passed by event fired before suite.
      */
     public static function before_suite_hook(BeforeSuiteScope $scope) {
+        global $CFG;
+
         // If behat has been initialised then no need to do this again.
-        if (self::$initprocessesfinished) {
+        if (!self::is_first_scenario()) {
             return;
         }
 
-        try {
-            self::before_suite($scope);
-        } catch (behat_stop_exception $e) {
-            echo $e->getMessage() . PHP_EOL;
-            exit(1);
-        }
-    }
-
-    /**
-     * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
-     *
-     * Includes config.php to use moodle codebase with $CFG->behat_*
-     * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
-     *
-     * @param BeforeSuiteScope $scope scope passed by event fired before suite.
-     * @static
-     * @throws behat_stop_exception
-     */
-    public static function before_suite(BeforeSuiteScope $scope) {
-        global $CFG;
-
         // Defined only when the behat CLI command is running, the moodle init setup process will
         // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
         // the normal site.
@@ -175,8 +156,7 @@ class behat_hooks extends behat_base {
         // before each scenario (accidental user deletes) in the BeforeScenario hook.
 
         if (!behat_util::is_test_mode_enabled()) {
-            throw new behat_stop_exception('Behat only can run if test mode is enabled. More info in ' .
-                behat_command::DOCS_URL);
+            self::log_and_stop('Behat only can run if test mode is enabled. More info in ' .  behat_command::DOCS_URL);
         }
 
         // Reset all data, before checking for check_server_status.
@@ -184,26 +164,28 @@ class behat_hooks extends behat_base {
         behat_util::clean_tables_updated_by_scenario_list();
         behat_util::reset_all_data();
 
-        // Check if server is running and using same version for cli and apache.
+        // Check if the web server is running and using same version for cli and apache.
         behat_util::check_server_status();
 
         // Prevents using outdated data, upgrade script would start and tests would fail.
         if (!behat_util::is_test_data_updated()) {
             $commandpath = 'php admin/tool/behat/cli/init.php';
-            throw new behat_stop_exception("Your behat test site is outdated, please run\n\n    " .
-                    $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
+            $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}
+
+EOF;
+            self::log_and_stop($message);
         }
+
         // Avoid parallel tests execution, it continues when the previous lock is released.
         test_lock::acquire('behat');
 
-        // Store the browser reset time if reset after N seconds is specified in config.php.
-        if (!empty($CFG->behat_restart_browser_after)) {
-            // Store the initial browser session opening.
-            self::$lastbrowsersessionstart = time();
-        }
-
         if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
-            throw new behat_stop_exception('You set $CFG->behat_faildump_path to a non-writable directory');
+            self::log_and_stop(
+                "The \$CFG->behat_faildump_path value is set to a non-writable directory ({$CFG->behat_faildump_path})."
+            );
         }
 
         // Handle interrupts on PHP7.
@@ -215,6 +197,25 @@ class behat_hooks extends behat_base {
         }
     }
 
+    /**
+     * Run final tests before running the suite.
+     *
+     * @BeforeSuite
+     * @param BeforeSuiteScope $scope scope passed by event fired before suite.
+     */
+    public static function before_suite_final_checks(BeforeSuiteScope $scope) {
+        $happy = defined('BEHAT_TEST');
+        $happy = $happy && defined('BEHAT_SITE_RUNNING');
+        $happy = $happy && php_sapi_name() == 'cli';
+        $happy = $happy && behat_util::is_test_mode_enabled();
+        $happy = $happy && behat_util::is_test_site();
+
+        if (!$happy) {
+            error_log('Behat only can modify the test database and the test dataroot!');
+            exit(1);
+        }
+    }
+
     /**
      * Gives access to moodle codebase, to keep track of feature start time.
      *
@@ -271,56 +272,108 @@ class behat_hooks extends behat_base {
     }
 
     /**
-     * Hook to capture before scenario event to get scope.
+     * Helper function to restart the Mink session.
+     */
+    protected function restart_session(): void {
+        $session = $this->getSession();
+        if ($session->isStarted()) {
+            $session->restart();
+        } else {
+            $session->start();
+        }
+        if ($this->running_javascript() && $this->getSession()->getDriver()->getWebDriverSessionId() === 'session') {
+            throw new DriverException('Unable to create a valid session');
+        }
+    }
+
+    /**
+     * Restart the session before each non-javascript scenario.
      *
+     * @BeforeScenario @~javascript
      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
-     * @BeforeScenario
      */
-    public function before_scenario_hook(BeforeScenarioScope $scope) {
-        try {
-            $this->before_scenario($scope);
-        } catch (behat_stop_exception $e) {
-            echo $e->getMessage() . PHP_EOL;
-            exit(1);
+    public function before_goutte_scenarios(BeforeScenarioScope $scope) {
+        if ($this->running_javascript()) {
+            // A bug in the BeforeScenario filtering prevents the @~javascript filter on this hook from working
+            // properly.
+            // See https://github.com/Behat/Behat/issues/1235 for further information.
+            return;
         }
+
+        $this->restart_session();
     }
 
     /**
-     * Resets the test environment.
+     * Start the session before the first javascript scenario.
+     *
+     * This is treated slightly differently to try to capture when Selenium is not running at all.
      *
+     * @BeforeScenario @javascript
      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
-     * @throws behat_stop_exception If here we are not using the test database it should be because of a coding error
      */
-    public function before_scenario(BeforeScenarioScope $scope) {
-        global $DB, $CFG;
-
-        // As many checks as we can.
-        if (!defined('BEHAT_TEST') ||
-               !defined('BEHAT_SITE_RUNNING') ||
-               php_sapi_name() != 'cli' ||
-               !behat_util::is_test_mode_enabled() ||
-               !behat_util::is_test_site()) {
-            throw new behat_stop_exception('Behat only can modify the test database and the test dataroot!');
+    public function before_first_scenario_start_session(BeforeScenarioScope $scope) {
+        if (!self::is_first_javascript_scenario()) {
+            // The first Scenario has started.
+            // The `before_subsequent_scenario_start_session` function will restart the session instead.
+            return;
         }
+        self::$firstjavascriptscenarioseen = true;
+
+        $docsurl = behat_command::DOCS_URL;
+        $driverexceptionmsg = <<<EOF
+
+The Selenium or WebDriver server is not running. You must start it to run tests that involve Javascript.
+See {$docsurl} for more information.
+
+The following debugging information is available:
+
+EOF;
+
 
-        $moreinfo = 'More info in ' . behat_command::DOCS_URL;
-        $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
         try {
-            $session = $this->getSession();
-            if (!$session->isStarted()) {
-                $session->start();
-            }
-        } catch (CurlExec $e) {
-            // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
-            // behat_util::check_server_status() we already checked that the server is running.
-            throw new behat_stop_exception($driverexceptionmsg);
-        } catch (DriverException $e) {
-            throw new behat_stop_exception($driverexceptionmsg);
+            $this->restart_session();
+        } catch (CurlExec | DriverException $e) {
+            // The CurlExec Exception is thrown by WebDriver.
+            self::log_and_stop(
+                $driverexceptionmsg . '. ' .
+                $e->getMessage() . "\n\n" .
+                format_backtrace($e->getTrace(), true)
+            );
         } catch (UnknownError $e) {
             // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
-            throw new behat_stop_exception($e->getMessage());
+            self::log_and_stop(
+                $e->getMessage() . "\n\n" .
+                format_backtrace($e->getTrace(), true)
+            );
+        }
+    }
+
+    /**
+     * Start the session before each javascript scenario.
+     *
+     * Note: Before the first scenario the @see before_first_scenario_start_session() function is used instead.
+     *
+     * @BeforeScenario @javascript
+     * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
+     */
+    public function before_subsequent_scenario_start_session(BeforeScenarioScope $scope) {
+        if (self::is_first_javascript_scenario()) {
+            // The initial init has not yet finished.
+            // The `before_first_scenario_start_session` function will have started the session instead.
+            return;
         }
 
+        $this->restart_session();
+    }
+
+    /**
+     * Resets the test environment.
+     *
+     * @BeforeScenario
+     * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
+     */
+    public function before_scenario_hook(BeforeScenarioScope $scope) {
+        global $DB;
         $suitename = $scope->getSuite()->getName();
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
@@ -354,9 +407,6 @@ class behat_hooks extends behat_base {
 
         }
 
-        // Reset mink session between the scenarios.
-        $session->reset();
-
         // Reset $SESSION.
         \core\session\manager::init_empty_session();
 
@@ -381,15 +431,6 @@ class behat_hooks extends behat_base {
         $user = $DB->get_record('user', array('username' => 'admin'));
         \core\session\manager::set_user($user);
 
-        // Reset the browser if specified in config.php.
-        if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
-            $now = time();
-            if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
-                $session->restart();
-                self::$lastbrowsersessionstart = $now;
-            }
-        }
-
         // Set the theme if not default.
         if ($suitename !== "default") {
             set_config('theme', $suitename);
@@ -576,25 +617,13 @@ class behat_hooks extends behat_base {
     }
 
     /**
-     * Executed after scenario having switch window to restart session.
-     * This is needed to close all extra browser windows and starting
-     * one browser window.
+     * Reset the session between each scenario.
      *
      * @param AfterScenarioScope $scope scope passed by event fired after scenario.
-     * @AfterScenario @_switch_window
+     * @AfterScenario
      */
-    public function after_scenario_switchwindow(AfterScenarioScope $scope) {
-        for ($count = 0; $count < behat_base::get_extended_timeout(); $count++) {
-            try {
-                $this->getSession()->restart();
-                break;
-            } catch (DriverException $e) {
-                // Wait for timeout and try again.
-                sleep(self::get_timeout());
-            }
-        }
-        // If session is not restarted above then it will try to start session before next scenario
-        // and if that fails then exception will be thrown.
+    public function reset_webdriver_between_scenarios(AfterScenarioScope $scope) {
+        $this->getSession()->stop();
     }
 
     /**
@@ -730,6 +759,15 @@ class behat_hooks extends behat_base {
         return !(self::$initprocessesfinished);
     }
 
+    /**
+     * Returns whether the first scenario of the suite is running
+     *
+     * @return bool
+     */
+    protected static function is_first_javascript_scenario(): bool {
+        return !self::$firstjavascriptscenarioseen;
+    }
+
     /**
      * Register a set of component selectors.
      *
@@ -769,20 +807,19 @@ class behat_hooks extends behat_base {
      * @param BeforeStepScope $scope
      * @BeforeStep
      */
-    public function first_step_setup_complete(BeforeStepScope $scope) {
+    public function first_step_setup_complete(BeforeStepScope $scope): void {
         self::$initprocessesfinished = true;
     }
 
-}
+    /**
+     * Log a notification, and then exit.
+     *
+     * @param   string $message The content to dispaly
+     */
+    protected static function log_and_stop(string $message): void {
+        error_log($message);
+
+        exit(1);
+    }
 
-/**
- * Behat stop exception
- *
- * This exception is thrown from before suite or scenario if any setup problem found.
- *
- * @package    core_test
- * @copyright  2016 Rajesh Taneja <rajesh@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class behat_stop_exception extends \Exception {
 }
index 3eed52f..052fe30 100644 (file)
@@ -194,7 +194,7 @@ class behat_navigation extends behat_base {
             // We just want to expand the node, we don't want to follow it.
             $node = $node->getParent();
         }
-        $node->click();
+        $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
     }
 
     /**
@@ -218,7 +218,7 @@ class behat_navigation extends behat_base {
             // We just want to expand the node, we don't want to follow it.
             $node = $node->getParent();
         }
-        $node->click();
+        $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
     }
 
     /**
@@ -245,7 +245,7 @@ class behat_navigation extends behat_base {
                 // don't wait, it is non-JS and we already waited for the DOM.
                 $siteadminlink = $this->getSession()->getPage()->find('named_exact', array('link', "'" . $siteadminstr . "'"));
                 if ($siteadminlink) {
-                    $siteadminlink->click();
+                    $this->execute('behat_general::i_click_on', [$siteadminlink, 'NodeElement']);
                 }
             }
         }
@@ -302,7 +302,7 @@ class behat_navigation extends behat_base {
             throw new ExpectationException('Navigation node "' . $nodetext . '" not found under "' .
                 implode(' > ', $parentnodes) . '"', $this->getSession());
         }
-        $nodetoclick->click();
+        $this->execute('behat_general::i_click_on', [$nodetoclick, 'NodeElement']);
     }
 
     /**
@@ -791,9 +791,8 @@ class behat_navigation extends behat_base {
         $node = $this->find('xpath', $xpath);
         $expanded = $node->getAttribute('aria-expanded');
         if ($expanded === 'false') {
-            $node->click();
+            $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
             $this->ensure_node_attribute_is_set($node, 'aria-expanded', 'true');
-            $this->wait_for_pending_js();
         }
     }
 
@@ -812,8 +811,7 @@ class behat_navigation extends behat_base {
         $node = $this->find('xpath', $xpath);
         $expanded = $node->getAttribute('aria-expanded');
         if ($expanded === 'true') {
-            $node->click();
-            $this->wait_for_pending_js();
+            $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
         }
     }
 
@@ -835,8 +833,8 @@ class behat_navigation extends behat_base {
     protected function go_to_main_course_page() {
         $url = $this->getSession()->getCurrentUrl();
         if (!preg_match('|/course/view.php\?id=[\d]+$|', $url)) {
-            $this->find('xpath', '//header//div[@id=\'page-navbar\']//a[contains(@href,\'/course/view.php?id=\')]')->click();
-            $this->execute('behat_general::wait_until_the_page_is_ready');
+            $node = $this->find('xpath', '//header//div[@id=\'page-navbar\']//a[contains(@href,\'/course/view.php?id=\')]');
+            $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
         }
     }
 
@@ -856,8 +854,8 @@ class behat_navigation extends behat_base {
             $tabxpath = '//ul[@role=\'tablist\']/li/a[contains(normalize-space(.), ' . $tabname . ')]';
             if ($node = $this->getSession()->getPage()->find('xpath', $tabxpath)) {
                 if ($this->running_javascript()) {
+                    $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
                     // Click on the tab and add 'active' tab to the xpath.
-                    $node->click();
                     $xpath .= '//div[contains(@class,\'active\')]';
                 } else {
                     // Add the tab content selector to the xpath.
@@ -881,8 +879,7 @@ class behat_navigation extends behat_base {
         if (!$node = $this->getSession()->getPage()->find('xpath', $xpath)) {
             throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"');
         }
-        $node->click();
-        $this->wait_for_pending_js();
+        $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
     }
 
     /**
@@ -929,8 +926,8 @@ class behat_navigation extends behat_base {
             $menuxpath = $this->find_header_administration_menu() ?: $this->find_page_administration_menu();
         }
         if ($menuxpath && $this->running_javascript()) {
-            $this->find('xpath', $menuxpath . '//a[@data-toggle=\'dropdown\']')->click();
-            $this->wait_for_pending_js();
+            $node = $this->find('xpath', $menuxpath . '//a[@data-toggle=\'dropdown\']');
+            $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
         }
     }
 
@@ -952,15 +949,14 @@ class behat_navigation extends behat_base {
             $isheader = false;
         }
 
-        $this->toggle_page_administration_menu($menuxpath);
+        $this->execute('behat_navigation::toggle_page_administration_menu', [$menuxpath]);
 
         if (!$isheader || count($nodelist) == 1) {
             $lastnode = end($nodelist);
             $linkname = behat_context_helper::escape($lastnode);
             $link = $this->getSession()->getPage()->find('xpath', $menuxpath . '//a[contains(normalize-space(.), ' . $linkname . ')]');
             if ($link) {
-                $link->click();
-                $this->wait_for_pending_js();
+                $this->execute('behat_general::i_click_on', [$link, 'NodeElement']);
                 return;
             }
         }
@@ -970,8 +966,7 @@ class behat_navigation extends behat_base {
             $linkname = behat_context_helper::escape(get_string('morenavigationlinks'));
             $link = $this->getSession()->getPage()->find('xpath', $menuxpath . '//a[contains(normalize-space(.), ' . $linkname . ')]');
             if ($link) {
-                $link->click();
-                $this->execute('behat_general::wait_until_the_page_is_ready');
+                $this->execute('behat_general::i_click_on', [$link, 'NodeElement']);
                 $this->select_on_administration_page($nodelist);
                 return;
             }
index 6e3ce5b..1e6c3b3 100644 (file)
@@ -79,7 +79,7 @@ class core_setuplib_testcase extends advanced_testcase {
         global $CFG;
 
         // This doesn't test them all possible ones, but these are set for unit tests.
-        $cfgnames = array('dataroot', 'dirroot', 'tempdir', 'cachedir', 'localcachedir');
+        $cfgnames = array('dataroot', 'dirroot', 'tempdir', 'backuptempdir', 'cachedir', 'localcachedir');
 
         $fixture  = '';
         $expected = '';
index 4e79b93..523e395 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.1 ===
+* The `$CFG->behat_retart_browser_after` configuration setting has been removed.
+  The browser session is now restarted between all tests.
+
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
     - cron_run_single_task()
index 90f397c..fe33a55 100644 (file)
@@ -59,6 +59,8 @@ if (strpos($CFG->airnotifierurl, message_airnotifier_manager::AIRNOTIFIER_PUBLIC
     }
 }
 
+echo $OUTPUT->header();
+
 $manager = new message_airnotifier_manager();
 $warnings = [];
 
@@ -107,6 +109,5 @@ foreach ($warnings as $warning) {
 
 $msg .= $OUTPUT->continue_button($returl);
 
-echo $OUTPUT->header();
 echo $OUTPUT->box($msg, 'generalbox ');
 echo $OUTPUT->footer();
index 558c6ae..91766bc 100644 (file)
@@ -26,7 +26,8 @@ defined('MOODLE_INTERNAL') || die;
 if ($ADMIN->fulltree) {
 
     $notify = new \core\output\notification(
-        get_string('moodleappsportallimitswarning', 'message_airnotifier'),
+        get_string('moodleappsportallimitswarning', 'message_airnotifier',
+            (new moodle_url('https://apps.moodle.com'))->out()),
         \core\output\notification::NOTIFY_WARNING);
     $settings->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $OUTPUT->render($notify)));
 
index be6b0a1..86b4151 100644 (file)
@@ -98,6 +98,7 @@ class convert_submissions extends scheduled_task {
             }
 
             mtrace('Convert ' . count($users) . ' submission attempt(s) for assignment ' . $assignmentid);
+            $conversionrequirespolling = false;
 
             foreach ($users as $userid) {
                 try {
@@ -107,6 +108,7 @@ class convert_submissions extends scheduled_task {
                         case combined_document::STATUS_READY_PARTIAL:
                         case combined_document::STATUS_PENDING_INPUT:
                             // The document has not been converted yet or is somehow still ready.
+                            $conversionrequirespolling = true;
                             continue 2;
                     }
                     document_services::get_page_images_for_attempt(
@@ -127,7 +129,9 @@ class convert_submissions extends scheduled_task {
             }
 
             // Remove from queue.
-            $DB->delete_records('assignfeedback_editpdf_queue', array('id' => $record->id));
+            if (!$conversionrequirespolling) {
+                $DB->delete_records('assignfeedback_editpdf_queue', array('id' => $record->id));
+            }
 
         }
     }
index 0288cb1..a56b121 100644 (file)
@@ -88,7 +88,7 @@ $string['limitanswers'] = 'Limit the number of responses allowed';
 $string['modulename'] = 'Choice';
 $string['modulename_help'] = 'The choice activity module enables a teacher to ask a single question and offer a selection of possible responses.
 
-Choice results may be published after students have answered, after a certain date, or not at all. Results may be published with student names or anonymously.
+Choice results may be published after students have answered, after a certain date, or not at all. Results may be published with student names or anonymously (though teachers always see student names and their responses).
 
 A choice activity may be used
 
index 56b2dbc..8676816 100644 (file)
     * none
 
     Context variables required for this template:
+    * id
     * hascapabilitygroups
     * state
     ** pending|configured|rejected|unknown
     ** text
     * courseid
     * urls
-    ** course
+    ** course - optional, required if courseid > 0
     ** icon
+    ** edit
     ** publickeyset
     ** accesstoken
     ** authrequest
     * deploymentid
     * instancecount
 
+    Example context (json):
+    {
+        "id": 1,
+        "name": "Example Tool",
+        "description": "This is an example tool",
+        "platformid": "https:\/\/moodle.example.com",
+        "courseid": 0,
+        "hascapabilitygroups": false,
+        "state": {
+            "text": "Active",
+            "pending": false,
+            "configured": true,
+            "rejected": false,
+            "unknown": false
+        },
+        "urls": {
+            "icon": "https://www.example.com/icon1.png",
+            "edit": "https://www.example.com/mod/lti/typessettings.php",
+            "publickeyset": "https:\/\/moodle.example.com\/mod\/lti\/certs.php",
+            "accesstoken": "https:\/\/moodle.example.com\/mod\/lti\/token.php",
+            "authrequest": "https:\/\/moodle.example.com\/mod\/lti\/auth.php"
+        },
+        "clientid": null,
+        "deploymentid": 1,
+        "instancecount": 0
+    }
+
 }}
 <div class="tool-card" data-type-id="{{id}}" data-uniqid="{{uniqid}}"
     data-modaltitle="{{#str}} tooldetailsmodaltitle, mod_lti {{/str}}"
                     <a class="delete" href="#" title="{{#str}} delete {{/str}}">{{#pix}} t/delete, core, {{#str}} delete {{/str}}{{/pix}}</a>
                 </div>
             </div>
-            <img class="tool-card-icon" src="{{{urls.icon}}}" alt="{{name}}"></img>
+            <img class="tool-card-icon" src="{{{urls.icon}}}" alt="{{name}}">
             <div class="contenteditable-container">
                 <h4 class="name" contenteditable="true">{{name}}</h4>
                 <div class="overlay-container">{{> mod_lti/loader }}</div>
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 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 5c1cfb3..d5f1285 100644 (file)
@@ -81,27 +81,19 @@ class report extends \mod_scorm\report {
                         && ($attemptsmode != SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO);
         // Select the students.
         $nostudents = false;
-
+        list($allowedlistsql, $params) = get_enrolled_sql($contextmodule, 'mod/scorm:savetrack', (int) $currentgroup);
         if (empty($currentgroup)) {
             // All users who can attempt scoes.
-            if (!$students = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id', '', '', '', '', '', false)) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsyet'));
                 $nostudents = true;
-                $allowedlist = '';
-            } else {
-                $allowedlist = array_keys($students);
             }
-            unset($students);
         } else {
             // All users who can attempt scoes and who are in the currently selected group.
-            if (!$groupstudents = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id', '', '', '',
-                                                            $currentgroup, '', false)) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsingroup'));
                 $nostudents = true;
-                $groupstudents = array();
             }
-            $allowedlist = array_keys($groupstudents);
-            unset($groupstudents);
         }
 
         if ( !$nostudents ) {
@@ -273,8 +265,6 @@ class report extends \mod_scorm\report {
                 $csvexport->set_filename($filename, ".txt");
                 $csvexport->add_data($headers);
             }
-            $params = array();
-            list($usql, $params) = $DB->get_in_or_equal($allowedlist, SQL_PARAMS_NAMED);
             // Construct the SQL.
             $select = 'SELECT DISTINCT '.$DB->sql_concat('u.id', '\'#\'', 'COALESCE(st.attempt, 0)').' AS uniqueid, ';
             $select .= 'st.scormid AS scormid, st.attempt AS attempt, ' .
@@ -287,15 +277,15 @@ class report extends \mod_scorm\report {
             switch ($attemptsmode) {
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH:
                     // Show only students with attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NOT NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NOT NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO:
                     // Show only students without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_ALL_STUDENTS:
                     // Show all students with or without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND (st.userid IS NOT NULL OR st.userid IS NULL)';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND (st.userid IS NOT NULL OR st.userid IS NULL)";
                     break;
             }
 
index 4971d5c..b295f00 100644 (file)
@@ -50,18 +50,15 @@ class report extends \mod_scorm\report {
      * Get the data for the report.
      *
      * @param int $scoid The sco ID.
-     * @param array $allowedlist The list of user IDs allowed to be displayed.
+     * @param array $allowedlist The SQL and params to get the userlist.
      * @return array of data indexed per bar.
      */
-    protected function get_data($scoid, $allowedlist = []) {
+    protected function get_data($scoid, $allowedlistsql) {
         global $DB;
         $data = array_fill(0, self::BANDS, 0);
-        if (empty($allowedlist)) {
-            return $data;
-        }
 
-        list($usql, $params) = $DB->get_in_or_equal($allowedlist);
-        $params[] = $scoid;
+        list($allowedlist, $params) = $allowedlistsql;
+        $params = array_merge($params, ['scoid' => $scoid]);
 
         // Construct the SQL.
         $sql = "SELECT DISTINCT " . $DB->sql_concat('st.userid', '\'#\'', 'COALESCE(st.attempt, 0)') . " AS uniqueid,
@@ -70,7 +67,7 @@ class report extends \mod_scorm\report {
                        st.attempt AS attempt,
                        st.scoid AS scoid
                   FROM {scorm_scoes_track} st
-                 WHERE st.userid $usql AND st.scoid = ?";
+                 WHERE st.userid IN ({$allowedlist}) AND st.scoid = :scoid";
         $attempts = $DB->get_records_sql($sql, $params);
 
         $usergrades = [];
@@ -144,15 +141,7 @@ class report extends \mod_scorm\report {
 
         // Find out current restriction.
         $group = groups_get_activity_group($cm, true);
-        if (empty($group)) {
-            // All users who can attempt scoes.
-            $students = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id' , '', '', '', '', '', false);
-            $allowedlist = empty($students) ? array() : array_keys($students);
-        } else {
-            // All users who can attempt scoes and who are in the currently selected group.
-            $groupstudents = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id', '', '', '', $group, '', false);
-            $allowedlist = empty($groupstudents) ? array() : array_keys($groupstudents);
-        }
+        $allowedlistsql = get_enrolled_sql($contextmodule, 'mod/scorm:savetrack', (int) $group);
 
         // Labels.
         $labels = [get_string('invaliddata', 'scormreport_graphs')];
@@ -164,7 +153,7 @@ class report extends \mod_scorm\report {
             foreach ($scoes as $sco) {
                 if ($sco->launch != '') {
 
-                    $data = $this->get_data($sco->id, $allowedlist);
+                    $data = $this->get_data($sco->id, $allowedlistsql);
                     $series = new chart_series($sco->title, $data);
 
                     $chart = new chart_bar();
index 1deef30..1bc5afb 100644 (file)
@@ -98,28 +98,19 @@ class report extends \mod_scorm\report {
                 && ($attemptsmode != SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO);
         // Select the students.
         $nostudents = false;
-
+        list($allowedlistsql, $params) = get_enrolled_sql($contextmodule, 'mod/scorm:savetrack', (int) $currentgroup);
         if (empty($currentgroup)) {
             // All users who can attempt scoes.
-            if (!$students = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id', '', '', '', '', '', false)) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsyet'));
                 $nostudents = true;
-                $allowedlist = '';
-            } else {
-                $allowedlist = array_keys($students);
             }
-            unset($students);
         } else {
             // All users who can attempt scoes and who are in the currently selected group.
-            if (!$groupstudents = get_users_by_capability($contextmodule,
-                                                            'mod/scorm:savetrack', 'u.id', '', '', '',
-                                                            $currentgroup, '', false)) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsingroup'));
                 $nostudents = true;
-                $groupstudents = array();
             }
-            $allowedlist = array_keys($groupstudents);
-            unset($groupstudents);
         }
         if ( !$nostudents ) {
             // Now check if asked download of data.
@@ -163,8 +154,6 @@ class report extends \mod_scorm\report {
                 }
             }
 
-            $params = array();
-            list($usql, $params) = $DB->get_in_or_equal($allowedlist, SQL_PARAMS_NAMED);
             // Construct the SQL.
             $select = 'SELECT DISTINCT '.$DB->sql_concat('u.id', '\'#\'', 'COALESCE(st.attempt, 0)').' AS uniqueid, ';
             $select .= 'st.scormid AS scormid, st.attempt AS attempt, ' .
@@ -177,15 +166,15 @@ class report extends \mod_scorm\report {
             switch ($attemptsmode) {
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH:
                     // Show only students with attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NOT NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NOT NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO:
                     // Show only students without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_ALL_STUDENTS:
                     // Show all students with or without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND (st.userid IS NOT NULL OR st.userid IS NULL)';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND (st.userid IS NOT NULL OR st.userid IS NULL)";
                     break;
             }
 
index 85488c7..4e2ad62 100644 (file)
@@ -92,28 +92,19 @@ class report extends \mod_scorm\report {
                 && ($attemptsmode != SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO);
         // Select the students.
         $nostudents = false;
-
+        list($allowedlistsql, $params) = get_enrolled_sql($contextmodule, 'mod/scorm:savetrack', (int) $currentgroup);
         if (empty($currentgroup)) {
             // All users who can attempt scoes.
-            if (!$students = get_users_by_capability($contextmodule, 'mod/scorm:savetrack', 'u.id', '', '', '', '', '', false)) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsyet'));
                 $nostudents = true;
-                $allowedlist = '';
-            } else {
-                $allowedlist = array_keys($students);
             }
-            unset($students);
         } else {
             // All users who can attempt scoes and who are in the currently selected group.
-            $groupstudents = get_users_by_capability($contextmodule, 'mod/scorm:savetrack',
-                                                     'u.id', '', '', '', $currentgroup, '', false);
-            if (!$groupstudents) {
+            if (!$DB->record_exists_sql($allowedlistsql, $params)) {
                 echo $OUTPUT->notification(get_string('nostudentsingroup'));
                 $nostudents = true;
-                $groupstudents = array();
             }
-            $allowedlist = array_keys($groupstudents);
-            unset($groupstudents);
         }
         if ( !$nostudents ) {
             // Now check if asked download of data.
@@ -157,8 +148,6 @@ class report extends \mod_scorm\report {
                 }
             }
 
-            $params = array();
-            list($usql, $params) = $DB->get_in_or_equal($allowedlist, SQL_PARAMS_NAMED);
             // Construct the SQL.
             $select = 'SELECT DISTINCT '.$DB->sql_concat('u.id', '\'#\'', 'COALESCE(st.attempt, 0)').' AS uniqueid, ';
             $select .= 'st.scormid AS scormid, st.attempt AS attempt, ' .
@@ -171,15 +160,15 @@ class report extends \mod_scorm\report {
             switch ($attemptsmode) {
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH:
                     // Show only students with attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NOT NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NOT NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_STUDENTS_WITH_NO:
                     // Show only students without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND st.userid IS NULL';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND st.userid IS NULL";
                     break;
                 case SCORM_REPORT_ATTEMPTS_ALL_STUDENTS:
                     // Show all students with or without attempts.
-                    $where = ' WHERE u.id ' .$usql. ' AND (st.userid IS NOT NULL OR st.userid IS NULL)';
+                    $where = " WHERE u.id IN ({$allowedlistsql}) AND (st.userid IS NOT NULL OR st.userid IS NULL)";
                     break;
             }
 
diff --git a/mod/scorm/tests/behat/behat_mod_scorm.php b/mod/scorm/tests/behat/behat_mod_scorm.php
deleted file mode 100644 (file)
index ed7e16f..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Steps definitions related to the SCORM activity module.
- *
- * @package    mod_scorm
- * @category   test
- * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
-
-require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
-
-use Behat\Behat\Hook\Scope\AfterScenarioScope;
-
-/**
- * Steps definitions related to the SCORM activity module.
- *
- * @package    mod_scorm
- * @category   test
- * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class behat_mod_scorm extends behat_base {
-
-    /**
-     * Restart the Seleium Session after each mod_scorm Scenario.
-     *
-     * This prevents issues with the scorm player's onbeforeunload event, and cached SCORM content being served to the
-     * browser in subsequent tests.
-     *
-     * @AfterScenario @mod_scorm
-     * @param AfterScenarioScope $scope The scenario scope
-     */
-    public function reset_after_scorm(AfterScenarioScope $scope) {
-        $this->getSession()->stop();
-    }
-}
index 9528995..0d5ade3 100644 (file)
@@ -58,7 +58,7 @@ class question_export_form extends moodleform {
             if (get_string_manager()->string_exists('pluginname_help', 'qformat_' . $shortname)) {
                 $separator .= $OUTPUT->help_icon('pluginname', 'qformat_' . $shortname);
             }
-            $separator .= '<br>';
+            $separator .= '<div class="w-100"></div>';
             $separators[] = $separator;
         }
 
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 9e1953f..d315b9d 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js and b/question/type/ddimageortext/amd/build/question.min.js differ
index 1a2e53b..28b05d3 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js.map and b/question/type/ddimageortext/amd/build/question.min.js.map differ
index 248c56f..8d031bf 100644 (file)
@@ -336,6 +336,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                         cloneDrag.removeClass('beingdragged');
                         cloneDrag.removeAttr('tabindex');
                         hiddenDrag.after(cloneDrag);
+                        questionManager.addEventHandlersToDrag(cloneDrag);
                         drag.offset(cloneDrag.offset());
                     } else {
                         hiddenDrag.addClass('active');
@@ -529,6 +530,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                         cloneDrag.removeClass('beingdragged');
                         cloneDrag.removeAttr('tabindex');
                         hiddenDrag.after(cloneDrag);
+                        questionManager.addEventHandlersToDrag(cloneDrag);
                         nextDrag.offset(cloneDrag.offset());
                     } else {
                         hiddenDrag.addClass('active');
@@ -623,7 +625,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             {
                 duration: 'fast',
                 done: function() {
-                    $('body').trigger('dragmoved', [drag, target, thisQ]);
+                    $('body').trigger('qtype_ddimageortext-dragmoved', [drag, target, thisQ]);
                     M.util.js_complete('qtype_ddimageortext-animate-' + thisQ.containerId);
                 }
             }
@@ -962,17 +964,16 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * Set up the event handlers that make this question type work. (Done once per page.)
          */
         setupEventHandlers: function() {
+            // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.
+            questionManager.addEventHandlersToDrag($('.que.ddimageortext:not(.qtype_ddimageortext-readonly) .draghome'));
             $('body')
-                .on('mousedown touchstart',
-                    '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .draghome',
-                    questionManager.handleDragStart)
                 .on('keydown',
                     '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dropzones .dropzone',
                     questionManager.handleKeyPress)
                 .on('keydown',
                     '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .draghome.placed:not(.beingdragged)',
                     questionManager.handleKeyPress)
-                .on('dragmoved', questionManager.handleDragMoved);
+                .on('qtype_ddimageortext-dragmoved', questionManager.handleDragMoved);
             $(window).on('resize', function() {
                 questionManager.handleWindowResize(false);
             });
@@ -989,6 +990,15 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             }, 100);
         },
 
+        /**
+         * Binding the drag/touch event again for newly created element.
+         *
+         * @param {jQuery} element Element to bind the event
+         */
+        addEventHandlersToDrag: function(element) {
+            element.on('mousedown touchstart', questionManager.handleDragStart);
+        },
+
         /**
          * Handle mouse down / touch start events on drags.
          * @param {Event} e the DOM event.
index 7f86df9..9b4d45e 100644 (file)
Binary files a/question/type/ddmarker/amd/build/question.min.js and b/question/type/ddmarker/amd/build/question.min.js differ
index 4d3ac96..8562ce1 100644 (file)
Binary files a/question/type/ddmarker/amd/build/question.min.js.map and b/question/type/ddmarker/amd/build/question.min.js.map differ
index 7dfa8ff..5d516de 100644 (file)
@@ -568,6 +568,7 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
             this.getDragClone(drag)
                 .removeClass('active')
                 .after(dragclone);
+            questionManager.addEventHandlersToMarker(dragclone);
         }
     };
 
@@ -678,31 +679,9 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
          * Set up the event handlers that make this question type work. (Done once per page.)
          */
         setupEventHandlers: function() {
-            $('body')
-                .on('mousedown touchstart',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleDragStart)
-                .on('mousedown touchstart',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleDragStart)
-                .on('keydown keypress',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleKeyPress)
-                .on('keydown keypress',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleKeyPress)
-                .on('focusin',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
-                        questionManager.handleKeyboardFocus(e, true);
-                    })
-                .on('focusin',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
-                        questionManager.handleKeyboardFocus(e, true);
-                    })
-                .on('focusout',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
-                        questionManager.handleKeyboardFocus(e, false);
-                    })
-                .on('focusout',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
-                        questionManager.handleKeyboardFocus(e, false);
-                    });
+            // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.
+            questionManager.addEventHandlersToMarker($('.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker'));
+            questionManager.addEventHandlersToMarker($('.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker'));
             $(window).on('resize', function() {
                 questionManager.handleWindowResize(false);
             });
@@ -719,6 +698,23 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
             }, 100);
         },
 
+        /**
+         * Binding the event again for newly created element.
+         *
+         * @param {jQuery} element Element to bind the event
+         */
+        addEventHandlersToMarker: function(element) {
+            element
+                .on('mousedown touchstart', questionManager.handleDragStart)
+                .on('keydown keypress', questionManager.handleKeyPress)
+                .focusin(function(e) {
+                    questionManager.handleKeyboardFocus(e, true);
+                })
+                .focusout(function(e) {
+                    questionManager.handleKeyboardFocus(e, false);
+                });
+        },
+
         /**
          * Handle mouse down / touch start events on markers.
          * @param {Event} e the DOM event.
index d526782..5aa26ef 100644 (file)
Binary files a/question/type/ddwtos/amd/build/ddwtos.min.js and b/question/type/ddwtos/amd/build/ddwtos.min.js differ
index 6e2a847..9f6a640 100644 (file)
Binary files a/question/type/ddwtos/amd/build/ddwtos.min.js.map and b/question/type/ddwtos/amd/build/ddwtos.min.js.map differ
index 2fb2eec..bad4324 100644 (file)
@@ -212,6 +212,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                         var cloneDrag = drag.clone();
                         cloneDrag.removeClass('beingdragged');
                         hiddenDrag.after(cloneDrag);
+                        questionManager.addEventHandlersToDrag(cloneDrag);
                         drag.offset(cloneDrag.offset());
                     } else {
                         hiddenDrag.addClass('active');
@@ -404,6 +405,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                         cloneDrag.removeClass('beingdragged');
                         cloneDrag.removeAttr('tabindex');
                         hiddenDrag.after(cloneDrag);
+                        questionManager.addEventHandlersToDrag(cloneDrag);
                         nextDrag.offset(cloneDrag.offset());
                     } else {
                         hiddenDrag.addClass('active');
@@ -498,7 +500,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             {
                 duration: 'fast',
                 done: function() {
-                    $('body').trigger('dragmoved', [drag, target, thisQ]);
+                    $('body').trigger('qtype_ddwtos-dragmoved', [drag, target, thisQ]);
                     M.util.js_complete('qtype_ddwtos-animate-' + thisQ.containerId);
                 }
             }
@@ -738,16 +740,25 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * Set up the event handlers that make this question type work. (Done once per page.)
          */
         setupEventHandlers: function() {
-            $('body').on('mousedown touchstart',
-                '.que.ddwtos:not(.qtype_ddwtos-readonly) span.draghome',
-                questionManager.handleDragStart)
+            // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.
+            questionManager.addEventHandlersToDrag($('.que.ddwtos:not(.qtype_ddwtos-readonly) span.draghome'));
+            $('body')
                 .on('keydown',
                     '.que.ddwtos:not(.qtype_ddwtos-readonly) span.drop',
                     questionManager.handleKeyPress)
                 .on('keydown',
                     '.que.ddwtos:not(.qtype_ddwtos-readonly) span.draghome.placed:not(.beingdragged)',
                     questionManager.handleKeyPress)
-                .on('dragmoved', questionManager.handleDragMoved);
+                .on('qtype_ddwtos-dragmoved', questionManager.handleDragMoved);
+        },
+
+        /**
+         * Binding the drag/touch event again for newly created element.
+         *
+         * @param {jQuery} element Element to bind the event
+         */
+        addEventHandlersToDrag: function(element) {
+            element.on('mousedown touchstart', questionManager.handleDragStart);
         },
 
         /**
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 d8d20a9..6a20c2d 100644 (file)
Binary files a/report/competency/amd/build/grading_popup.min.js and b/report/competency/amd/build/grading_popup.min.js differ
index f32b8c2..c7f243c 100644 (file)
Binary files a/report/competency/amd/build/grading_popup.min.js.map and b/report/competency/amd/build/grading_popup.min.js.map differ
index 43984a4..7aeba24 100644 (file)
Binary files a/report/competency/amd/build/user_course_navigation.min.js and b/report/competency/amd/build/user_course_navigation.min.js differ
index f2dcf64..c971332 100644 (file)
Binary files a/report/competency/amd/build/user_course_navigation.min.js.map and b/report/competency/amd/build/user_course_navigation.min.js.map differ
index e395d51..a38fd92 100644 (file)
@@ -59,10 +59,9 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/log', 'cor
             args: {userid: userId, competencyid: competencyId, courseid: courseId},
         }]);
 
-        $.when.apply($, requests).then(function(context) {
-            this._contextLoaded.bind(this)(context);
-            return;
-        }.bind(this)).catch(notification.exception);
+        $.when(requests[0], requests[1])
+        .then(this._contextLoaded.bind(this))
+        .catch(notification.exception);
     };
 
     /**
@@ -70,23 +69,37 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/log', 'cor
      *
      * @method _contextLoaded
      * @param {Object} context
+     * @returns {Promise}
      */
     GradingPopup.prototype._contextLoaded = function(context) {
-        var self = this;
-
         // We have to display user info in popup.
         context.displayuser = true;
-        templates.render('tool_lp/user_competency_summary_in_course', context).done(function(html, js) {
-            str.get_string('usercompetencysummary', 'report_competency').done(function(title) {
-                (new Dialogue(title, html, templates.runTemplateJS.bind(templates, js), self._refresh.bind(self), true));
-            }).fail(notification.exception);
-        }).fail(notification.exception);
+
+        M.util.js_pending('report_competency/grading_popup:_contextLoaded');
+
+        return $.when(
+            str.get_string('usercompetencysummary', 'report_competency'),
+            templates.render('tool_lp/user_competency_summary_in_course', context)
+        )
+        .then(function(title, templateData) {
+            return new Dialogue(
+                title,
+                templateData[0],
+                function() {
+                    templates.runTemplateJS(templateData[1]);
+                    M.util.js_complete('report_competency/grading_popup:_contextLoaded');
+                },
+                this._refresh.bind(this),
+                true
+            );
+        }.bind(this));
     };
 
     /**
      * Refresh the page.
      *
      * @method _refresh
+     * @returns {Promise}
      */
     GradingPopup.prototype._refresh = function() {
         var region = $(this._regionSelector);
@@ -99,7 +112,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/log', 'cor
             moduleId = 0;
         }
 
-        ajax.call([{
+        return ajax.call([{
             methodname: 'report_competency_data_for_report',
             args: {courseid: courseId, userid: userId, moduleid: moduleId},
             done: this._pageContextLoaded.bind(this),
@@ -114,10 +127,13 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/log', 'cor
      * @param {Object} context
      */
     GradingPopup.prototype._pageContextLoaded = function(context) {
-        var self = this;
-        templates.render('report_competency/report', context).done(function(html, js) {
-            templates.replaceNode(self._regionSelector, html, js);
-        }).fail(notification.exception);
+        templates.render('report_competency/report', context)
+        .then(function(html, js) {
+            templates.replaceNode(this._regionSelector, html, js);
+
+            return;
+        }.bind(this))
+        .catch(notification.exception);
     };
 
     /** @type {String} The selector for the region with the user competencies */
index ae2bbc5..146094b 100644 (file)
@@ -50,6 +50,8 @@ define(['jquery'], function($) {
      * @param {Event} e the event
      */
     UserCourseNavigation.prototype._userChanged = function(e) {
+        // Note: This change causes a page reload and is intentionally not paired with a js_complete call.
+        M.util.js_pending('report_competency/user_course_navigation:_userChanged');
         var newUserId = $(e.target).val();
         var queryStr = '?user=' + newUserId + '&id=' + this._courseId + '&mod=' + this._moduleId;
         document.location = this._baseUrl + queryStr;
@@ -62,6 +64,8 @@ define(['jquery'], function($) {
      * @param {Event} e the event
      */
     UserCourseNavigation.prototype._moduleChanged = function(e) {
+        // Note: This change causes a page reload and is intentionally not paired with a js_complete call.
+        M.util.js_pending('report_competency/user_course_navigation:_moduleChanged');
         var newModuleId = $(e.target).val();
         var queryStr = '?mod=' + newModuleId + '&id=' + this._courseId + '&user=' + this._userId;
         document.location = this._baseUrl + queryStr;
index 6c1fc25..ccf0eb8 100644 (file)
@@ -76,7 +76,7 @@ $output = $PAGE->get_renderer('report_competency');
 echo $output->header();
 $baseurl = new moodle_url('/report/competency/index.php');
 $nav = new \report_competency\output\user_course_navigation($currentuser, $course->id, $baseurl, $currentmodule);
-echo $output->render($nav);
+$top = $output->render($nav);
 if ($currentuser > 0) {
     $user = core_user::get_user($currentuser);
     $usercontext = context_user::instance($currentuser);
@@ -88,9 +88,9 @@ if ($currentuser > 0) {
     if ($currentmodule > 0) {
         $title = get_string('filtermodule', 'report_competency', format_string($cm->name));
     }
-    echo $output->context_header($userheading, 3);
+    $top .= $output->context_header($userheading, 3);
 }
-echo $output->container('', 'clearfix');
+echo $output->container($top, 'clearfix');
 echo $output->heading($title, 3);
 
 if ($currentuser > 0) {
index 3630be6..8aff844 100644 (file)
Binary files a/theme/boost/amd/build/pending.min.js and b/theme/boost/amd/build/pending.min.js differ
index 09b04e7..1d1b9b9 100644 (file)
Binary files a/theme/boost/amd/build/pending.min.js.map and b/theme/boost/amd/build/pending.min.js.map differ
index 4b3a548..ad036d4 100644 (file)
@@ -121,13 +121,13 @@ export default () => {
         pairs.forEach(pair => {
             const eventStart = `${pair.start}.bs.${key}`;
             const eventEnd = `${pair.end}.bs.${key}`;
-            jQuery(document.body).on(eventStart, () => {
+            jQuery(document.body).on(eventStart, e => {
                 M.util.js_pending(eventEnd);
+                jQuery(e.target).one(eventEnd, () => {
+                    M.util.js_complete(eventEnd);
+                });
             });
 
-            jQuery(document.body).on(eventEnd, () => {
-                M.util.js_complete(eventEnd);
-            });
         });
     });
 };
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 acbd6c0..b74f3bd 100644 (file)
@@ -38,9 +38,21 @@ if (!$parts) {
     combo_not_found();
 }
 
-$etag = sha1($parts);
 $parts = trim($parts, '&');
 
+// Remove any duplicate parts, since each file only needs to be loaded once (which also helps reduce total file size).
+$parts = implode('&', array_unique(explode('&', $parts)));
+
+// Limit length of parts to match the YUI loader limit of 1024, to prevent loading an arbitrary number of files.
+if (strlen($parts) > 1024) {
+    $parts = substr($parts, 0, 1024);
+
+    // If the shortened $parts has been cut off mid-way through a filename, trim back to the end of the previous filename.
+    if (substr($parts, -3) !== '.js' && substr($parts, -4) !== '.css') {
+        $parts = substr($parts, 0, strrpos($parts, '&'));
+    }
+}
+
 // find out what we are serving - only one type per request
 $content = '';
 if (substr($parts, -3) === '.js') {
@@ -51,6 +63,8 @@ if (substr($parts, -3) === '.js') {
     combo_not_found();
 }
 
+$etag = sha1($parts);
+
 // if they are requesting a revision that's not -1, and they have supplied an
 // If-Modified-Since header, we can send back a 304 Not Modified since the
 // content never changes (the rev number is increased any time the content changes)
index c1a4499..f7cc197 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020061500.03;              // 20200615      = branching date YYYYMMDD - do not modify!
+$version  = 2020061501.01;              // 20200615      = branching date YYYYMMDD - do not modify!
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9+ (Build: 20200626)'; // Human-friendly version name
+$release  = '3.9.1 (Build: 20200713)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_STABLE;             // This version's maturity level.