Merge branch 'MDL-69111-39' of git://github.com/bmbrands/moodle into MOODLE_39_STABLE
authorJun Pataleta <jun@moodle.com>
Thu, 6 Aug 2020 03:36:48 +0000 (11:36 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 6 Aug 2020 03:36:48 +0000 (11:36 +0800)
83 files changed:
admin/cli/install.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/createdatarequest_form.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/xmldb/actions/check_defaults/check_defaults.class.php
analytics/classes/manager.php
auth/email/tests/behat/behat_auth_email.php
auth/tests/behat/behat_auth.php
backup/util/ui/tests/behat/behat_backup.php
calendar/tests/behat/behat_calendar.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/src/activitychooser.js
course/classes/local/service/content_item_service.php
course/templates/local/activitychooser/error.mustache [new file with mode: 0644]
customfield/field/textarea/classes/data_controller.php
customfield/field/textarea/tests/plugin_test.php
grade/import/csv/classes/load_data.php
install/lang/bar/admin.php [new file with mode: 0644]
install/lang/bar/langconfig.php [new file with mode: 0644]
install/lang/gl/error.php
install/lang/gl/install.php
install/lang/hi/moodle.php
install/lang/hi_kids/langconfig.php [new file with mode: 0644]
install/lang/km/error.php
lib/adminlib.php
lib/behat/behat_base.php
lib/behat/form_field/behat_form_editor.php
lib/behat/form_field/behat_form_passwordunmask.php
lib/classes/dataformat.php
lib/db/upgrade.php
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js
lib/editor/atto/plugins/undo/yui/src/button/js/button.js
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/tablelib.php
lib/templates/paging_bar.mustache
lib/tests/behat/behat_app.php
lib/tests/behat/behat_forms.php
lib/tests/behat/behat_general.php
lib/tests/behat/behat_hooks.php
lib/tests/behat/behat_navigation.php
login/index.php
message/classes/api.php
message/templates/message_index.mustache
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_navigation.min.js.map
mod/assign/amd/src/grading_navigation.js
mod/assign/feedback/editpdf/ajax.php
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/feedback/editpdf/tests/behat/behat_assignfeedback_editpdf.php
mod/assign/tests/behat/grading_app_filters.feature [new file with mode: 0644]
mod/feedback/classes/responses_table.php
mod/forum/classes/privacy/provider.php
mod/forum/classes/subscriptions.php
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/discuss.php
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/version.php
mod/lesson/classes/local/numeric/helper.php [new file with mode: 0644]
mod/lesson/lang/en/lesson.php
mod/lesson/locallib.php
mod/lesson/pagetypes/numerical.php
mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature [new file with mode: 0644]
mod/lesson/tests/numeric_helper_test.php [new file with mode: 0644]
mod/lti/tests/externallib_test.php
mod/quiz/accessrule/seb/db/install.xml
mod/quiz/lib.php
mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php
question/type/ddmarker/tests/behat/behat_qtype_ddmarker.php
search/tests/behat/behat_search.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/index.php
user/tests/behat/table_column_visibility.feature [new file with mode: 0644]
version.php

index cfc8ea0..bba5cea 100644 (file)
@@ -266,6 +266,7 @@ list($options, $unrecognized) = cli_get_params(
 );
 
 $interactive = empty($options['non-interactive']);
+$skipdatabase = $options['skip-database'];
 
 // set up language
 $lang = clean_param($options['lang'], PARAM_SAFEDIR);
@@ -637,96 +638,100 @@ do {
     }
 } while ($hintdatabase !== '');
 
-// ask for fullname
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('fullsitename', 'moodle'));
+// If --skip-database option is provided, we do not need to ask for site fullname, shortname, adminuser, adminpass, adminemail.
+// These fields will be requested during the database install part.
+if (!$skipdatabase) {
+    // Ask for fullname.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('fullsitename', 'moodle'));
 
-    if ($options['fullname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['fullname']);
+        if ($options['fullname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['fullname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+
+        do {
+            $options['fullname'] = cli_input($prompt, $options['fullname']);
+        } while (empty($options['fullname']));
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        if (empty($options['fullname'])) {
+            $a = (object)['option' => 'fullname', 'value' => $options['fullname']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
 
-    do {
-        $options['fullname'] = cli_input($prompt, $options['fullname']);
-    } while (empty($options['fullname']));
-} else {
-    if (empty($options['fullname'])) {
-        $a = (object)array('option'=>'fullname', 'value'=>$options['fullname']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
-    }
-}
+    // Ask for shortname.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('shortsitename', 'moodle'));
 
-// ask for shortname
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('shortsitename', 'moodle'));
+        if ($options['shortname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['shortname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
 
-    if ($options['shortname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['shortname']);
+        do {
+            $options['shortname'] = cli_input($prompt, $options['shortname']);
+        } while (empty($options['shortname']));
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        if (empty($options['shortname'])) {
+            $a = (object)['option' => 'shortname', 'value' => $options['shortname']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
 
-    do {
-        $options['shortname'] = cli_input($prompt, $options['shortname']);
-    } while (empty($options['shortname']));
-} else {
-    if (empty($options['shortname'])) {
-        $a = (object)array('option'=>'shortname', 'value'=>$options['shortname']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+    // Ask for admin user name.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminusername', 'install'));
+        if (!empty($options['adminuser'])) {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['adminuser']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        do {
+            $options['adminuser'] = cli_input($prompt, $options['adminuser']);
+        } while (empty($options['adminuser']) or $options['adminuser'] === 'guest');
+    } else {
+        if ((empty($options['adminuser']) || $options['adminuser'] === 'guest')) {
+            $a = (object)['option' => 'adminuser', 'value' => $options['adminuser']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
-}
 
-// ask for admin user name
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminusername', 'install'));
-    if (!empty($options['adminuser'])) {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['adminuser']);
-    } else {
+    // Ask for admin user password.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminpassword', 'install'));
         $prompt = get_string('clitypevalue', 'admin');
+        do {
+            $options['adminpass'] = cli_input($prompt);
+        } while (empty($options['adminpass']) or $options['adminpass'] === 'admin');
+    } else {
+        if ((empty($options['adminpass']) or $options['adminpass'] === 'admin')) {
+            $a = (object)['option' => 'adminpass', 'value' => $options['adminpass']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
-    do {
-        $options['adminuser'] = cli_input($prompt, $options['adminuser']);
-    } while (empty($options['adminuser']) or $options['adminuser'] === 'guest');
-} else {
-    if (empty($options['adminuser']) or $options['adminuser'] === 'guest') {
-        $a = (object)array('option'=>'adminuser', 'value'=>$options['adminuser']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+
+    // Ask for the admin email address.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminemail', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['adminemail']);
+        $options['adminemail'] = cli_input($prompt, $options['adminemail']);
     }
-}
 
-// ask for admin user password
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminpassword', 'install'));
-    $prompt = get_string('clitypevalue', 'admin');
-    do {
-        $options['adminpass'] = cli_input($prompt);
-    } while (empty($options['adminpass']) or $options['adminpass'] === 'admin');
-} else {
-    if (empty($options['adminpass']) or $options['adminpass'] === 'admin') {
-        $a = (object)array('option'=>'adminpass', 'value'=>$options['adminpass']);
+    // Validate that the address provided was an e-mail address.
+    if (!empty($options['adminemail']) && !validate_email($options['adminemail'])) {
+        $a = (object)['option' => 'adminemail', 'value' => $options['adminemail']];
         cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
     }
 }
 
-// Ask for the admin email address.
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminemail', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['adminemail']);
-    $options['adminemail'] = cli_input($prompt, $options['adminemail']);
-}
-
-// Validate that the address provided was an e-mail address.
-if (!empty($options['adminemail']) && !validate_email($options['adminemail'])) {
-    $a = (object) array('option' => 'adminemail', 'value' => $options['adminemail']);
-    cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
-}
-
 // Ask for the upgrade key.
 if ($interactive) {
     cli_separator();
@@ -745,22 +750,26 @@ if ($options['upgradekey'] !== '') {
     $CFG->upgradekey = $options['upgradekey'];
 }
 
-if ($interactive) {
-    if (!$options['agree-license']) {
-        cli_separator();
-        cli_heading(get_string('copyrightnotice'));
-        echo "Moodle  - Modular Object-Oriented Dynamic Learning Environment\n";
-        echo get_string('gpl3')."\n\n";
-        echo get_string('doyouagree')."\n";
-        $prompt = get_string('cliyesnoprompt', 'admin');
-        $input = cli_input($prompt, '', array(get_string('clianswerno', 'admin'), get_string('cliansweryes', 'admin')));
-        if ($input == get_string('clianswerno', 'admin')) {
-            exit(1);
+// The user does not also need to pass agree-license when --skip-database is provided as the user will need to accept
+// the license again in the database install part.
+if (!$skipdatabase) {
+    if ($interactive) {
+        if (!$options['agree-license']) {
+            cli_separator();
+            cli_heading(get_string('copyrightnotice'));
+            echo "Moodle  - Modular Object-Oriented Dynamic Learning Environment\n";
+            echo get_string('gpl3')."\n\n";
+            echo get_string('doyouagree')."\n";
+            $prompt = get_string('cliyesnoprompt', 'admin');
+            $input = cli_input($prompt, '', array(get_string('clianswerno', 'admin'), get_string('cliansweryes', 'admin')));
+            if ($input == get_string('clianswerno', 'admin')) {
+                exit(1);
+            }
+        }
+    } else {
+        if (!$options['agree-license'] && !$skipdatabase) {
+            cli_error(get_string('climustagreelicense', 'install'));
         }
-    }
-} else {
-    if (!$options['agree-license']) {
-        cli_error(get_string('climustagreelicense', 'install'));
     }
 }
 
@@ -809,7 +818,7 @@ if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
     cli_error(get_string('pluginschecktodo', 'admin'));
 }
 
-if (!$options['skip-database']) {
+if (!$skipdatabase) {
     install_cli_database($options, $interactive);
     // This needs to happen at the end to ensure it occurs after all caches
     // have been purged for the last time.
index 6a94fad..02edc7c 100644 (file)
@@ -26,10 +26,11 @@ namespace tool_dataprivacy;
 
 defined('MOODLE_INTERNAL') || die();
 
+use lang_string;
 use core\persistent;
 
 /**
- * Class for loading/storing competencies from the DB.
+ * Class for loading/storing data requests from the DB.
  *
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -62,6 +63,7 @@ class data_request extends persistent {
             ],
             'comments' => [
                 'type' => PARAM_TEXT,
+                'message' => new lang_string('errorinvalidrequestcomments', 'tool_dataprivacy'),
                 'default' => ''
             ],
             'commentsformat' => [
@@ -75,7 +77,10 @@ class data_request extends persistent {
                 'default' => FORMAT_PLAIN
             ],
             'userid' => [
-                'default' => 0,
+                'default' => function() {
+                    global $USER;
+                    return $USER->id;
+                },
                 'type' => PARAM_INT
             ],
             'requestedby' => [
index c29f197..a81df11 100644 (file)
@@ -67,8 +67,8 @@ if (!$manage && !\tool_dataprivacy\api::can_contact_dpo()) {
     redirect($returnurl, get_string('contactdpoviaprivacypolicy', 'tool_dataprivacy'), 0, \core\output\notification::NOTIFY_ERROR);
 }
 
-$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
-$mform->set_data(['type' => $requesttype]);
+$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage),
+    'persistent' => new \tool_dataprivacy\data_request(0, (object) ['type' => $requesttype])]);
 
 // Data request cancelled.
 if ($mform->is_cancelled()) {
index c91213c..c708a88 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 use tool_dataprivacy\api;
+use tool_dataprivacy\data_request;
 use tool_dataprivacy\local\helper;
 
 defined('MOODLE_INTERNAL') || die();
@@ -36,7 +37,10 @@ require_once($CFG->libdir.'/formslib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  * @package tool_dataprivacy
  */
-class tool_dataprivacy_data_request_form extends moodleform {
+class tool_dataprivacy_data_request_form extends \core\form\persistent {
+
+    /** @var string Name of the persistent class. */
+    protected static $persistentclass = data_request::class;
 
     /** @var bool Flag to indicate whether this form is being rendered for managing data requests or for regular requests. */
     protected $manage = false;
@@ -96,14 +100,13 @@ class tool_dataprivacy_data_request_form extends moodleform {
             api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexport', 'tool_dataprivacy'),
             api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedelete', 'tool_dataprivacy')
         ];
+
         $mform->addElement('select', 'type', get_string('requesttype', 'tool_dataprivacy'), $options);
-        $mform->setType('type', PARAM_INT);
         $mform->addHelpButton('type', 'requesttype', 'tool_dataprivacy');
 
         // Request comments text area.
         $textareaoptions = ['cols' => 60, 'rows' => 10];
         $mform->addElement('textarea', 'comments', get_string('requestcomments', 'tool_dataprivacy'), $textareaoptions);
-        $mform->setType('type', PARAM_ALPHANUM);
         $mform->addHelpButton('comments', 'requestcomments', 'tool_dataprivacy');
 
         // Action buttons.
@@ -129,34 +132,49 @@ class tool_dataprivacy_data_request_form extends moodleform {
         }
     }
 
+    /**
+     * Get the default data. Unset the default userid if managing data requests
+     *
+     * @return stdClass
+     */
+    protected function get_default_data() {
+        $data = parent::get_default_data();
+        if ($this->manage) {
+            unset($data->userid);
+        }
+
+        return $data;
+    }
+
     /**
      * Form validation.
      *
-     * @param array $data
+     * @param stdClass $data
      * @param array $files
+     * @param array $errors
      * @return array
      * @throws coding_exception
      * @throws dml_exception
      */
-    public function validation($data, $files) {
+    public function extra_validation($data, $files, array &$errors) {
         global $USER;
-        $errors = [];
 
         $validrequesttypes = [
             api::DATAREQUEST_TYPE_EXPORT,
             api::DATAREQUEST_TYPE_DELETE
         ];
-        if (!in_array($data['type'], $validrequesttypes)) {
+        if (!in_array($data->type, $validrequesttypes)) {
             $errors['type'] = get_string('errorinvalidrequesttype', 'tool_dataprivacy');
         }
 
-        if (api::has_ongoing_request($data['userid'], $data['type'])) {
+        $userid = $data->userid;
+
+        if (api::has_ongoing_request($userid, $data->type)) {
             $errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
         }
 
         // Check if current user can create data deletion request.
-        $userid = $data['userid'];
-        if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+        if ($data->type == api::DATAREQUEST_TYPE_DELETE) {
             if ($userid == $USER->id) {
                 if (!api::can_create_data_deletion_request_for_self()) {
                     $errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
index b5f3e60..63d33fe 100644 (file)
@@ -135,6 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
+$string['errorinvalidrequestcomments'] = 'Please ensure your comment contains plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
index c37ab36..ea21a12 100644 (file)
@@ -93,6 +93,17 @@ class check_defaults extends XMLDBCheckAction {
                     $physicaldefault = null;
                 }
 
+                // For number fields there are issues with type differences, so let's convert
+                // everything to a float.
+                if ($xmldbfield->getType() === XMLDB_TYPE_NUMBER) {
+                    if ($physicaldefault !== null) {
+                        $physicaldefault = (float) $physicaldefault;
+                    }
+                    if ($xmldbdefault !== null) {
+                        $xmldbdefault = (float) $xmldbdefault;
+                    }
+                }
+
                 // There *is* a default and it's wrong.
                 if ($physicaldefault !== $xmldbdefault) {
                     $xmldbtext = self::display_default($xmldbdefault);
index 131c782..faea917 100644 (file)
@@ -624,9 +624,25 @@ class manager {
                         LEFT JOIN {context} ctx ON ap.contextid = ctx.id
                             WHERE ctx.id IS NULL)");
 
-        $contextsql = "SELECT id FROM {context} ctx";
-        $DB->delete_records_select('analytics_predictions', "contextid NOT IN ($contextsql)");
-        $DB->delete_records_select('analytics_indicator_calc', "contextid NOT IN ($contextsql)");
+        // Cleanup analaytics predictions/calcs with MySQL friendly sub-select.
+        $DB->execute("DELETE FROM {analytics_predictions} WHERE id IN (
+                        SELECT oldpredictions.id
+                        FROM (
+                            SELECT p.id
+                            FROM {analytics_predictions} p
+                            LEFT JOIN {context} ctx ON p.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldpredictions
+                    )");
+
+        $DB->execute("DELETE FROM {analytics_indicator_calc} WHERE id IN (
+                        SELECT oldcalcs.id FROM (
+                            SELECT c.id
+                            FROM {analytics_indicator_calc} c
+                            LEFT JOIN {context} ctx ON c.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldcalcs
+                    )");
 
         // Clean up stuff that depends on analysable ids that do not exist anymore.
 
index b3fe2fb..83924f7 100644 (file)
@@ -49,6 +49,6 @@ class behat_auth_email extends behat_base {
         $confirmationpath = $confirmationurl->out_as_local_url(false);
         $url = $confirmationpath .  '?' . 'data='. $secret .'/'. $username;
 
-        $this->getSession()->visit($this->locate_path($url));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index e7b65e5..6233454 100644 (file)
@@ -58,7 +58,7 @@ class behat_auth extends behat_base {
         }
 
         // Visit login page.
-        $this->getSession()->visit($this->locate_path($loginurl->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$loginurl]);
 
         // Enter username and password.
         $this->execute('behat_forms::i_set_the_field_to', array('Username', $this->escape($username)));
index a5ead37..fe5f575 100644 (file)
@@ -56,7 +56,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
         $this->execute("behat_general::wait_until_the_page_is_ready");
 
         // Click the course link.
@@ -98,7 +98,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
 
         // Click the course link.
         $this->execute("behat_general::click_link", $backupcourse);
@@ -134,7 +134,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
         $this->execute("behat_general::wait_until_the_page_is_ready");
 
         // Click the course link.
index 1c6cb4c..e089afc 100644 (file)
@@ -121,7 +121,7 @@ class behat_calendar extends behat_base {
      */
     public function i_view_the_calendar_for($month, $year) {
         $time = make_timestamp($year, $month, 1);
-        $this->getSession()->visit($this->locate_path('/calendar/view.php?view=month&course=1&time='.$time));
+        $this->execute('behat_general::i_visit', ['/calendar/view.php?view=month&course=1&time='.$time]);
 
     }
 
@@ -134,6 +134,6 @@ class behat_calendar extends behat_base {
      */
     public function i_am_viewing_site_calendar() {
         $url = new moodle_url('/calendar/view.php', ['view' => 'month']);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index b49f3dc..b8eeb83 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index a409f76..7740906 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index 5de0576..041a04f 100644 (file)
@@ -133,7 +133,18 @@ const registerListenerEvents = (courseId, chooserConfig) => {
                 const sectionModal = buildModal(bodyPromise, footerData);
 
                 // Now we have a modal we should start fetching data.
-                const data = await fetchModuleData();
+                // If an error occurs while fetching the data, display the error within the modal.
+                const data = await fetchModuleData().catch(async(e) => {
+                    const errorTemplateData = {
+                        'errormessage': e.message
+                    };
+                    bodyPromiseResolver(await Templates.render('core_course/local/activitychooser/error', errorTemplateData));
+                });
+
+                // Early return if there is no module data.
+                if (!data) {
+                    return;
+                }
 
                 // Apply the section id to all the module instance links.
                 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid, caller.dataset.sectionreturnid);
index b1dfddd..20209ae 100644 (file)
@@ -105,6 +105,11 @@ class content_item_service {
             return $favmods;
         }
 
+        // Make sure the guest user exists in the database.
+        if (!\core_user::get_user($CFG->siteguest)) {
+            throw new \coding_exception('The guest user does not exist in the database.');
+        }
+
         $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, \context_user::instance($CFG->siteguest));
 
         $recommendationcache->set($CFG->siteguest, $favourites);
diff --git a/course/templates/local/activitychooser/error.mustache b/course/templates/local/activitychooser/error.mustache
new file mode 100644 (file)
index 0000000..98ac463
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/local/activitychooser/error
+
+    Chooser error template.
+
+    Variables required for this template:
+    * errormessage - The error message
+
+    Example context (json):
+    {
+        "errormessage": "Error"
+    }
+}}
+<div class="p-2 px-sm-5 py-sm-4">
+    <div class="alert alert-danger" role="alert">
+        <h5 class="alert-heading">
+            <i class="fa fa-exclamation-circle fa-fw text-danger"></i>
+            {{#str}} error, error {{/str}}
+        </h5>
+        <hr>
+        <p class="text-break">{{{errormessage}}}</p>
+    </div>
+</div>
index 384918c..03d5656 100644 (file)
@@ -96,7 +96,7 @@ class data_controller extends \core_customfield\data_controller {
             $this->save();
         }
 
-        if ($fromform['text']) {
+        if (array_key_exists('text', $fromform)) {
             $textoptions = $this->value_editor_options();
             $data = (object) ['field_editor' => $fromform];
             $data = file_postupdate_standard_editor($data, 'field', $textoptions, $textoptions['context'],
index ee846ef..a5cc62d 100644 (file)
@@ -145,6 +145,41 @@ class customfield_textarea_plugin_testcase extends advanced_testcase {
         $handler->instance_form_save($data);
     }
 
+    /**
+     * Test that instance form save empties the field content for blank values
+     */
+    public function test_instance_form_save_clear(): void {
+        global $CFG;
+
+        require_once("{$CFG->dirroot}/customfield/tests/fixtures/test_instance_form.php");
+
+        $this->setAdminUser();
+
+        $handler = $this->cfcat->get_handler();
+
+        // Set our custom field to a known value.
+        $submitdata = (array) $this->courses[1] + [
+            'customfield_myfield1_editor' => ['text' => 'I can see it in your eyes', 'format' => FORMAT_HTML],
+            'customfield_myfield2_editor' => ['text' => 'I can see it in your smile', 'format' => FORMAT_HTML],
+        ];
+
+        core_customfield_test_instance_form::mock_submit($submitdata, []);
+        $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]);
+        $handler->instance_form_save($form->get_data());
+
+        $this->assertEquals($submitdata['customfield_myfield1_editor']['text'],
+            core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value());
+
+        // Now empty our non-required field.
+        $submitdata['customfield_myfield1_editor']['text'] = '';
+
+        core_customfield_test_instance_form::mock_submit($submitdata, []);
+        $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]);
+        $handler->instance_form_save($form->get_data());
+
+        $this->assertEmpty(core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value());
+    }
+
     /**
      * Test for data_controller::get_value and export_value
      */
index c1d5067..df8876a 100644 (file)
@@ -399,10 +399,7 @@ class gradeimport_csv_load_data {
             case 'useridnumber':
             case 'useremail':
             case 'username':
-                // Skip invalid row with blank user field.
-                if (!empty($value)) {
-                    $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
-                }
+                $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
             break;
             case 'new':
                 $this->import_new_grade_item($header, $key, $value);
diff --git a/install/lang/bar/admin.php b/install/lang/bar/admin.php
new file mode 100644 (file)
index 0000000..645fbf6
--- /dev/null
@@ -0,0 +1,36 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['clianswerno'] = 'n';
+$string['cliansweryes'] = 'j';
+$string['cliincorrectvalueretry'] = 'Falscha Wert, probiers nummal';
+$string['cliyesnoprompt'] = 'druck j (bedeit ja) oda n (bedeit na)';
diff --git a/install/lang/bar/langconfig.php b/install/lang/bar/langconfig.php
new file mode 100644 (file)
index 0000000..02f2239
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'de';
+$string['thislanguage'] = 'Bairisch';
index 2c16045..a892ac1 100644 (file)
@@ -33,20 +33,20 @@ defined('MOODLE_INTERNAL') || die();
 $string['cannotcreatedboninstall'] = '<p>Non é posíbel crear a base de datos.</p>
 <p>A base de datos especificada non existe e o usuario indicado non ten permiso para crear a base de datos.</p>
 <p>O administrador do sitio debería verificar a configuración da base de datos.</p>';
-$string['cannotcreatelangdir'] = 'Non se pode crear o directorio de idioma';
-$string['cannotcreatetempdir'] = 'Non se pode crear un directorio temporal';
-$string['cannotdownloadcomponents'] = 'Non foi posíbel descargar compoñentes';
-$string['cannotdownloadzipfile'] = 'Non foi posíbel descargar o ficheiro ZIP';
-$string['cannotfindcomponent'] = 'Non foi posíbel atopar o compoñente';
+$string['cannotcreatelangdir'] = 'Non é posíbel crear o directorio de idioma';
+$string['cannotcreatetempdir'] = 'Non é posíbel crear un directorio temporal';
+$string['cannotdownloadcomponents'] = 'Non é posíbel descargar compoñentes';
+$string['cannotdownloadzipfile'] = 'Non é posíbel descargar o arquivo ZIP';
+$string['cannotfindcomponent'] = 'Non é posíbel atopar o compoñente';
 $string['cannotsavemd5file'] = 'Non é posíbel gardar o ficheiro md5';
 $string['cannotsavezipfile'] = 'Non é posíbel gardar o arquivo ZIP';
 $string['cannotunzipfile'] = 'Non é posíbel descomprimir o ficheiro';
 $string['componentisuptodate'] = 'O compoñente está actualizado';
 $string['dmlexceptiononinstall'] = '<p>Produciuse un erro na base de datos [{$a->errorcode}].<br />{$a->debuginfo}</p>';
 $string['downloadedfilecheckfailed'] = 'A comprobación do ficheiro descargado non foi satisfactoria';
-$string['invalidmd5'] = 'md5 non válido';
+$string['invalidmd5'] = 'A variábel de verificación non é correcta, ténteo de novo';
 $string['missingrequiredfield'] = 'Falta algún campo obrigatorio';
-$string['remotedownloaderror'] = '<p>Fallo a descarga do compoñente cara o seu servidor. Recomendase encarecidamente que verifiqoe os axustes do proxy, extensión PHP cURL.</p>
+$string['remotedownloaderror'] = '<p>Fallo a descarga do compoñente cara ao seu servidor. Recomendase encarecidamente que verifiqoe os axustes do proxy, extensión PHP cURL.</p>
 <p>Debe descargar o ficheiro <a href="{$a->url}">{$a->url}</a> manualmente, copialo en «{$a->dest}» no seu servidor e descomprimilo alí.</p>';
 $string['wrongdestpath'] = 'Camiño de destino errado.';
 $string['wrongsourcebase'] = 'URL da fonte errado.';
index 117e7b6..aa7efee 100644 (file)
@@ -33,7 +33,7 @@ defined('MOODLE_INTERNAL') || die();
 $string['admindirname'] = 'Directorio Admin';
 $string['availablelangs'] = 'Lista de idiomas dispoñíbeis';
 $string['chooselanguagehead'] = 'Escolla un idioma';
-$string['chooselanguagesub'] = 'Escolla un idioma para o proceso de instalación. Este idioma empregarase tamén como idioma predeterminado do sitio, se ben pode cambiarse máis adiante.';
+$string['chooselanguagesub'] = 'Escolla un idioma para o proceso de instalación. Este idioma empregarase tamén como idioma predeterminado do sitio, malia que pode cambiarse máis adiante.';
 $string['clialreadyconfigured'] = 'Xa existe o ficheiro config.php. Empregue admin/cli/install_database.php se quere actualizar o seu sitio web.';
 $string['clialreadyinstalled'] = 'Xa existe o ficheiro config.php. Empregue admin/cli/upgrade.php se quere actualizar o seu sitio web.';
 $string['cliinstallheader'] = 'Programa de instalación de Moodle en liña de ordes {$a}';
@@ -44,10 +44,10 @@ $string['dataroot'] = 'Directorio de datos';
 $string['datarootpermission'] = 'Permisos dos directorios de datos';
 $string['dbprefix'] = 'Prefixo das táboas';
 $string['dirroot'] = 'Directorio de Moodle';
-$string['environmenthead'] = 'Comprobando o seu entorno ...';
+$string['environmenthead'] = 'Comprobando o seu contorno...';
 $string['environmentsub2'] = 'Cada versión de Moodle ten algún requisito mínimo da versión de PHP e un número obrigatorio de extensións de PHP.
-Antes de cada instalación ou actualización faise unha comprobación completa do entorno . Póñase en contacto co administrador do servidor se non sabe como instalar a nova versión ou activar as extensións PHP.';
-$string['errorsinenvironment'] = 'A comprobación do entorno no foi satisfactoria!';
+Antes de cada instalación ou actualización faise unha comprobación completa do contorno. Póñase en contacto co administrador do servidor se non sabe como instalar a nova versión ou activar as extensións PHP.';
+$string['errorsinenvironment'] = 'A comprobación do contorno no foi satisfactoria!';
 $string['installation'] = 'Instalación';
 $string['langdownloaderror'] = 'Non foi posíbel descargar o idioma «{$a}». O proceso de instalación continuará en inglés.';
 $string['memorylimithelp'] = '<p>O límite de memoria PHP no seu servidor está estabelecido en {$a}.</p>
@@ -70,39 +70,36 @@ $string['memorylimithelp'] = '<p>O límite de memoria PHP no seu servidor está
     (poderá ver os erros cando se miran as páxinas) de modo que terá que eliminar o ficheiro .htaccess.</p></li>
 </ol>';
 $string['paths'] = 'Rutas';
-$string['pathserrcreatedataroot'] = 'O directorio de datos ({$a->dataroot}) non puido ser creado polo instalador.';
+$string['pathserrcreatedataroot'] = 'O instalador non pode crear o directorio de datos ({$a->dataroot}).';
 $string['pathshead'] = 'Confirme as rutas';
 $string['pathsrodataroot'] = 'O directorio dataroot non ten permisos de escritura.';
 $string['pathsroparentdataroot'] = 'O directorio principal ({$a->parent}) non ten permisos de escritura. O instalador non pode crear o directorio de datos ({$a->dataroot}).';
 $string['pathssubadmindir'] = 'Moi poucos enderezos web empregan /admin como URL especial para
 permitirlle acceder a un panel de control ou semellante. Desafortunadamente, isto entra en conflito coa localización estándar das páxinas de administración de Moodle. Vostede pode corrixir isto
 renomeando o directorio admin na súa instalación, e poñendo aquí ese novo nome.  Por exemplo: <em>moodleadmin</em>. Iso corrixirá as ligazóns admin en Moodle.';
-$string['pathssubdataroot'] = 'Necesitase un lugar no que Moodle poida gardar os ficheiros enviados. Este directorio debe ser lexíbel E ESCRIBÍBEL polo usuario do servidor web
-(normalmente «nobody», «apache», «www-data»), mais non debería ser accesíbel directamente desde o web. Se non existe o instalador tentará crealo.';
+$string['pathssubdataroot'] = '<p>Un directorio onde Moodle almacenará todo o contido de ficheiros enviados polos usuarios.</p>
+<p>Este directorio debería ser lexíbel e escribíbel polo usuario do servidor web (normalmente «www-data«» «ninguén» ou «apache»).</p>
+<p> Non debe ser accesíbel directamente na web. </p>
+<p> Se o directorio non existe actualmente, o proceso de instalación tentará crealo. </p>';
 $string['pathssubdirroot'] = 'Ruta completa do directorio de instalación de Moodle.';
-$string['pathssubwwwroot'] = 'Enderezo web completo para acceder a Moodle.
-Non é posíbel acceder a Moodle empregando enderezos múltiples.
-Se o seu sitio ten varios enderezos públicos debe configurar encamiñamentos permanentes en todos eles, agás neste.
-Se o seu sitio web é accesíbel tanto desde unha Intranet como desde Internet, escriba aquí o enderezo público e configure o DNS para que os usuarios da Intranet poidan empregar tamén o enderezo público.
-Se o enderezo non é correcto, cambie o URL no seu navegador para reiniciar a instalación cun valor diferente.';
-$string['pathsunsecuredataroot'] = 'A localización de dataroot non é segura';
+$string['pathssubwwwroot'] = '<p>O enderezo completo onde se accederá a Moodle, é dicir, o enderezo que os usuarios introducirán na barra de enderezos do seu navegador para acceder a Moodle.</p>
+<p>Non é posíbel acceder a Moodle con varias direccións. Se o seu sitio é accesíbel a través de varios enderezos, escolla o máis sinxelo e configure unha redirección permanente para cada un dos outros enderezos.</p>
+<p Se o seu sitio é accesíbel tanto dende a Internet como dende unha rede interna (ás veces chamada Intranet), entón use o enderezo público aquí.</p>
+<p>Se o enderezo actual non é correcto, cambie o URL na barra de enderezos do seu navegador e reinicie a instalación.</p>';
+$string['pathsunsecuredataroot'] = 'A localización de «dataroot» non é segura';
 $string['pathswrongadmindir'] = 'Non existe o directorio Admin';
 $string['phpextension'] = 'Extensión PHP {$a}';
 $string['phpversion'] = 'Versión PHP';
-$string['phpversionhelp'] = '<p>Moodle require polo menos unha das versións de PHP 4.3.0 ou 5.1.0 ( as versións 5.0.x teñen unha serie de problemas coñecidos).</p>
+$string['phpversionhelp'] = '<p>Moodle require polo menos unha das versións de PHP 5.6.5 ou 7.1 (7.0.x ten algunhas limitacións de motor).</p>
 <p>Neste momento está executandose a versión {$a}</p>
-<p>Debe actualizar PHP ou trasladarse a outro servidor cunha versión máis recente de PHP!<br />
-(NO caso de 5.0.x podería tamén reverter cara a versión 4.4.x)</p>';
+<p>Debe actualizar PHP ou trasladarse a outro servidor cunha versión máis recente de PHP.</p>';
 $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'Se esta a ver esta páxina é porque puido instalar satisfactoriamente e
     executar o paquete <strong>{$a->packname} {$a->packversion}</strong> no seu computador. Parabéns!';
-$string['welcomep30'] = 'Esta versión de <strong>{$a->installername}</strong> inclúe os aplicativos
-    para crear un entorno no que <strong>Moodle</strong> funcione, nomeadamente:';
+$string['welcomep30'] = 'Esta versión de <strong>{$a->installername}</strong> inclúe as aplicacións
+    para crear un contorno no que <strong>Moodle</strong> funcione, nomeadamente:';
 $string['welcomep40'] = 'O paquete inclúe tamen <strong>Moodle {$a->moodlerelease} ({$a->moodleversion})</strong>.';
-$string['welcomep50'] = 'O uso de todos os aplicativos do paquete está rexido polas súas respectivas
-    licenzas. O paquete completo <strong>{$a->installername}</strong> é
-    <a href="http://www.opensource.org/docs/definition_plain.html">código aberto</a> e distribúese
-    baixo a licenza <a href="http://www.gnu.org/copyleft/gpl.html">GPL</a>.';
+$string['welcomep50'] = 'O uso de todas as aplicacións deste paquete está rexido polas súas respectivas licenzas. O paquete completo <strong>{$a->installername}</strong> é de <a href="https://www.opensource.org/docs/definition_plain.html">código aberto</a> e distribúese baixo a licenza <a href="https://www.gnu.org/copyleft/gpl.html">GPL</a>.';
 $string['welcomep60'] = 'As páxinas seguintes guiarano a través de algúns sinxelos pasos para configurar
     e axustar <strong>Moodle</strong> no seu computador. Pode empregar os axustes predeterminados
     ou, opcionalmente, modificalos para que se axusten ás súas necesidades.';
index 38af08e..0cc122b 100644 (file)
@@ -31,5 +31,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = 'भाषा';
+$string['moodlelogo'] = 'Moodle लोगो';
 $string['next'] = 'अगला';
 $string['reload'] = 'सीमा से अधिक लादना';
diff --git a/install/lang/hi_kids/langconfig.php b/install/lang/hi_kids/langconfig.php
new file mode 100644 (file)
index 0000000..0c9caf6
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'hi';
+$string['thislanguage'] = 'बच्चों के लिए हिंदी';
index 114fcfe..c392295 100644 (file)
@@ -41,7 +41,7 @@ $string['cannotunzipfile'] = 'មិនអាចពន្លាឯកសារ
 $string['componentisuptodate'] = 'សមាសភាគគឺទាន់សម័យ ។';
 $string['downloadedfilecheckfailed'] = 'បានបរាជ័យក្នុងការពិនិត្យឯកសារដែលបានទាញយក ។';
 $string['invalidmd5'] = 'md5 មិនត្រឹមត្រូវ';
-$string['missingrequiredfield'] = 'á\9e\94á\9e¶á\9e\8fá\9f\8bá\9e\9cá\9e¶á\9e\9bá\9e\8aá\9f\82á\9e\9bá\9e\8fá\9f\92á\9e\9aá\9e¼á\9e\9cá\9e\80ារមួយចំនួន';
+$string['missingrequiredfield'] = 'á\9e\94á\9e¶á\9e\8fá\9f\8bá\9e\94á\9f\92á\9e\9aá\9e¢á\9e\94á\9f\8bá\9e\91á\9e·á\9e\93á\9f\92á\9e\93á\9e\93á\9f\90á\9e\99á\9e\8aá\9f\82á\9e\9bá\9e\8fá\9f\92á\9e\9aá\9e¼á\9e\9cá\9e\91á\9e¶á\9e\98á\9e\91ារមួយចំនួន';
 $string['remotedownloaderror'] = 'បរាជ័យក្នុងការទាញយកសមាសភាគទៅម៉ាស៊ីនបម្រើរបស់អ្នក សូមផ្ទៀងផ្ទាត់ប្រូកស៊ី ផ្នែកបន្ថែម PHP cURL ត្រូវបានផ្ដល់អនុសាសន៍ ។<br /><br />អ្នកត្រូវតែទាញយកឯកសារ <a href="{$a->url}">{$a->url}</a> ដោយដៃ ចម្លងវាទៅ "{$a->dest}" ក្នុងម៉ាស៊ីនបម្រើរបស់អ្នក និងពន្លាវានៅទីនោះ ។';
 $string['wrongdestpath'] = 'ផ្លូវទិសដៅមិនត្រឹមត្រូវ ។';
 $string['wrongsourcebase'] = 'មូលដ្ឋាន URL ប្រភពមិនត្រឹមត្រូវ ។';
index fd87ec4..9b7445d 100644 (file)
@@ -1201,7 +1201,7 @@ class admin_externalpage implements part_of_admin_tree {
     /** @var string The external URL that we should link to when someone requests this external page. */
     public $url;
 
-    /** @var string The role capability/permission a user must have to access this external page. */
+    /** @var array The role capability/permission a user must have to access this external page. */
     public $req_capability;
 
     /** @var object The context in which capability/permission should be checked, default is site context. */
@@ -1425,7 +1425,7 @@ class admin_settingpage implements part_of_admin_tree {
     /** @var admin_settingdependency[] list of settings to hide when certain conditions are met */
     protected $dependencies = [];
 
-    /** @var string The role capability/permission a user must have to access this external page. */
+    /** @var array The role capability/permission a user must have to access this external page. */
     public $req_capability;
 
     /** @var object The context in which capability/permission should be checked, default is site context. */
index c97f492..b36272b 100644 (file)
@@ -482,12 +482,45 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     }
 
     /**
-     * Returns whether the scenario is running in a browser that can run Javascript or not.
+     * Whether Javascript is available in the current Session.
      *
      * @return boolean
      */
     protected function running_javascript() {
-        return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
+        return self::running_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Require that javascript be available in the current Session.
+     *
+     * @throws DriverException
+     */
+    protected function require_javascript() {
+        return self::require_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Whether Javascript is available in the specified Session.
+     *
+     * @param Session $session
+     * @return boolean
+     */
+    protected static function running_javascript_in_session(Session $session): bool {
+        return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
+    }
+
+    /**
+     * Require that javascript be available for the specified Session.
+     *
+     * @param Session $session
+     * @throws DriverException
+     */
+    protected static function require_javascript_in_session(Session $session): void {
+        if (self::running_javascript_in_session($session)) {
+            return;
+        }
+
+        throw new DriverException('Javascript is required');
     }
 
     /**
@@ -502,7 +535,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         }
 
         // Check on page to see if it's an app page. Safest way is to look for added JavaScript.
-        return $this->getSession()->evaluateScript('typeof window.behat') === 'object';
+        return $this->evaluate_script('return typeof window.behat') === 'object';
     }
 
     /**
@@ -738,14 +771,18 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
             // The window inner height will be as specified, which means the available viewport will
             // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
             // scrollbars are rare so this doesn't matter.
-            $offset = $this->getSession()->getDriver()->evaluateScript(
-                    'return (function() { var before = document.body.style.overflowY;' .
-                    'document.body.style.overflowY = "scroll";' .
-                    'var result = {};' .
-                    'result.x = window.outerWidth - document.body.offsetWidth;' .
-                    'result.y = window.outerHeight - window.innerHeight;' .
-                    'document.body.style.overflowY = before;' .
-                    'return result; })();');
+            $js = <<<EOF
+return (function() {
+    var before = document.body.style.overflowY;
+    document.body.style.overflowY = "scroll";
+    var result = {};
+    result.x = window.outerWidth - document.body.offsetWidth;
+    result.y = window.outerHeight - window.innerHeight;
+    document.body.style.overflowY = before;
+    return result;
+})();
+EOF;
+            $offset = $this->evaluate_script($js);
             $width += $offset['x'];
             $height += $offset['y'];
         }
@@ -794,8 +831,8 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                         } else {
                             return "incomplete"
                         }
-                    }());'));
-                $pending = $session->evaluateScript($jscode);
+                    })()'));
+                $pending = self::evaluate_script_in_session($session, $jscode);
             } catch (NoSuchWindow $nsw) {
                 // We catch an exception here, in case we just closed the window we were interacting with.
                 // No javascript is running if there is no window right?
@@ -1206,4 +1243,70 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     public static function get_named_replacements(): array {
         return [];
     }
+
+    /**
+     * Evaluate the supplied script in the current session, returning the result.
+     *
+     * @param string $script
+     * @return mixed
+     */
+    public function evaluate_script(string $script) {
+        return self::evaluate_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Evaluate the supplied script in the specified session, returning the result.
+     *
+     * @param Session $session
+     * @param string $script
+     * @return mixed
+     */
+    public static function evaluate_script_in_session(Session $session, string $script) {
+        self::require_javascript_in_session($session);
+
+        return $session->evaluateScript($script);
+    }
+
+    /**
+     * Execute the supplied script in the current session.
+     *
+     * No result will be returned.
+     *
+     * @param string $script
+     */
+    public function execute_script(string $script): void {
+        self::execute_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Excecute the supplied script in the specified session.
+     *
+     * No result will be returned.
+     *
+     * @param Session $session
+     * @param string $script
+     */
+    public static function execute_script_in_session(Session $session, string $script): void {
+        self::require_javascript_in_session($session);
+
+        $session->executeScript($script);
+    }
+
+    /**
+     * Get the session key for the current session via Javascript.
+     *
+     * @return string
+     */
+    public function get_sesskey(): string {
+        $script = <<<EOF
+return (function() {
+if (M && M.cfg && M.cfg.sesskey) {
+    return M.cfg.sesskey;
+}
+return '';
+})()
+EOF;
+
+        return $this->evaluate_script($script);
+    }
 }
index 8f3ff72..3d5df09 100644 (file)
@@ -52,14 +52,16 @@ class behat_form_editor extends behat_form_textarea {
         if ($this->running_javascript()) {
             $value = addslashes($value);
             $js = '
-var editor = Y.one(document.getElementById("'.$editorid.'editable"));
-if (editor) {
-    editor.setHTML("' . $value . '");
-}
-editor = Y.one(document.getElementById("'.$editorid.'"));
-editor.set("value", "' . $value . '");
+(function() {
+    var editor = Y.one(document.getElementById("'.$editorid.'editable"));
+    if (editor) {
+        editor.setHTML("' . $value . '");
+    }
+    editor = Y.one(document.getElementById("'.$editorid.'"));
+    editor.set("value", "' . $value . '");
+})();
 ';
-            $this->session->executeScript($js);
+            behat_base::execute_script_in_session($this->session, $js);
         } else {
             parent::set_value($value);
         }
@@ -88,7 +90,7 @@ editor.set("value", "' . $value . '");
     r.selectNodeContents(e);
     s.setSingleRange(r);
 }()); ';
-        $this->session->executeScript($js);
+        behat_base::execute_script_in_session($this->session, $js);
     }
 
     /**
index 43cf372..cfc48c2 100644 (file)
@@ -51,12 +51,14 @@ class behat_form_passwordunmask extends behat_form_text {
         if ($this->running_javascript()) {
             $id = $this->field->getAttribute('id');
             $js = <<<JS
-require(["jquery"], function($) {
-    var wrapper = $(document.getElementById("{$id}")).closest('[data-passwordunmask="wrapper"]');
-        wrapper.find('[data-passwordunmask="edit"]').trigger("click");
-});
+(function() {
+    require(["jquery"], function($) {
+        var wrapper = $(document.getElementById("{$id}")).closest('[data-passwordunmask="wrapper"]');
+            wrapper.find('[data-passwordunmask="edit"]').trigger("click");
+    });
+})();
 JS;
-            $this->session->executeScript($js);
+            behat_base::execute_script_in_session($this->session, $js);
         }
 
         $this->field->setValue($value);
index 42ae23d..be2f9ec 100644 (file)
  * Class containing utility methods for dataformats
  *
  * @package     core
- * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
- * @author      2020 Paul Holden <paulh@moodle.com>
+ * @copyright   2020 Paul Holden <paulh@moodle.com>
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
  */
 
 namespace core;
@@ -33,15 +31,13 @@ use core_php_time_limit;
  * Dataformat utility class
  *
  * @package     core
- * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
- * @author      2020 Paul Holden <paulh@moodle.com>
+ * @copyright   2020 Paul Holden <paulh@moodle.com>
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
  */
 class dataformat {
 
     /**
-     * Return an instead of a dataformat writer from given dataformat type
+     * Return an instance of a dataformat writer from given dataformat type
      *
      * @param string $dataformat
      * @return dataformat\base
index 1df218e..32298db 100644 (file)
@@ -2497,5 +2497,35 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020061501.01);
     }
 
+    if ($oldversion < 2020061501.04) {
+        // Restore and set the guest user if it has been previously removed via GDPR, or set to an nonexistent
+        // user account.
+        $currentguestuser = $DB->get_record('user', array('id' => $CFG->siteguest));
+
+        if (!$currentguestuser) {
+            if (!$guest = $DB->get_record('user', array('username' => 'guest', 'mnethostid' => $CFG->mnet_localhost_id))) {
+                // Create a guest user account.
+                $guest = new stdClass();
+                $guest->auth        = 'manual';
+                $guest->username    = 'guest';
+                $guest->password    = hash_internal_user_password('guest');
+                $guest->firstname   = get_string('guestuser');
+                $guest->lastname    = ' ';
+                $guest->email       = 'root@localhost';
+                $guest->description = get_string('guestuserinfo');
+                $guest->mnethostid  = $CFG->mnet_localhost_id;
+                $guest->confirmed   = 1;
+                $guest->lang        = $CFG->lang;
+                $guest->timemodified= time();
+                $guest->id = $DB->insert_record('user', $guest);
+            }
+            // Set the guest user.
+            set_config('siteguest', $guest->id);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020061501.04);
+    }
+
     return true;
 }
index 1a3e43e..81c39d1 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js differ
index fc5786d..7031821 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js differ
index 1a3e43e..81c39d1 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js differ
index e72ef44..aa3ce8a 100644 (file)
@@ -255,6 +255,12 @@ Y.namespace('M.atto_undo').Button = Y.Base.create('button', Y.M.editor_atto.Edit
      */
     _redoHandler: function(e) {
         e.preventDefault();
+
+        // Don't do anything if redo stack is empty.
+        if (this._redoStack.length === 0) {
+            return;
+        }
+
         var html = this._getHTML(),
             redo = this._getRedo();
 
index 11ddc73..457bab1 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 089870b..e2275f1 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index 899809b..a0246dd 100644 (file)
@@ -336,12 +336,13 @@ export const getLastInitial = tableRoot => getTableData(tableRoot).tableLastInit
  * @param {HTMLElement} tableRoot
  * @param {String} columnToHide
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
     const hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns.push(columnToHide);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
@@ -350,12 +351,13 @@ export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
  * @param {HTMLElement} tableRoot
  * @param {String} columnToShow
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
     let hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns = hiddenColumns.filter(columnName => columnName !== columnToShow);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
index 78bade9..63510a6 100644 (file)
@@ -1444,23 +1444,19 @@ class flexible_table {
             }
         }
 
-        // Now, update the column attributes for collapsed columns
-        foreach (array_keys($this->columns) as $column) {
-            if (!empty($this->prefs['collapse'][$column])) {
-                $this->column_style[$column]['width'] = '10px';
-            }
-        }
+        $this->set_hide_show_preferences();
+        $this->set_sorting_preferences();
+        $this->set_initials_preferences();
 
-        // Now, update the column attributes for collapsed columns
+        // Now, reduce the width of collapsed columns and remove the width from columns that should be expanded.
         foreach (array_keys($this->columns) as $column) {
             if (!empty($this->prefs['collapse'][$column])) {
                 $this->column_style[$column]['width'] = '10px';
+            } else {
+                unset($this->column_style[$column]['width']);
             }
         }
 
-        $this->set_sorting_preferences();
-        $this->set_initials_preferences();
-
         if (empty($this->baseurl)) {
             debugging('You should set baseurl when using flexible_table.');
             global $PAGE;
index 56a793c..be66847 100644 (file)
@@ -1,3 +1,54 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/paging_bar
+
+    This template renders the horizontal bar with page links, e.g.
+        | « | 1 | 2 | 3 | » |
+
+    Example context (json):
+    {
+        "previous": null,
+        "next": {
+            "page": 2,
+            "url": "./page.php?p=1"
+        },
+        "first": null,
+        "last": {
+            "page": 100,
+            "url": "./page.php?p=99"
+        },
+        "label": "Page",
+        "pages": [
+            {
+                "page": 1,
+                "active": true,
+                "url": null
+            },
+            {
+                "page": 2,
+                "active": false,
+                "url": "./page.php?p=1"
+            }
+        ],
+        "haspages": true,
+        "pagesize": 10
+    }
+}}
 {{#haspages}}
     <nav aria-label="{{label}}" class="pagination pagination-centered justify-content-center">
         <ul class="mt-1 pagination " data-page-size="{{pagesize}}">
@@ -14,7 +65,7 @@
                     <a href="{{url}}" class="page-link">{{page}}</a>
                 </li>
                 <li class="page-item disabled" data-page-number="{{page}}">
-                    <span class="page-link">&hellip;</a>
+                    <span class="page-link">&hellip;</span>
                 </li>
             {{/first}}
             {{#pages}}
@@ -29,7 +80,7 @@
             {{/pages}}
             {{#last}}
                 <li class="page-item disabled" data-page-number="{{page}}">
-                    <span class="page-link">&hellip;</a>
+                    <span class="page-link">&hellip;</span>
                 </li>
                 <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link">{{page}}</a>
index af82844..ce9f392 100644 (file)
@@ -283,7 +283,7 @@ class behat_app extends behat_base {
         global $CFG;
 
         // Visit the Ionic URL and wait for it to load.
-        $this->getSession()->visit($url);
+        $this->execute('behat_general::i_visit', [$url]);
         $this->spin(
                 function($context, $args) {
                     $title = $context->getSession()->getPage()->find('xpath', '//title');
@@ -297,8 +297,7 @@ class behat_app extends behat_base {
                 }, false, 60);
 
         // Run the scripts to install Moodle 'pending' checks.
-        $this->getSession()->executeScript(
-                file_get_contents(__DIR__ . '/app_behat_runtime.js'));
+        $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js'));
 
         // Wait until the site login field appears OR the main page.
         $situation = $this->spin(
@@ -373,8 +372,7 @@ class behat_app extends behat_base {
      */
     public function i_press_the_standard_button_in_the_app(string $button) {
         $this->spin(function($context, $args) use ($button) {
-            $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' .
-                    $button . '");');
+            $result = $this->evaluate_script("return window.behat.pressStandard('{$button}');");
             if ($result !== 'OK') {
                 throw new DriverException('Error pressing standard button - ' . $result);
             }
@@ -391,7 +389,7 @@ class behat_app extends behat_base {
      */
     public function i_close_the_popup_in_the_app() {
         $this->spin(function($context, $args)  {
-            $result = $this->getSession()->evaluateScript('return window.behat.closePopup();');
+            $result = $this->evaluate_script("return window.behat.closePopup();");
             if ($result !== 'OK') {
                 throw new DriverException('Error closing popup - ' . $result);
             }
@@ -449,7 +447,7 @@ class behat_app extends behat_base {
             } else {
                 $nearbit = '';
             }
-            $result = $context->getSession()->evaluateScript('return window.behat.press("' .
+            $result = $this->evaluate_script('return window.behat.press("' .
                     addslashes_js($text) . '"' . $nearbit .');');
             if ($result !== 'OK') {
                 throw new DriverException('Error pressing item - ' . $result);
@@ -472,7 +470,7 @@ class behat_app extends behat_base {
      */
     public function i_set_the_field_in_the_app(string $field, string $value) {
         $this->spin(function($context, $args) use ($field, $value) {
-            $result = $this->getSession()->evaluateScript('return window.behat.setField("' .
+            $result = $this->evaluate_script('return window.behat.setField("' .
                     addslashes_js($field) . '", "' . addslashes_js($value) . '");');
             if ($result !== 'OK') {
                 throw new DriverException('Error setting field - ' . $result);
@@ -494,7 +492,7 @@ class behat_app extends behat_base {
      */
     public function the_header_should_be_in_the_app(string $text) {
         $result = $this->spin(function($context, $args) {
-            $result = $this->getSession()->evaluateScript('return window.behat.getHeader();');
+            $result = $this->evaluate_script('return window.behat.getHeader();');
             if (substr($result, 0, 3) !== 'OK:') {
                 throw new DriverException('Error getting header - ' . $result);
             }
@@ -536,7 +534,7 @@ class behat_app extends behat_base {
         if (count($names) !== 2) {
             throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
         }
-        $this->getSession()->getDriver()->executeScript('window.close()');
+        $this->execute_script('window.close()');
         $this->getSession()->switchToWindow($names[0]);
     }
 
@@ -548,6 +546,6 @@ class behat_app extends behat_base {
      * @throws DriverException If the navigator.online mode is not available
      */
     public function i_switch_offline_mode(string $offline) {
-        $this->getSession()->evaluateScript('appProvider.setForceOffline(' . $offline . ');');
+        $this->execute_script('appProvider.setForceOffline(' . $offline . ');');
     }
 }
index d81686e..2532f94 100644 (file)
 require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
 require_once(__DIR__ . '/../../../lib/behat/behat_field_manager.php');
 
-use Behat\Gherkin\Node\TableNode as TableNode,
-    Behat\Gherkin\Node\PyStringNode as PyStringNode,
-    Behat\Mink\Exception\ExpectationException as ExpectationException,
-    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+use Behat\Gherkin\Node\{TableNode, PyStringNode};
+use Behat\Mink\Element\NodeElement;
+use Behat\Mink\Exception\{ElementNotFoundException, ExpectationException};
 
 /**
  * Forms-related steps definitions.
@@ -286,10 +285,7 @@ class behat_forms extends behat_base {
      * @return void
      */
     public function i_set_the_field_with_xpath_to($fieldxpath, $value) {
-        $fieldnode = $this->find('xpath', $fieldxpath);
-        $this->ensure_node_is_visible($fieldnode);
-        $field = behat_field_manager::get_form_field($fieldnode, $this->getSession());
-        $field->set_value($value);
+        $this->set_field_node_value($this->find('xpath', $fieldxpath), $value);
     }
 
     /**
@@ -627,13 +623,24 @@ class behat_forms extends behat_base {
      * @return void
      */
     protected function set_field_value($fieldlocator, $value) {
-
         // We delegate to behat_form_field class, it will
         // guess the type properly as it is a select tag.
         $field = behat_field_manager::get_form_field_from_label($fieldlocator, $this);
         $field->set_value($value);
     }
 
+    /**
+     * Generic field setter to be used by chainable steps.
+     *
+     * @param NodeElement $fieldnode
+     * @param string $value
+     */
+    public function set_field_node_value(NodeElement $fieldnode, string $value): void {
+        $this->ensure_node_is_visible($fieldnode);
+        $field = behat_field_manager::get_form_field($fieldnode, $this->getSession());
+        $field->set_value($value);
+    }
+
     /**
      * Generic field setter.
      *
@@ -646,12 +653,8 @@ class behat_forms extends behat_base {
      * @param string $containerelement Element we look in
      */
     protected function set_field_value_in_container($fieldlocator, $value, $containerselectortype, $containerelement) {
-
         $node = $this->get_node_in_container('field', $fieldlocator, $containerselectortype, $containerelement);
-        // We delegate to behat_form_field class, it will
-        // guess the type properly as it is a select tag.
-        $field = behat_field_manager::get_form_field($node, $this->getSession());
-        $field->set_value($value);
+        $this->set_field_node_value($node, $value);
     }
 
     /**
index ee6175a..49b2fdd 100644 (file)
@@ -74,7 +74,7 @@ class behat_general extends behat_base {
      * @Given /^I am on homepage$/
      */
     public function i_am_on_homepage() {
-        $this->getSession()->visit($this->locate_path('/'));
+        $this->execute('behat_general::i_visit', ['/']);
     }
 
     /**
@@ -83,7 +83,7 @@ class behat_general extends behat_base {
      * @Given /^I am on site homepage$/
      */
     public function i_am_on_site_homepage() {
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
     }
 
     /**
@@ -92,7 +92,7 @@ class behat_general extends behat_base {
      * @Given /^I am on course index$/
      */
     public function i_am_on_course_index() {
-        $this->getSession()->visit($this->locate_path('/course/index.php'));
+        $this->execute('behat_general::i_visit', ['/course/index.php']);
     }
 
     /**
@@ -228,8 +228,7 @@ class behat_general extends behat_base {
         // unnamed window (presumably the main window) to some other named
         // window, then we first set the main window name to a conventional
         // value that we can later use this name to switch back.
-        $this->getSession()->executeScript(
-                'if (window.name == "") window.name = "' . self::MAIN_WINDOW_NAME . '"');
+        $this->execute_script('if (window.name == "") window.name = "' . self::MAIN_WINDOW_NAME . '"');
 
         $this->getSession()->switchToWindow($windowname);
     }
@@ -258,7 +257,7 @@ class behat_general extends behat_base {
         $names = $this->getSession()->getWindowNames();
         for ($index = 1; $index < count($names); $index ++) {
             $this->getSession()->switchToWindow($names[$index]);
-            $this->getSession()->executeScript("window.open('', '_self').close();");
+            $this->execute_script("window.open('', '_self').close();");
         }
         $names = $this->getSession()->getWindowNames();
         if (count($names) !== 1) {
@@ -924,7 +923,7 @@ class behat_general extends behat_base {
     return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
 })()
 EOF;
-            $ok = $this->getSession()->getDriver()->evaluateScript($js);
+            $ok = $this->evaluate_script($js);
         } else {
 
             // Using following xpath axe to find it.
@@ -1058,7 +1057,7 @@ EOF;
      * @Given /^I trigger cron$/
      */
     public function i_trigger_cron() {
-        $this->getSession()->visit($this->locate_path('/admin/cron.php'));
+        $this->execute('behat_general::i_visit', ['/admin/cron.php']);
     }
 
     /**
@@ -1609,11 +1608,12 @@ EOF;
 
         $this->pageloaddetectionrunning = true;
 
-        $session->executeScript(
-                'var span = document.createElement("span");
-                span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
-                span.setAttribute("style", "display: none;");
-                document.body.appendChild(span);');
+        $this->execute_script(
+            'var span = document.createElement("span");
+            span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
+            span.setAttribute("style", "display: none;");
+            document.body.appendChild(span);'
+        );
     }
 
     /**
@@ -1811,7 +1811,7 @@ EOF;
         $xpath = addslashes_js($element->getXpath());
         $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
                 document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
-        $targetisfocused = $this->getSession()->evaluateScript($script);
+        $targetisfocused = $this->evaluate_script($script);
         if ($not == ' not') {
             if ($targetisfocused) {
                 throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
@@ -1843,7 +1843,7 @@ EOF;
         $xpath = addslashes_js($element->getXpath());
         $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
                 document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
-        $targetisfocused = $this->getSession()->evaluateScript($script);
+        $targetisfocused = $this->evaluate_script($script);
         if ($not == ' not') {
             if ($targetisfocused) {
                 throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
@@ -1937,4 +1937,16 @@ EOF;
         $value = [\WebDriver\Key::ENTER];
         $this->getSession()->getDriver()->getWebDriverSession()->activeElement()->postValue(['value' => $value]);
     }
+
+    /**
+     * Visit a local URL relative to the behat root.
+     *
+     * @When I visit :localurl
+     *
+     * @param string|moodle_url $localurl The URL relative to the behat_wwwroot to visit.
+     */
+    public function i_visit($localurl): void {
+        $localurl = new moodle_url($localurl);
+        $this->getSession()->visit($this->locate_path($localurl->out_as_local_url(false)));
+    }
 }
index 1ffcb14..1e8e1e8 100644 (file)
@@ -86,6 +86,17 @@ class behat_hooks extends behat_base {
      */
     protected static $currentstepexception = null;
 
+    /**
+     * If an Exception is thrown in the BeforeScenario hook it will cause the Scenario to be skipped, and the exit code
+     * to be non-zero triggering a potential rerun.
+     *
+     * To combat this the exception is stored and re-thrown when looking for exceptions.
+     * This allows the test to instead be failed and re-run correctly.
+     *
+     * @var null|Exception
+     */
+    protected static $currentscenarioexception = null;
+
     /**
      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
      *
@@ -173,7 +184,7 @@ class behat_hooks extends behat_base {
             $message = <<<EOF
 Your behat test site is outdated, please run the following command from your Moodle dirroot to drop, and reinstall the Behat test site.
 
-    {$comandpath}
+    {$commandpath}
 
 EOF;
             self::log_and_stop($message);
@@ -362,8 +373,13 @@ EOF;
             // The `before_first_scenario_start_session` function will have started the session instead.
             return;
         }
+        self::$currentscenarioexception = null;
 
-        $this->restart_session();
+        try {
+            $this->restart_session();
+        } catch (Exception $e) {
+            self::$currentscenarioexception = $e;
+        }
     }
 
     /**
@@ -374,6 +390,12 @@ EOF;
      */
     public function before_scenario_hook(BeforeScenarioScope $scope) {
         global $DB;
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         $suitename = $scope->getSuite()->getName();
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
@@ -474,7 +496,7 @@ EOF;
             // Again, this would be better in the BeforeSuite hook, but that does not have access to the selectors in
             // order to perform the necessary searches.
             $session = $this->getSession();
-            $session->visit($this->locate_path('/'));
+            $this->execute('behat_general::i_visit', ['/']);
 
             // Checking that the root path is a Moodle test site.
             if (self::is_first_scenario()) {
@@ -526,6 +548,12 @@ EOF;
      * @BeforeStep
      */
     public function before_step_javascript(BeforeStepScope $scope) {
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         self::$currentstepexception = null;
 
         // Only run if JS.
@@ -742,6 +770,11 @@ EOF;
      * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
      */
     public function i_look_for_exceptions() {
+        // If the scenario already failed in a hook throw the exception.
+        if (!is_null(self::$currentscenarioexception)) {
+            throw self::$currentscenarioexception;
+        }
+
         // If the step already failed in a hook throw the exception.
         if (!is_null(self::$currentstepexception)) {
             throw self::$currentstepexception;
index 052fe30..7acc898 100644 (file)
@@ -391,7 +391,7 @@ class behat_navigation extends behat_base {
         )";
 
         // Adding an extra click we need to show the 'Log in' link.
-        if (!$this->getSession()->getDriver()->evaluateScript($navbuttonjs)) {
+        if (!$this->evaluate_script($navbuttonjs)) {
             return false;
         }
 
@@ -526,7 +526,7 @@ class behat_navigation extends behat_base {
 
                 }
             }
-            $this->getSession()->visit($this->locate_path($url->out_as_local_url()));
+            $this->execute('behat_general::i_visit', [$url]);
         }
 
         // Restore global user variable.
@@ -549,8 +549,7 @@ class behat_navigation extends behat_base {
      * @throws Exception if the specified page cannot be determined.
      */
     public function i_am_on_page(string $page) {
-        $this->getSession()->visit($this->locate_path(
-                $this->resolve_page_helper($page)->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$this->resolve_page_helper($page)]);
     }
 
     /**
@@ -629,8 +628,7 @@ class behat_navigation extends behat_base {
      * @throws Exception if the specified page cannot be determined.
      */
     public function i_am_on_page_instance(string $identifier, string $type) {
-        $this->getSession()->visit($this->locate_path(
-                $this->resolve_page_instance_helper($identifier, $type)->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$this->resolve_page_instance_helper($identifier, $type)]);
     }
 
     /**
@@ -753,11 +751,11 @@ class behat_navigation extends behat_base {
         global $DB;
         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 
     /**
-     * Opens the course homepage with editing mode on.
+     * Open the course homepage with editing mode enabled.
      *
      * @Given /^I am on "(?P<coursefullname_string>(?:[^"]|\\")*)" course homepage with editing mode on$/
      * @throws coding_exception
@@ -766,9 +764,22 @@ class behat_navigation extends behat_base {
      */
     public function i_am_on_course_homepage_with_editing_mode_on($coursefullname) {
         global $DB;
+
         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+
+        if ($this->running_javascript() && $sesskey = $this->get_sesskey()) {
+            // Javascript is running so it is possible to grab the session ket and jump straight to editing mode.
+            $url->param('edit', 1);
+            $url->param('sesskey', $sesskey);
+            $this->execute('behat_general::i_visit', [$url]);
+
+            return;
+        }
+
+        // Visit the course page.
+        $this->execute('behat_general::i_visit', [$url]);
+
         try {
             $this->execute("behat_forms::press_button", get_string('turneditingon'));
         } catch (Exception $e) {
@@ -990,6 +1001,6 @@ class behat_navigation extends behat_base {
         if (!preg_match($fixtureregex, $url)) {
             throw new coding_exception("URL {$url} is not a fixture URL");
         }
-        $this->getSession()->visit($this->locate_path($url));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index 7f66560..0966e92 100644 (file)
@@ -198,7 +198,8 @@ if ($frm and isset($frm->username)) {                             // Login WITH
                 [
                     'username' => $frm->username,
                     'password' => $frm->password,
-                    'resendconfirmemail' => true
+                    'resendconfirmemail' => true,
+                    'logintoken' => \core\session\manager::get_login_token()
                 ]
             );
             echo $OUTPUT->single_button($resendconfirmurl, get_string('emailconfirmationresend'));
index 0055855..86708bc 100644 (file)
@@ -2629,14 +2629,15 @@ class api {
         $userto = \core_user::get_user($requesteduserid);
         $url = new \moodle_url('/message/index.php', ['view' => 'contactrequests']);
 
-        $subject = get_string('messagecontactrequestsubject', 'core_message', (object) [
+        $subject = get_string_manager()->get_string('messagecontactrequestsubject', 'core_message', (object) [
             'sitename' => format_string($SITE->fullname, true, ['context' => \context_system::instance()]),
             'user' => $userfromfullname,
-        ]);
-        $fullmessage = get_string('messagecontactrequest', 'core_message', (object) [
+        ], $userto->lang);
+
+        $fullmessage = get_string_manager()->get_string('messagecontactrequest', 'core_message', (object) [
             'url' => $url->out(),
             'user' => $userfromfullname,
-        ]);
+        ], $userto->lang);
 
         $message = new \core\message\message();
         $message->courseid = SITEID;
index f47b39c..33e3ab6 100644 (file)
@@ -43,7 +43,7 @@
 >
     <div class="container-fluid">
         <div class="row-fluid h-100 no-gutters">
-            <div class="col-4 d-flex flex-column">
+            <div class="col-4 d-flex flex-column conversationcontainer">
                 <div class="border-right h-100">
                     <div class="panel-header-container" data-region="panel-header-container">
                         {{> core_message/message_drawer_view_overview_header }}
index d66df9f..6707bfe 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js and b/mod/assign/amd/build/grading_navigation.min.js differ
index 263adb4..798e324 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js.map and b/mod/assign/amd/build/grading_navigation.min.js.map differ
index 9f6a349..5c85fb9 100644 (file)
@@ -229,10 +229,13 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         // There are 3 types of filter right now.
         var filterPanel = this._region.find('[data-region="configure-filters"]');
         var filters = filterPanel.find('select');
+        var preferenceNames = [];
 
         this._filters = [];
         filters.each(function(idx, ele) {
-            this._filters.push($(ele).val());
+            var element = $(ele);
+            this._filters.push(element.val());
+            preferenceNames.push('assign_' + element.prop('name'));
         }.bind(this));
 
         // Update the active filter string.
@@ -250,7 +253,6 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
 
         var select = this._region.find('[data-action=change-user]');
         var currentUserID = select.data('currentuserid');
-        var preferenceNames = ['assign_filter', 'assign_workflowfilter', 'assign_markerfilter'];
         this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {
             // Reload the list of users to apply the new filters.
             if (!this._loadAllUsers()) {
index fd9cd4c..432bcff 100644 (file)
@@ -125,14 +125,14 @@ if ($action === 'pollconversions') {
             $annotations = page_editor::get_annotations($grade->id, $index, $draft);
             $page->annotations = $annotations;
             $response->pages[] = $page;
-
-            $component = 'assignfeedback_editpdf';
-            $filearea = document_services::PAGE_IMAGE_FILEAREA;
-            $filepath = '/';
-            $fs = get_file_storage();
-            $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
-            $response->pageready = count($files);
         }
+
+        $component = 'assignfeedback_editpdf';
+        $filearea = document_services::PAGE_IMAGE_FILEAREA;
+        $filepath = '/';
+        $fs = get_file_storage();
+        $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
+        $response->pageready = count($files);
     }
 
     echo json_encode($response);
index 9c02a10..f417141 100644 (file)
@@ -576,7 +576,9 @@ EOD;
             }
         }
 
-        if (empty($pages)) {
+        $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
+        // Here we are comparing the total number of images against the total number of pages from the combined PDF.
+        if (empty($pages) || count($pages) != $totalpagesforattempt) {
             if ($readonly) {
                 // This should never happen, there should be a version of the pages available
                 // whenever we are requesting the readonly version.
index fb9f4b2..c6c69d6 100644 (file)
@@ -61,21 +61,21 @@ class behat_assignfeedback_editpdf extends behat_base {
     var event = { clientX: 100, clientY: 250, preventDefault: function() {} };
     instance.edit_start(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
         $js = ' (function() {
     var instance = M.assignfeedback_editpdf.instance;
     var event = { clientX: 150, clientY: 275, preventDefault: function() {} };
     instance.edit_move(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
         $js = ' (function() {
     var instance = M.assignfeedback_editpdf.instance;
     var event = { clientX: 200, clientY: 300, preventDefault: function() {} };
     instance.edit_end(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
     }
 
diff --git a/mod/assign/tests/behat/grading_app_filters.feature b/mod/assign/tests/behat/grading_app_filters.feature
new file mode 100644 (file)
index 0000000..92fd41f
--- /dev/null
@@ -0,0 +1,109 @@
+@mod @mod_assign
+Feature: In an assignment, teachers can change filters in the grading app
+  In order to manage submissions more easily
+  As a teacher
+  I need to preserve filter settings between the grader app and grading table.
+
+  @javascript
+  Scenario: Set filters in the grading table and see them in the grading app
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I set the field "filter" to "Not submitted"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
+
+  @javascript
+  Scenario: Set filters in the grading app and see them in the grading table
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "filter" to "Not submitted"
+    # The popup closes for some reason, so it needs to be reopened.
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "View all submissions" "link"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
index 383a322..663c3c5 100644 (file)
@@ -194,7 +194,11 @@ class mod_feedback_responses_table extends table_sql {
         if (preg_match('/^val(\d+)$/', $column, $matches)) {
             $items = $this->feedbackstructure->get_items();
             $itemobj = feedback_get_item_class($items[$matches[1]]->typ);
-            return trim($itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column] ));
+            $printval = $itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column]);
+            if ($this->is_downloading()) {
+                $printval = html_entity_decode($printval, ENT_QUOTES);
+            }
+            return trim($printval);
         }
         return $row->$column;
     }
index 06466e6..a7c9687 100644 (file)
@@ -37,6 +37,8 @@ use tool_dataprivacy\context_instance;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . '/grade/grading/lib.php');
+
 /**
  * Implementation of the privacy subsystem plugin provider for the forum activity module.
  *
@@ -690,35 +692,44 @@ class provider implements
     protected static function export_all_posts(int $userid, array $mappings) {
         global $DB;
 
-        // Find all of the posts, and post subscriptions for this forum.
-        list($foruminsql, $forumparams) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
-        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
-        $sql = "SELECT
-                    p.discussion AS id,
-                    f.id AS forumid,
-                    d.name,
-                    d.groupid
-                  FROM {forum} f
-                  JOIN {forum_discussions} d ON d.forum = f.id
-                  JOIN {forum_posts} p ON p.discussion = d.id
-             LEFT JOIN {forum_read} fr ON fr.postid = p.id AND fr.userid = :readuserid
-            {$ratingsql->join}
-                 WHERE f.id ${foruminsql} AND
-                (
-                    p.userid = :postuserid OR
-                    p.privatereplyto = :privatereplyrecipient OR
-                    fr.id IS NOT NULL OR
-                    {$ratingsql->userwhere}
-                )
-              GROUP BY f.id, p.discussion, d.name, d.groupid
-        ";
+        $commonsql = "SELECT p.discussion AS id, f.id AS forumid, d.name, d.groupid
+                        FROM {forum} f
+                        JOIN {forum_discussions} d ON d.forum = f.id
+                        JOIN {forum_posts} p ON p.discussion = d.id";
+
+        // All discussions with posts authored by the user or containing private replies to the user.
+        list($foruminsql1, $forumparams1) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $sql1 = "{$commonsql}
+                       WHERE f.id {$foruminsql1}
+                         AND (p.userid = :postuserid OR p.privatereplyto = :privatereplyrecipient)";
+
+        // All discussions with the posts marked as read by the user.
+        list($foruminsql2, $forumparams2) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $sql2 = "{$commonsql}
+                        JOIN {forum_read} fr ON fr.postid = p.id
+                       WHERE f.id {$foruminsql2}
+                         AND fr.userid = :readuserid";
+
+        // All discussions with ratings provided by the user.
+        list($foruminsql3, $forumparams3) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid, true);
+        $sql3 = "{$commonsql}
+                 {$ratingsql->join}
+                       WHERE f.id {$foruminsql3}
+                         AND {$ratingsql->userwhere}";
+
+        $sql = "SELECT *
+                  FROM ({$sql1} UNION {$sql2} UNION {$sql3}) united
+              GROUP BY id, forumid, name, groupid";
 
         $params = [
-            'postuserid'    => $userid,
-            'readuserid'    => $userid,
+            'postuserid' => $userid,
+            'readuserid' => $userid,
             'privatereplyrecipient' => $userid,
         ];
-        $params += $forumparams;
+        $params += $forumparams1;
+        $params += $forumparams2;
+        $params += $forumparams3;
         $params += $ratingsql->params;
 
         $discussions = $DB->get_records_sql($sql, $params);
index ca4c7df..af3f57e 100644 (file)
@@ -850,7 +850,7 @@ class subscriptions {
             // User does not have permission to unsubscribe from this discussion at all.
             $discussionsubscribe = true;
         } else {
-            if (isset($discussion) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, $discussionid, $cm)) {
+            if (isset($discussionid) && self::is_subscribed($USER->id, $forum, $discussionid, $cm)) {
                 // User is subscribed to the discussion - continue the subscription.
                 $discussionsubscribe = true;
             } else if (!isset($discussionid) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, null, $cm)) {
index ef7545d..bbda97a 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20191001" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20200508" COMMENT="XMLDB file for Moodle mod/forum"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -99,6 +99,7 @@
         <INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
         <INDEX NAME="created" UNIQUE="false" FIELDS="created"/>
         <INDEX NAME="mailed" UNIQUE="false" FIELDS="mailed"/>
+        <INDEX NAME="privatereplyto" UNIQUE="false" FIELDS="privatereplyto" COMMENT="The field is used in certain queries (such as privacy requests) to search for private replies to the user."/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="forum_queue" COMMENT="For keeping track of posts that will be mailed in digest form">
index 7d36e04..deb13e3 100644 (file)
@@ -246,5 +246,17 @@ function xmldb_forum_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020061501) {
+        // Add index privatereplyto (not unique) to the forum_posts table.
+        $table = new xmldb_table('forum_posts');
+        $index = new xmldb_index('privatereplyto', XMLDB_INDEX_NOTUNIQUE, ['privatereplyto']);
+
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        upgrade_mod_savepoint(true, 2020061501, 'forum');
+    }
+
     return true;
 }
index e884477..e33b755 100644 (file)
@@ -328,9 +328,6 @@ $rendererfactory = mod_forum\local\container::get_renderer_factory();
 $discussionrenderer = $rendererfactory->get_discussion_renderer($forum, $discussion, $displaymode);
 $orderpostsby = $displaymode == FORUM_MODE_FLATNEWEST ? 'created DESC' : 'created ASC';
 $replies = $postvault->get_replies_to_post($USER, $post, $capabilitymanager->can_view_any_private_reply($USER), $orderpostsby);
-$postids = array_map(function($post) {
-    return $post->get_id();
-}, array_merge([$post], array_values($replies)));
 
 if ($move == -1 and confirm_sesskey()) {
     $forumname = format_string($forum->get_name(), true);
@@ -341,5 +338,12 @@ echo $discussionrenderer->render($USER, $post, $replies);
 echo $OUTPUT->footer();
 
 if ($istracked && !$CFG->forum_usermarksread) {
-    forum_tp_mark_posts_read($USER, $postids);
+    if ($displaymode == FORUM_MODE_THREADED) {
+        forum_tp_add_read_record($USER->id, $post->get_id());
+    } else {
+        $postids = array_map(function($post) {
+            return $post->get_id();
+        }, array_merge([$post], array_values($replies)));
+        forum_tp_mark_posts_read($USER, $postids);
+    }
 }
index 58937ce..454743e 100644 (file)
@@ -529,6 +529,6 @@ class behat_mod_forum extends behat_base {
         global $DB;
         $post = $DB->get_record("forum_posts", array("subject" => $postsubject), 'id', MUST_EXIST);
         $url = new moodle_url('/mod/forum/post.php', ['reply' => $post->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index 7eddcd8..7935556 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061500;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2020061501;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2020060900;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)
diff --git a/mod/lesson/classes/local/numeric/helper.php b/mod/lesson/classes/local/numeric/helper.php
new file mode 100644 (file)
index 0000000..01ad8f8
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Lesson's numeric helper lib.
+ *
+ * Contains any helper functions for the numeric pagetyep
+ *
+ * @package    mod_lesson
+ * @copyright  2020 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lesson\local\numeric;
+
+/**
+ * Lesson numeric page helper
+ *
+ * @copyright  2020 Peter Dias<peter@moodle.com>
+ * @package core_lesson
+ */
+class helper {
+
+    /**
+     * Helper function to unformat a given numeric value from locale specific values with n:n signifying ranges to standards
+     * with decimal point numbers/ranges
+     *
+     * @param string $value The value to be formatted
+     * @return string|float|bool $formattedvalue unformatted value
+     *              String - If it is a range it will return a value e.g. 2:4
+     *              Float - if it's a properly formatted float
+     *              Null - If empty and could not be converted
+     */
+    public static function lesson_unformat_numeric_value(string $value) {
+        if (strpos($value, ':')) {
+            list($min, $max) = explode(':', $value);
+            $formattedvalue = unformat_float($min) . ':' . unformat_float($max);
+        } else {
+            $formattedvalue = unformat_float($value);
+        }
+
+        return $formattedvalue;
+    }
+
+    /**
+     * Helper function to format a given value into locale specific values with n:n signifying ranges
+     *
+     * @param string|number $value The value to be formatted
+     * @return string $formattedvalue Formatted value OR $value if not numeric
+     */
+    public static function lesson_format_numeric_value($value) : string {
+        $formattedvalue = $value;
+        if (strpos($value, ':')) {
+            list($min, $max) = explode(':', $value);
+            $formattedvalue = $min . ':' . $max;
+            if (is_numeric($min) && is_numeric($max)) {
+                $formattedvalue = format_float($min, strlen($min), true, true) . ':'
+                    . format_float($max, strlen($max), true, true);
+            }
+        } else {
+            $formattedvalue = is_numeric($value) ? format_float($value, strlen($value), true, true) : $value;
+        }
+
+        return $formattedvalue;
+    }
+
+}
index fc14d26..997176c 100644 (file)
@@ -399,6 +399,8 @@ $string['numberofpagesviewed'] = 'Number of questions answered: {$a}';
 $string['numberofpagesviewedheader'] = 'Number of questions answered';
 $string['numberofpagesviewednotice'] = 'Number of questions answered: {$a->nquestions} (You should answer at least {$a->minquestions})';
 $string['numerical'] = 'Numerical';
+$string['numericanswer_help'] = 'You can specify a number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 including them are correct.';
+$string['numericanswer'] = 'Numeric answer';
 $string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago. Please check that you do not have any unsaved work.';
 $string['ongoing'] = 'Display ongoing score';
 $string['ongoing_help'] = 'If enabled, each page will display the student\'s current points earned out of the total possible thus far.';
index 30a8d4b..9b1cb3a 100644 (file)
@@ -1458,9 +1458,11 @@ abstract class lesson_add_page_form_base extends moodleform {
      * @param string $label, null means default
      * @param bool $required
      * @param string $format
+     * @param array $help Add help text via the addHelpButton. Must be an array which contains the string identifier and
+     *                      component as it's elements
      * @return void
      */
-    protected final function add_answer($count, $label = null, $required = false, $format= '') {
+    protected final function add_answer($count, $label = null, $required = false, $format= '', array $help = []) {
         if ($label === null) {
             $label = get_string('answer', 'lesson');
         }
@@ -1473,13 +1475,17 @@ abstract class lesson_add_page_form_base extends moodleform {
             $this->_form->setDefault('answer_editor['.$count.']', array('text' => '', 'format' => FORMAT_HTML));
         } else {
             $this->_form->addElement('text', 'answer_editor['.$count.']', $label,
-                    array('size' => '50', 'maxlength' => '200'));
+                array('size' => '50', 'maxlength' => '200'));
             $this->_form->setType('answer_editor['.$count.']', PARAM_TEXT);
         }
 
         if ($required) {
             $this->_form->addRule('answer_editor['.$count.']', get_string('required'), 'required', null, 'client');
         }
+
+        if ($help) {
+            $this->_form->addHelpButton("answer_editor[$count]", $help['identifier'], $help['component']);
+        }
     }
     /**
      * Convenience function: Adds an response editor
@@ -4530,6 +4536,7 @@ abstract class lesson_page extends lesson_base {
                     $this->answers[$i]->lessonid = $this->lesson->id;
                     $this->answers[$i]->pageid = $this->id;
                     $this->answers[$i]->timecreated = $this->timecreated;
+                    $this->answers[$i]->answer = null;
                 }
 
                 if (isset($properties->answer_editor[$i])) {
@@ -4542,6 +4549,9 @@ abstract class lesson_page extends lesson_base {
                         $this->answers[$i]->answer = $properties->answer_editor[$i];
                         $this->answers[$i]->answerformat = FORMAT_MOODLE;
                     }
+                } else {
+                    // If there is no data posted which means we want to reset the stored values.
+                    $this->answers[$i]->answer = null;
                 }
 
                 if (!empty($properties->response_editor[$i]) && is_array($properties->response_editor[$i])) {
index 014daac..ff7bfa2 100644 (file)
@@ -28,6 +28,8 @@ defined('MOODLE_INTERNAL') || die();
 /** Numerical question type */
 define("LESSON_PAGE_NUMERICAL",     "8");
 
+use mod_lesson\local\numeric\helper;
+
 class lesson_page_type_numerical extends lesson_page {
 
     protected $type = lesson_page::TYPE_QUESTION;
@@ -48,8 +50,9 @@ class lesson_page_type_numerical extends lesson_page {
         return $this->typeidstring;
     }
     public function display($renderer, $attempt) {
-        global $USER, $CFG, $PAGE;
-        $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents(), 'lessonid'=>$this->lesson->id));
+        global $USER, $PAGE;
+        $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+            array('contents' => $this->get_contents(), 'lessonid' => $this->lesson->id));
         $data = new stdClass;
         $data->id = $PAGE->cm->id;
         $data->pageid = $this->properties->id;
@@ -109,10 +112,10 @@ class lesson_page_type_numerical extends lesson_page {
     }
 
     public function check_answer() {
-        global $CFG;
         $result = parent::check_answer();
 
-        $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents()));
+        $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+            array('contents' => $this->get_contents()));
         $data = $mform->get_data();
         require_sesskey();
 
@@ -124,12 +127,11 @@ class lesson_page_type_numerical extends lesson_page {
         $result->response = '';
         $result->newpageid = 0;
 
-        if (!isset($data->answer) || !is_numeric($data->answer)) {
+        if (!isset($data->answer)) {
             $result->noanswer = true;
             return $result;
         } else {
-            // Just doing default PARAM_RAW, not doing PARAM_INT because it could be a float.
-            $result->useranswer = (float)$data->answer;
+            $result->useranswer = $data->answer;
         }
         $result->studentanswer = $result->userresponse = $result->useranswer;
         $answers = $this->get_answers();
@@ -201,7 +203,8 @@ class lesson_page_type_numerical extends lesson_page {
             } else {
                 $cells[] = '<label class="correct">' . get_string('answer', 'lesson') . ' ' . $i . '</label>:';
             }
-            $cells[] = format_text($answer->answer, $answer->answerformat, $options);
+            $formattedanswer = helper::lesson_format_numeric_value($answer->answer);
+            $cells[] = format_text($formattedanswer, $answer->answerformat, $options);
             $table->data[] = new html_table_row($cells);
 
             $cells = array();
@@ -258,7 +261,8 @@ class lesson_page_type_numerical extends lesson_page {
                     unset($stats["total"]);
                     foreach ($stats as $valentered => $ntimes) {
                         $data = '<input class="form-control" type="text" size="50" ' .
-                                'disabled="disabled" readonly="readonly" value="'.s($valentered).'" />';
+                                'disabled="disabled" readonly="readonly" value="'.
+                                s(format_float($valentered, strlen($valentered), true, true)).'" />';
                         $percent = $ntimes / $total * 100;
                         $percent = round($percent, 2);
                         $percent .= "% ".get_string("enteredthis", "lesson");
@@ -272,7 +276,8 @@ class lesson_page_type_numerical extends lesson_page {
                     empty($answerdata->answers)))) {
                 // Get in here when the user answered or for the last answer.
                 $data = '<input class="form-control" type="text" size="50" ' .
-                        'disabled="disabled" readonly="readonly" value="'.s($useranswer->useranswer).'">';
+                        'disabled="disabled" readonly="readonly" value="'.
+                        s(format_float($useranswer->useranswer, strlen($useranswer->useranswer), true, true)).'">';
                 if (isset($pagestats[$this->properties->id][$useranswer->useranswer])) {
                     $percent = $pagestats[$this->properties->id][$useranswer->useranswer] / $pagestats[$this->properties->id]["total"] * 100;
                     $percent = round($percent, 2);
@@ -321,6 +326,12 @@ class lesson_page_type_numerical extends lesson_page {
      */
     public function update_form_data(stdClass $data) : stdClass {
         $answercount = count($this->get_answers());
+
+        // If no answers provided, then we don't need to check anything.
+        if (!$answercount) {
+            return $data;
+        }
+
         // Check for other answer entry.
         $lastanswer = $data->{'answer_editor[' . ($answercount - 1) . ']'};
         if (strpos($lastanswer, LESSON_OTHER_ANSWERS) !== false) {
@@ -354,7 +365,10 @@ class lesson_add_page_form_numerical extends lesson_add_page_form_base {
         $answercount = $this->_customdata['lesson']->maxanswers;
         for ($i = 0; $i < $answercount; $i++) {
             $this->_form->addElement('header', 'answertitle'.$i, get_string('answer').' '.($i+1));
-            $this->add_answer($i, null, ($i < 1));
+            $this->add_answer($i, null, ($i < 1), '', [
+                    'identifier' => 'numericanswer',
+                    'component' => 'mod_lesson'
+            ]);
             $this->add_response($i);
             $this->add_jumpto($i, null, ($i == 0 ? LESSON_NEXTPAGE : LESSON_THISPAGE));
             $this->add_score($i, null, ($i===0)?1:0);
@@ -367,6 +381,64 @@ class lesson_add_page_form_numerical extends lesson_add_page_form_base {
         $this->add_jumpto($newcount, get_string('allotheranswersjump', 'lesson'), LESSON_NEXTPAGE);
         $this->add_score($newcount, get_string('allotheranswersscore', 'lesson'), 0);
     }
+
+    /**
+     * We call get data when storing the data into the db. Override to format the floats properly
+     *
+     * @return object|void
+     */
+    public function get_data() : ?stdClass {
+        $data = parent::get_data();
+
+        if (!empty($data->answer_editor)) {
+            foreach ($data->answer_editor as $key => $answer) {
+                $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Return submitted data if properly submitted or returns NULL if validation fails or
+     * if there is no submitted data with formatted numbers
+     *
+     * @return object submitted data; NULL if not valid or not submitted or cancelled
+     */
+    public function get_submitted_data() : ?stdClass {
+        $data = parent::get_submitted_data();
+
+        if (!empty($data->answer_editor)) {
+            foreach ($data->answer_editor as $key => $answer) {
+                $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Load in existing data as form defaults. Usually new entry defaults are stored directly in
+     * form definition (new entry form); this function is used to load in data where values
+     * already exist and data is being edited (edit entry form) after formatting numbers
+     *
+     *
+     * @param stdClass|array $defaults object or array of default values
+     */
+    public function set_data($defaults) {
+        if (is_object($defaults)) {
+            $defaults = (array) $defaults;
+        }
+
+        $editor = 'answer_editor';
+        foreach ($defaults as $key => $answer) {
+            if (substr($key, 0, strlen($editor)) == $editor) {
+                $defaults[$key] = helper::lesson_format_numeric_value($answer);
+            }
+        }
+
+        parent::set_data($defaults);
+    }
 }
 
 class lesson_display_answer_form_numerical extends moodleform {
@@ -402,8 +474,7 @@ class lesson_display_answer_form_numerical extends moodleform {
         $mform->addElement('hidden', 'pageid');
         $mform->setType('pageid', PARAM_INT);
 
-        $mform->addElement('text', 'answer', get_string('youranswer', 'lesson'), $attrs);
-        $mform->setType('answer', PARAM_FLOAT);
+        $mform->addElement('float', 'answer', get_string('youranswer', 'lesson'), $attrs);
 
         if ($hasattempt) {
             $this->add_action_buttons(null, get_string("nextpage", "lesson"));
@@ -411,5 +482,4 @@ class lesson_display_answer_form_numerical extends moodleform {
             $this->add_action_buttons(null, get_string("submit", "lesson"));
         }
     }
-
 }
diff --git a/mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature b/mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature
new file mode 100644 (file)
index 0000000..a3daa90
--- /dev/null
@@ -0,0 +1,135 @@
+@mod @mod_lesson
+Feature: In a lesson activity, I need to edit pages in the lesson taking into account locale settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "language customisations" exist:
+      | component       | stringid | value |
+      | core_langconfig | decsep   | #     |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Lesson" to section "1" and I fill the form with:
+      | Name | Test lesson name |
+      | Description | Test lesson description |
+      | Allow student review | Yes |
+    And I follow "Test lesson name"
+    And I follow "Add a question page"
+    And I set the field "Select a question type" to "Numerical"
+    And I press "Add a question page"
+    And I set the following fields to these values:
+      | Page title | Hardest question ever |
+      | Page contents | 1 + 1? |
+      | id_answer_editor_0 | 2#87 |
+      | id_response_editor_0 | Correct answer |
+      | id_jumpto_0 | End of lesson |
+      | id_score_0 | 1 |
+      | id_answer_editor_1 | 2#1:2#8 |
+      | id_response_editor_1 | Incorrect answer |
+      | id_jumpto_1 | This page |
+      | id_score_1 | 0 |
+    And I press "Save page"
+    And I log out
+
+  Scenario: Edit a numerical question with the locale specific variables
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    And I click on "Edit" "link" in the "region-main" "region"
+    And I follow "Hardest question ever"
+    Then I should see "2#87"
+    And I should see "2#1:2#8"
+    And I log out
+
+  Scenario: View the detailed page of lesson
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    And I click on "Edit" "link" in the "region-main" "region"
+    And I click on "Expanded" "link" in the "region-main" "region"
+    Then I should see "2#87"
+    And I should see "2#1:2#8"
+    And I log out
+
+  Scenario: Attempt the lesson successfully as a student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#87 |
+    And I press "Submit"
+    Then I should see "Correct answer"
+    And I should not see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 1 (out of 1)."
+    And I log out
+
+  Scenario: Attempt the lesson unsuccessfully as a student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#7 |
+    And I press "Submit"
+    Then I should not see "Correct answer"
+    And I should see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 0 (out of 1)."
+    And I log out
+
+  Scenario: Attempt the lesson successfully as a student and review
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#87 |
+    And I press "Submit"
+    Then I should see "Correct answer"
+    And I should not see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 1 (out of 1)."
+    And I follow "Review lesson"
+    Then I should see "1 + 1?"
+    And the following fields match these values:
+      | Your answer | 2#87 |
+    And I log out
+
+  Scenario: Edit lesson question page with updated locale setting and wrong answer
+    Given I log in as "teacher1"
+    And the following "language customisations" exist:
+      | component       | stringid | value |
+      | core_langconfig | decsep   | ,     |
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    Then I click on "Edit" "link" in the "region-main" "region"
+    And I follow "Hardest question ever"
+    Then I should see "2,87"
+    And I should see "2,1:2,8"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2,7 |
+    And I press "Submit"
+    And I should see "Incorrect answer"
+    And I should not see "Correct answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 0 (out of 1)."
diff --git a/mod/lesson/tests/numeric_helper_test.php b/mod/lesson/tests/numeric_helper_test.php
new file mode 100644 (file)
index 0000000..da93292
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for page types classes
+ *
+ * @package   mod_lesson
+ * @category  test
+ * @copyright 2020 Peter Dias
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+use mod_lesson\local\numeric\helper;
+
+/**
+ * This class contains the test cases for the numeric helper functions
+ *
+ * @copyright 2020 Peter Dias
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+class mod_lesson_numeric_type_helper_test extends advanced_testcase {
+    /**
+     * Test the lesson_unformat_numeric_value function.
+     *
+     * @dataProvider lesson_unformat_dataprovider
+     * @param $decsep
+     * @param $tests
+     */
+    public function test_lesson_unformat_numeric_value($decsep, $tests) {
+        $this->define_local_decimal_separator($decsep);
+
+        foreach ($tests as $test) {
+            $this->assertEquals($test[1], helper::lesson_unformat_numeric_value($test[0]));
+        }
+    }
+
+    /**
+     * Test the lesson_format_numeric_value function.
+     *
+     * @dataProvider lesson_format_dataprovider
+     * @param $decsep
+     * @param $tests
+     */
+    public function test_lesson_format_numeric_value($decsep, $tests) {
+        $this->define_local_decimal_separator($decsep);
+
+        foreach ($tests as $test) {
+            $this->assertEquals($test[1], helper::lesson_format_numeric_value($test[0]));
+        }
+    }
+
+    /**
+     * Provide various cases for the unformat test function
+     *
+     * @return array
+     */
+    public function lesson_unformat_dataprovider() {
+        return [
+            "Using a decimal as a separator" => [
+                "decsep" => ".",
+                "test" => [
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                    ["2,1", 2],
+                    ["1:4,2", "1:4"],
+                    ["", null]
+                ]
+            ],
+            "Using a comma as a separator" => [
+                "decsep" => ",",
+                "test" => [
+                    ["2,1", 2.1],
+                    ["1:4,2", "1:4.2"],
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                ]
+            ],
+            "Using a X as a separator" => [
+                "decsep" => "X",
+                "test" => [
+                    ["2X1", 2.1],
+                    ["1:4X2", "1:4.2"],
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Provide various cases for the unformat test function
+     *
+     * @return array
+     */
+    public function lesson_format_dataprovider() {
+        return [
+            "Using a decimal as a separator" => [
+                "decsep" => ".",
+                "test" => [
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                    ["2,1", "2,1"],
+                    ["1:4,2", "1:4,2"]
+                ]
+            ],
+            "Using a comma as a separator" => [
+                "decsep" => ",",
+                "test" => [
+                    ["2,1", "2,1"],
+                    ["1:4,2", "1:4,2"],
+                    ["2.1", "2,1"],
+                    [2.1, "2,1"],
+                    ["1:4.2", "1:4,2"],
+                ]
+            ],
+            "Using a X as a separator" => [
+                "decsep" => "X",
+                "test" => [
+                    ["2X1", "2X1"],
+                    ["1:4X2", "1:4X2"],
+                    ["2.1", "2X1"],
+                    ["1:4.2", "1:4X2"],
+                ]
+            ]
+        ];
+    }
+
+
+    /**
+     * Define a local decimal separator.
+     *
+     * It is not possible to directly change the result of get_string in
+     * a unit test. Instead, we create a language pack for language 'xx' in
+     * dataroot and make langconfig.php with the string we need to change.
+     * The default example separator used here is 'X'; on PHP 5.3 and before this
+     * must be a single byte character due to PHP bug/limitation in
+     * number_format, so you can't use UTF-8 characters.
+     *
+     * @param string $decsep Separator character. Defaults to `'X'`.
+     */
+    protected function define_local_decimal_separator(string $decsep = 'X') {
+        global $SESSION, $CFG;
+
+        $SESSION->lang = 'xx';
+        $langconfig = "<?php\n\$string['decsep'] = '$decsep';";
+        $langfolder = $CFG->dataroot . '/lang/xx';
+        check_dir_exists($langfolder);
+        file_put_contents($langfolder . '/langconfig.php', $langconfig);
+
+        // Ensure the new value is picked up and not taken from the cache.
+        $stringmanager = get_string_manager();
+        $stringmanager->reset_caches(true);
+    }
+}
index b37cf4e..2b088a0 100644 (file)
@@ -46,39 +46,64 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
      * Set up for every test
      */
     public function setUp() {
-        global $DB;
         $this->resetAfterTest();
+    }
+
+    /**
+     * Sets up some basic test data including course, users, roles, and an lti instance, for use in some tests.
+     * @return array
+     */
+    protected function setup_test_data() {
+        global $DB;
         $this->setAdminUser();
 
         // Setup test data.
-        $this->course = $this->getDataGenerator()->create_course();
-        $this->lti = $this->getDataGenerator()->create_module('lti',
-            array('course' => $this->course->id, 'toolurl' => 'http://localhost/not/real/tool.php'));
-        $this->context = context_module::instance($this->lti->cmid);
-        $this->cm = get_coursemodule_from_instance('lti', $this->lti->id);
+        $course = $this->getDataGenerator()->create_course();
+        $lti = $this->getDataGenerator()->create_module(
+            'lti',
+            ['course' => $course->id, 'toolurl' => 'http://localhost/not/real/tool.php']
+        );
+        $context = context_module::instance($lti->cmid);
+        $cm = get_coursemodule_from_instance('lti', $lti->id);
 
         // Create users.
-        $this->student = self::getDataGenerator()->create_user();
-        $this->teacher = self::getDataGenerator()->create_user();
+        $student = self::getDataGenerator()->create_user();
+        $teacher = self::getDataGenerator()->create_user();
 
         // Users enrolments.
-        $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
-        $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
-        $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, $teacherrole->id, 'manual');
+
+        return [
+            'course' => $course,
+            'lti' => $lti,
+            'context' => $context,
+            'cm' => $cm,
+            'student' => $student,
+            'teacher' => $teacher,
+            'studentrole' => $studentrole,
+            'teacherrole' => $teacherrole
+        ];
     }
 
     /**
-     * Test view_lti
+     * Test get_tool_launch_data.
      */
     public function test_get_tool_launch_data() {
-        global $USER, $SITE;
+        global $USER;
 
-        $result = mod_lti_external::get_tool_launch_data($this->lti->id);
+        [
+            'course' => $course,
+            'lti' => $lti
+        ] = $this->setup_test_data();
+
+        $result = mod_lti_external::get_tool_launch_data($lti->id);
         $result = external_api::clean_returnvalue(mod_lti_external::get_tool_launch_data_returns(), $result);
 
         // Basic test, the function returns what it's expected.
-        self::assertEquals($this->lti->toolurl, $result['endpoint']);
+        self::assertEquals($lti->toolurl, $result['endpoint']);
         self::assertCount(36, $result['parameters']);
 
         // Check some parameters.
@@ -86,9 +111,9 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         foreach ($result['parameters'] as $param) {
             $parameters[$param['name']] = $param['value'];
         }
-        self::assertEquals($this->lti->resourcekey, $parameters['oauth_consumer_key']);
-        self::assertEquals($this->course->fullname, $parameters['context_title']);
-        self::assertEquals($this->course->shortname, $parameters['context_label']);
+        self::assertEquals($lti->resourcekey, $parameters['oauth_consumer_key']);
+        self::assertEquals($course->fullname, $parameters['context_title']);
+        self::assertEquals($course->shortname, $parameters['context_label']);
         self::assertEquals($USER->id, $parameters['user_id']);
         self::assertEquals($USER->firstname, $parameters['lis_person_name_given']);
         self::assertEquals($USER->lastname, $parameters['lis_person_name_family']);
@@ -96,14 +121,19 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         self::assertEquals($USER->username, $parameters['ext_user_username']);
         self::assertEquals("phpunit", $parameters['tool_consumer_instance_name']);
         self::assertEquals("PHPUnit test site", $parameters['tool_consumer_instance_description']);
-
     }
 
-    /*
-     * Test get ltis by courses
+    /**
+     * Test get_ltis_by_courses.
      */
     public function test_mod_lti_get_ltis_by_courses() {
-        global $DB;
+        [
+            'course' => $course,
+            'lti' => $lti,
+            'student' => $student,
+            'teacher' => $teacher,
+            'studentrole' => $studentrole,
+        ] = $this->setup_test_data();
 
         // Create additional course.
         $course2 = self::getDataGenerator()->create_course();
@@ -122,19 +152,19 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
                 break;
             }
         }
-        $enrol->enrol_user($instance2, $this->student->id, $this->studentrole->id);
+        $enrol->enrol_user($instance2, $student->id, $studentrole->id);
 
-        self::setUser($this->student);
+        self::setUser($student);
 
         $returndescription = mod_lti_external::get_ltis_by_courses_returns();
 
         // Create what we expect to be returned when querying the two courses.
         // First for the student user.
-        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'launchcontainer',
-                                'showtitlelaunch', 'showdescriptionlaunch', 'icon', 'secureicon');
+        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles',
+            'launchcontainer', 'showtitlelaunch', 'showdescriptionlaunch', 'icon', 'secureicon');
 
         // Add expected coursemodule and data.
-        $lti1 = $this->lti;
+        $lti1 = $lti;
         $lti1->coursemodule = $lti1->cmid;
         $lti1->introformat = 1;
         $lti1->section = 0;
@@ -152,14 +182,14 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $lti2->introfiles = [];
 
         foreach ($expectedfields as $field) {
-                $expected1[$field] = $lti1->{$field};
-                $expected2[$field] = $lti2->{$field};
+            $expected1[$field] = $lti1->{$field};
+            $expected2[$field] = $lti2->{$field};
         }
 
         $expectedltis = array($expected2, $expected1);
 
         // Call the external function passing course ids.
-        $result = mod_lti_external::get_ltis_by_courses(array($course2->id, $this->course->id));
+        $result = mod_lti_external::get_ltis_by_courses(array($course2->id, $course->id));
         $result = external_api::clean_returnvalue($returndescription, $result);
 
         $this->assertEquals($expectedltis, $result['ltis']);
@@ -172,7 +202,7 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $this->assertCount(0, $result['warnings']);
 
         // Unenrol user from second course and alter expected ltis.
-        $enrol->unenrol_user($instance2, $this->student->id);
+        $enrol->unenrol_user($instance2, $student->id);
         array_shift($expectedltis);
 
         // Call the external function without passing course id.
@@ -182,20 +212,21 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
 
         // Call for the second course we unenrolled the user from, expected warning.
         $result = mod_lti_external::get_ltis_by_courses(array($course2->id));
+        $result = external_api::clean_returnvalue($returndescription, $result);
         $this->assertCount(1, $result['warnings']);
         $this->assertEquals('1', $result['warnings'][0]['warningcode']);
         $this->assertEquals($course2->id, $result['warnings'][0]['itemid']);
 
         // Now, try as a teacher for getting all the additional fields.
-        self::setUser($this->teacher);
+        self::setUser($teacher);
 
         $additionalfields = array('timecreated', 'timemodified', 'typeid', 'toolurl', 'securetoolurl',
-                        'instructorchoicesendname', 'instructorchoicesendemailaddr', 'instructorchoiceallowroster',
-                        'instructorchoiceallowsetting', 'instructorcustomparameters', 'instructorchoiceacceptgrades', 'grade',
-                        'resourcekey', 'password', 'debuglaunch', 'servicesalt', 'visible', 'groupmode', 'groupingid');
+            'instructorchoicesendname', 'instructorchoicesendemailaddr', 'instructorchoiceallowroster',
+            'instructorchoiceallowsetting', 'instructorcustomparameters', 'instructorchoiceacceptgrades', 'grade',
+            'resourcekey', 'password', 'debuglaunch', 'servicesalt', 'visible', 'groupmode', 'groupingid');
 
         foreach ($additionalfields as $field) {
-                $expectedltis[0][$field] = $lti1->{$field};
+            $expectedltis[0][$field] = $lti1->{$field};
         }
 
         $result = mod_lti_external::get_ltis_by_courses();
@@ -205,56 +236,91 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         // Admin also should get all the information.
         self::setAdminUser();
 
-        $result = mod_lti_external::get_ltis_by_courses(array($this->course->id));
+        $result = mod_lti_external::get_ltis_by_courses(array($course->id));
         $result = external_api::clean_returnvalue($returndescription, $result);
         $this->assertEquals($expectedltis, $result['ltis']);
 
         // Now, prohibit capabilities.
-        $this->setUser($this->student);
-        $contextcourse1 = context_course::instance($this->course->id);
+        $this->setUser($student);
+        $contextcourse1 = context_course::instance($course->id);
         // Prohibit capability = mod:lti:view on Course1 for students.
-        assign_capability('mod/lti:view', CAP_PROHIBIT, $this->studentrole->id, $contextcourse1->id);
+        assign_capability('mod/lti:view', CAP_PROHIBIT, $studentrole->id, $contextcourse1->id);
         // Empty all the caches that may be affected by this change.
         accesslib_clear_all_caches_for_unit_testing();
         course_modinfo::clear_instance_cache();
 
-        $ltis = mod_lti_external::get_ltis_by_courses(array($this->course->id));
+        $ltis = mod_lti_external::get_ltis_by_courses(array($course->id));
         $ltis = external_api::clean_returnvalue(mod_lti_external::get_ltis_by_courses_returns(), $ltis);
         $this->assertCount(0, $ltis['ltis']);
     }
 
     /**
-     * Test view_lti
+     * Test view_lti with an invalid instance id.
      */
-    public function test_view_lti() {
-        global $DB;
+    public function test_view_lti_invalid_instanceid() {
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::view_lti(0);
+    }
 
-        // Test invalid instance id.
-        try {
-            mod_lti_external::view_lti(0);
-            $this->fail('Exception expected due to invalid mod_lti instance id.');
-        } catch (moodle_exception $e) {
-            $this->assertEquals('invalidrecord', $e->errorcode);
-        }
+    /**
+     * Test view_lti as a user who is not enrolled in the course.
+     */
+    public function test_view_lti_no_enrolment() {
+        [
+            'lti' => $lti
+        ] = $this->setup_test_data();
 
         // Test not-enrolled user.
         $usernotenrolled = self::getDataGenerator()->create_user();
         $this->setUser($usernotenrolled);
-        try {
-            mod_lti_external::view_lti($this->lti->id);
-            $this->fail('Exception expected due to not enrolled user.');
-        } catch (moodle_exception $e) {
-            $this->assertEquals('requireloginerror', $e->errorcode);
-        }
+
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::view_lti($lti->id);
+    }
+
+    /**
+     * Test view_lti for a user without the mod/lti:view capability.
+     */
+    public function test_view_lti_no_capability() {
+        [
+            'lti' => $lti,
+            'student' => $student,
+            'studentrole' => $studentrole,
+            'context' => $context,
+        ] = $this->setup_test_data();
+
+        $this->setUser($student);
+
+        // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
+        assign_capability('mod/lti:view', CAP_PROHIBIT, $studentrole->id, $context->id);
+        // Empty all the caches that may be affected by this change.
+        accesslib_clear_all_caches_for_unit_testing();
+        course_modinfo::clear_instance_cache();
+
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::view_lti($lti->id);
+    }
+
+    /**
+     * Test view_lti for a user with the mod/lti:view capability in the course.
+     */
+    public function test_view_lti() {
+        [
+            'lti' => $lti,
+            'context' => $context,
+            'cm' => $cm,
+            'student' => $student,
+        ] = $this->setup_test_data();
 
         // Test user with full capabilities.
-        $this->setUser($this->student);
+        $this->setUser($student);
 
         // Trigger and capture the event.
         $sink = $this->redirectEvents();
 
-        $result = mod_lti_external::view_lti($this->lti->id);
-        $result = external_api::clean_returnvalue(mod_lti_external::view_lti_returns(), $result);
+        $result = mod_lti_external::view_lti($lti->id);
+        // The value of the result isn't needed but validation is.
+        external_api::clean_returnvalue(mod_lti_external::view_lti_returns(), $result);
 
         $events = $sink->get_events();
         $this->assertCount(1, $events);
@@ -262,88 +328,92 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
 
         // Checking that the event contains the expected values.
         $this->assertInstanceOf('\mod_lti\event\course_module_viewed', $event);
-        $this->assertEquals($this->context, $event->get_context());
-        $moodlelti = new \moodle_url('/mod/lti/view.php', array('id' => $this->cm->id));
+        $this->assertEquals($context, $event->get_context());
+        $moodlelti = new moodle_url('/mod/lti/view.php', array('id' => $cm->id));
         $this->assertEquals($moodlelti, $event->get_url());
         $this->assertEventContextNotUsed($event);
         $this->assertNotEmpty($event->get_name());
-
-        // Test user with no capabilities.
-        // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
-        assign_capability('mod/lti:view', CAP_PROHIBIT, $this->studentrole->id, $this->context->id);
-        // Empty all the caches that may be affected by this change.
-        accesslib_clear_all_caches_for_unit_testing();
-        course_modinfo::clear_instance_cache();
-
-        try {
-            mod_lti_external::view_lti($this->lti->id);
-            $this->fail('Exception expected due to missing capability.');
-        } catch (moodle_exception $e) {
-            $this->assertEquals('requireloginerror', $e->errorcode);
-        }
-
     }
 
-    /*
-     * Test create tool proxy
+    /**
+     * Test create_tool_proxy.
      */
     public function test_mod_lti_create_tool_proxy() {
+        $this->setAdminUser();
         $capabilities = ['AA', 'BB'];
         $proxy = mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), $capabilities, []);
+        $proxy = (object) external_api::clean_returnvalue(mod_lti_external::create_tool_proxy_returns(), $proxy);
+
         $this->assertEquals('Test proxy', $proxy->name);
         $this->assertEquals($this->getExternalTestFileUrl('/test.html'), $proxy->regurl);
         $this->assertEquals(LTI_TOOL_PROXY_STATE_PENDING, $proxy->state);
         $this->assertEquals(implode("\n", $capabilities), $proxy->capabilityoffered);
     }
 
-    /*
-     * Test create tool proxy with duplicate url
+    /**
+     * Test create_tool_proxy with a duplicate url.
      */
     public function test_mod_lti_create_tool_proxy_duplicateurl() {
-        $this->expectException('moodle_exception');
-        $proxy = mod_lti_external::create_tool_proxy('Test proxy 1', $this->getExternalTestFileUrl('/test.html'), array(), array());
-        $proxy = mod_lti_external::create_tool_proxy('Test proxy 2', $this->getExternalTestFileUrl('/test.html'), array(), array());
+        $this->setAdminUser();
+        mod_lti_external::create_tool_proxy('Test proxy 1', $this->getExternalTestFileUrl('/test.html'), array(), array());
+
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::create_tool_proxy('Test proxy 2', $this->getExternalTestFileUrl('/test.html'), array(), array());
     }
 
-    /*
-     * Test create tool proxy without sufficient capability
+    /**
+     * Test create_tool_proxy for a user without the required capability.
      */
     public function test_mod_lti_create_tool_proxy_without_capability() {
-        self::setUser($this->teacher);
-        $this->expectException('required_capability_exception');
-        $proxy = mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), array(), array());
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), array(), array());
     }
 
-    /*
-     * Test delete tool proxy
+    /**
+     * Test delete_tool_proxy.
      */
     public function test_mod_lti_delete_tool_proxy() {
+        $this->setAdminUser();
         $proxy = mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), array(), array());
+        $proxy = (object) external_api::clean_returnvalue(mod_lti_external::create_tool_proxy_returns(), $proxy);
         $this->assertNotEmpty(lti_get_tool_proxy($proxy->id));
 
         $proxy = mod_lti_external::delete_tool_proxy($proxy->id);
+        $proxy = (object) external_api::clean_returnvalue(mod_lti_external::delete_tool_proxy_returns(), $proxy);
+
         $this->assertEquals('Test proxy', $proxy->name);
         $this->assertEquals($this->getExternalTestFileUrl('/test.html'), $proxy->regurl);
         $this->assertEquals(LTI_TOOL_PROXY_STATE_PENDING, $proxy->state);
         $this->assertEmpty(lti_get_tool_proxy($proxy->id));
     }
 
-    /*
-     * Test get tool proxy registration request
+    /**
+     * Test get_tool_proxy_registration_request.
      */
     public function test_mod_lti_get_tool_proxy_registration_request() {
+        $this->setAdminUser();
         $proxy = mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), array(), array());
+        $proxy = (object) external_api::clean_returnvalue(mod_lti_external::create_tool_proxy_returns(), $proxy);
+
         $request = mod_lti_external::get_tool_proxy_registration_request($proxy->id);
+        $request = external_api::clean_returnvalue(mod_lti_external::get_tool_proxy_registration_request_returns(),
+            $request);
+
         $this->assertEquals('ToolProxyRegistrationRequest', $request['lti_message_type']);
         $this->assertEquals('LTI-2p0', $request['lti_version']);
     }
 
-    /*
-     * Test get tool types
+    /**
+     * Test get_tool_types.
      */
     public function test_mod_lti_get_tool_types() {
         // Create a tool proxy.
+        $this->setAdminUser();
         $proxy = mod_lti_external::create_tool_proxy('Test proxy', $this->getExternalTestFileUrl('/test.html'), array(), array());
+        $proxy = (object) external_api::clean_returnvalue(mod_lti_external::create_tool_proxy_returns(), $proxy);
 
         // Create a tool type, associated with that proxy.
         $type = new stdClass();
@@ -353,20 +423,25 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $type->description = "Example description";
         $type->toolproxyid = $proxy->id;
         $type->baseurl = $this->getExternalTestFileUrl('/test.html');
-        $typeid = lti_add_type($type, $data);
+        lti_add_type($type, $data);
 
         $types = mod_lti_external::get_tool_types($proxy->id);
-        $this->assertEquals(1, count($types));
+        $types = external_api::clean_returnvalue(mod_lti_external::get_tool_types_returns(), $types);
+
+        $this->assertCount(1, $types);
         $type = $types[0];
         $this->assertEquals('Test tool', $type['name']);
         $this->assertEquals('Example description', $type['description']);
     }
 
-    /*
-     * Test create tool type
+    /**
+     * Test create_tool_type.
      */
     public function test_mod_lti_create_tool_type() {
+        $this->setAdminUser();
         $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
+        $type = external_api::clean_returnvalue(mod_lti_external::create_tool_type_returns(), $type);
+
         $this->assertEquals('Example tool', $type['name']);
         $this->assertEquals('Example tool description', $type['description']);
         $this->assertEquals('https://download.moodle.org/unittest/test.jpg', $type['urls']['icon']);
@@ -379,70 +454,90 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $this->assertTrue(isset($config['forcessl']));
     }
 
-    /*
-     * Test create tool type failure from non existant file
+    /**
+     * Test create_tool_type failure from non existent file.
      */
     public function test_mod_lti_create_tool_type_nonexistant_file() {
-        $this->expectException('moodle_exception');
-        $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/doesntexist.xml'), '', '');
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/doesntexist.xml'), '', '');
     }
 
-    /*
-     * Test create tool type failure from xml that is not a cartridge
+    /**
+     * Test create_tool_type failure from xml that is not a cartridge.
      */
     public function test_mod_lti_create_tool_type_bad_file() {
-        $this->expectException('moodle_exception');
-        $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/rsstest.xml'), '', '');
+        $this->expectException(moodle_exception::class);
+        mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/rsstest.xml'), '', '');
     }
 
-    /*
-     * Test creating of tool types without sufficient capability
+    /**
+     * Test create_tool_type as a user without the required capability.
      */
     public function test_mod_lti_create_tool_type_without_capability() {
-        self::setUser($this->teacher);
-        $this->expectException('required_capability_exception');
-        $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
     }
 
-    /*
-     * Test update tool type
+    /**
+     * Test update_tool_type.
      */
     public function test_mod_lti_update_tool_type() {
+        $this->setAdminUser();
         $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
+        $type = external_api::clean_returnvalue(mod_lti_external::create_tool_type_returns(), $type);
+
         $type = mod_lti_external::update_tool_type($type['id'], 'New name', 'New description', LTI_TOOL_STATE_PENDING);
+        $type = external_api::clean_returnvalue(mod_lti_external::update_tool_type_returns(), $type);
+
         $this->assertEquals('New name', $type['name']);
         $this->assertEquals('New description', $type['description']);
         $this->assertEquals('Pending', $type['state']['text']);
     }
 
-    /*
-     * Test delete tool type
+    /**
+     * Test delete_tool_type for a user with the required capability.
      */
     public function test_mod_lti_delete_tool_type() {
+        $this->setAdminUser();
         $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
+        $type = external_api::clean_returnvalue(mod_lti_external::create_tool_type_returns(), $type);
         $this->assertNotEmpty(lti_get_type($type['id']));
+
         $type = mod_lti_external::delete_tool_type($type['id']);
+        $type = external_api::clean_returnvalue(mod_lti_external::delete_tool_type_returns(), $type);
         $this->assertEmpty(lti_get_type($type['id']));
     }
 
-    /*
-     * Test delete tool type without sufficient capability
+    /**
+     * Test delete_tool_type for a user without the required capability.
      */
     public function test_mod_lti_delete_tool_type_without_capability() {
+        $this->setAdminUser();
         $type = mod_lti_external::create_tool_type($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'), '', '');
+        $type = external_api::clean_returnvalue(mod_lti_external::create_tool_type_returns(), $type);
         $this->assertNotEmpty(lti_get_type($type['id']));
-        $this->expectException('required_capability_exception');
-        self::setUser($this->teacher);
-        $type = mod_lti_external::delete_tool_type($type['id']);
+
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        mod_lti_external::delete_tool_type($type['id']);
     }
 
-    /*
-     * Test is cartridge
+    /**
+     * Test is_cartridge.
      */
     public function test_mod_lti_is_cartridge() {
+        $this->setAdminUser();
         $result = mod_lti_external::is_cartridge($this->getExternalTestFileUrl('/ims_cartridge_basic_lti_link.xml'));
+        $result = external_api::clean_returnvalue(mod_lti_external::is_cartridge_returns(), $result);
         $this->assertTrue($result['iscartridge']);
+
         $result = mod_lti_external::is_cartridge($this->getExternalTestFileUrl('/test.html'));
+        $result = external_api::clean_returnvalue(mod_lti_external::is_cartridge_returns(), $result);
         $this->assertFalse($result['iscartridge']);
     }
 }
index 1969ad3..ffbf932 100644 (file)
@@ -39,7 +39,7 @@
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="quizid" TYPE="foreign-unique" FIELDS="quizid" REFTABLE="quiz" REFFIELDS="id"/>
         <KEY NAME="cmid" TYPE="foreign-unique" FIELDS="cmid" REFTABLE="course_modules" REFFIELDS="id"/>
-        <KEY NAME="templateid" TYPE="foreign" FIELDS="templateid" REFTABLE="quizacces_seb_template" REFFIELDS="id"/>
+        <KEY NAME="templateid" TYPE="foreign" FIELDS="templateid" REFTABLE="quizaccess_seb_template" REFFIELDS="id"/>
         <KEY NAME="usermodified" TYPE="foreign" FIELDS="usermodified" REFTABLE="user" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
index 7330c3e..786c6ef 100644 (file)
@@ -562,7 +562,7 @@ function quiz_user_complete($course, $user, $mod, $quiz) {
                     echo get_string('hidden', 'grades');
                 }
             }
-            echo ' - '.userdate($attempt->timemodified).'<br />';
+            echo ' - '.userdate($attempt->timefinish).'<br />';
         }
     } else {
         print_string('noattempts', 'quiz');
index b69913b..668d27f 100644 (file)
@@ -63,20 +63,19 @@ class behat_workshopallocation_manual extends behat_base {
             $selectnode = $this->find('xpath', $xpathselect);
         }
 
-        $selectformfield = behat_field_manager::get_form_field($selectnode, $this->getSession());
-        $selectformfield->set_value($reviewername);
+        $this->execute('behat_forms::set_field_node_value', [
+            $selectnode,
+            $reviewername,
+        ]);
 
         if (!$this->running_javascript()) {
             // Without Javascript we need to press the "Go" button.
             $go = behat_context_helper::escape(get_string('go'));
             $this->find('xpath', $xpathtd."/descendant::input[@value=$go]")->click();
-        } else {
-            // With Javascript we just wait for the page to reload.
-            $this->getSession()->wait(behat_base::get_extended_timeout(), self::PAGE_READY_JS);
         }
+
         // Check the success string to appear.
-        $allocatedtext = behat_context_helper::escape(
-            get_string('allocationadded', 'workshopallocation_manual'));
+        $allocatedtext = behat_context_helper::escape(get_string('allocationadded', 'workshopallocation_manual'));
         $this->find('xpath', "//*[contains(.,$allocatedtext)]");
     }
 
@@ -88,8 +87,7 @@ class behat_workshopallocation_manual extends behat_base {
      * @param TableNode $table should have one column with title 'Reviewer' and another with title 'Participant' (or 'Reviewee')
      */
     public function i_allocate_submissions_in_workshop_as($workshopname, TableNode $table) {
-
-        $this->find_link($workshopname)->click();
+        $this->execute('behat_general::i_click_on', [$workshopname, 'link']);
         $this->execute('behat_navigation::i_navigate_to_in_current_page_administration', get_string('allocate', 'workshop'));
         $rows = $table->getRows();
         $reviewer = $participant = null;
@@ -108,8 +106,15 @@ class behat_workshopallocation_manual extends behat_base {
         if ($participant === null) {
             throw new ElementTextException('Neither "Participant" nor "Reviewee" column could be located', $this->getSession());
         }
+
         for ($i = 1; $i < count($rows); $i++) {
-            $this->i_add_a_reviewer_for_workshop_participant($rows[$i][$reviewer], $rows[$i][$participant]);
+            $this->execute(
+                'behat_workshopallocation_manual::i_add_a_reviewer_for_workshop_participant',
+                [
+                    $rows[$i][$reviewer],
+                    $rows[$i][$participant],
+                ]
+            );
         }
     }
 }
index 04cabba..9a74a13 100644 (file)
@@ -68,8 +68,7 @@ class behat_qtype_ddmarker extends behat_base {
         // DOM node so that its centre is over the centre of anothe DOM node.
         // Therefore to make it drag to the specified place, we have to add
         // a target div.
-        $session = $this->getSession();
-        $session->executeScript("
+        $this->execute_script("
                 (function() {
                     if (document.getElementById('target-{$x}-{$y}')) {
                         return;
@@ -86,7 +85,8 @@ class behat_qtype_ddmarker extends behat_base {
                     target.style.setProperty('top', yadjusted + 'px');
                     target.style.setProperty('width', '1px');
                     target.style.setProperty('height', '1px');
-                }())");
+                }())"
+        );
 
         $generalcontext = behat_context_helper::get('behat_general');
         $generalcontext->i_drag_and_i_drop_it_in($this->marker_xpath($marker),
index c7ca947..ac11c88 100644 (file)
@@ -51,7 +51,7 @@ class behat_search extends behat_base {
         $this->execute('behat_forms::i_set_the_field_to', ['q', $query]);
 
         // Submit the form.
-        $this->getSession()->executeScript('document.querySelector(".search-input-form.expanded").submit();');
+        $this->execute_script('return document.querySelector(".search-input-form.expanded").submit();');
     }
 
     /**
index 4f10a37..00b1583 100644 (file)
@@ -2201,6 +2201,7 @@ h3.sectionname .inplaceeditable.inplaceeditingon .editinstructions {
 // Reset for ul.
 ul {
     padding-left: 1rem;
+    -webkit-margin-start: 0.2rem;  /* stylelint-disable-line */
 }
 
 /* YUI 2 Tree View */
index 8388761..fff7cd4 100644 (file)
@@ -651,15 +651,25 @@ $message-day-color: color-yiq($message-app-bg) !default;
         overflow-y: auto;
     }
 }
-#page-message-index #region-main {
-    height: 100%;
-    div[role="main"] {
+#page-message-index {
+    #page-header {
+        display: none;
+    }
+    #region-main {
         height: 100%;
-        #maincontent {
-            margin-top: -1px;
+        margin-top: 0;
+        .conversationcontainer {
+            max-height: calc(100vh - 50px);
+            overflow: auto;
         }
-        .message-app.main {
+        div[role="main"] {
             height: 100%;
+            #maincontent {
+                margin-top: -1px;
+            }
+            .message-app.main {
+                height: 100%;
+            }
         }
     }
 }
index a6e59bb..fb86dfc 100644 (file)
@@ -11415,7 +11415,9 @@ h3.sectionname .inplaceeditable.inplaceeditingon .editinstructions {
     display: block; }
 
 ul {
-  padding-left: 1rem; }
+  padding-left: 1rem;
+  -webkit-margin-start: 0.2rem;
+  /* stylelint-disable-line */ }
 
 /* YUI 2 Tree View */
 /*rtl:raw:
@@ -15316,8 +15318,15 @@ a.ygtvspacer:hover {
   .message-app .lazy-load-list {
     overflow-y: auto; }
 
+#page-message-index #page-header {
+  display: none; }
+
 #page-message-index #region-main {
-  height: 100%; }
+  height: 100%;
+  margin-top: 0; }
+  #page-message-index #region-main .conversationcontainer {
+    max-height: calc(100vh - 50px);
+    overflow: auto; }
   #page-message-index #region-main div[role="main"] {
     height: 100%; }
     #page-message-index #region-main div[role="main"] #maincontent {
index c484fc7..b3c0961 100644 (file)
@@ -11626,7 +11626,9 @@ h3.sectionname .inplaceeditable.inplaceeditingon .editinstructions {
     display: block; }
 
 ul {
-  padding-left: 1rem; }
+  padding-left: 1rem;
+  -webkit-margin-start: 0.2rem;
+  /* stylelint-disable-line */ }
 
 /* YUI 2 Tree View */
 /*rtl:raw:
@@ -15534,8 +15536,15 @@ a.ygtvspacer:hover {
   .message-app .lazy-load-list {
     overflow-y: auto; }
 
+#page-message-index #page-header {
+  display: none; }
+
 #page-message-index #region-main {
-  height: 100%; }
+  height: 100%;
+  margin-top: 0; }
+  #page-message-index #region-main .conversationcontainer {
+    max-height: calc(100vh - 50px);
+    overflow: auto; }
   #page-message-index #region-main div[role="main"] {
     height: 100%; }
     #page-message-index #region-main div[role="main"] #maincontent {
index fc84fcc..7bf05da 100644 (file)
@@ -89,6 +89,7 @@ $bulkoperations = has_capability('moodle/course:bulkmessaging', $context);
 $PAGE->set_title("$course->shortname: ".get_string('participants'));
 $PAGE->set_heading($course->fullname);
 $PAGE->set_pagetype('course-view-' . $course->format);
+$PAGE->set_docs_path('enrol/users');
 $PAGE->add_body_class('path-user');                     // So we can style it independently.
 $PAGE->set_other_editing_capability('moodle/course:manageactivities');
 
diff --git a/user/tests/behat/table_column_visibility.feature b/user/tests/behat/table_column_visibility.feature
new file mode 100644 (file)
index 0000000..f7dd23b
--- /dev/null
@@ -0,0 +1,49 @@
+@core @core_user
+Feature: The visibility of table columns can be toggled
+  In order to customise my view of participants data
+  As a user
+  I need to be able to hide and show columns in the participants table
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email               |
+      | t1       | Agatha    | T        | agatha@example.com  |
+      | s1       | Matilda   | W        | matilda@example.com |
+      | s2       | Mick      | H        | mick@example.com    |
+    And the following "course enrolments" exist:
+      | user | course | role           |
+      | t1   | C1     | editingteacher |
+      | s1   | C1     | student        |
+      | s2   | C1     | student        |
+
+  @javascript
+  Scenario: The visibility of columns can be individually toggled within the participants table
+    Given I log in as "t1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I should see "Email address" in the "participants" "table"
+    And I should see "matilda@example.com" in the "participants" "table"
+    And I should see "Roles" in the "participants" "table"
+    And I should see "Student" in the "participants" "table"
+    When I follow "Hide Email address"
+    Then I should not see "Email address" in the "participants" "table"
+    And I should not see "matilda@example.com" in the "participants" "table"
+    And I should see "Roles" in the "participants" "table"
+    And I should see "Student" in the "participants" "table"
+    And I follow "Hide Roles"
+    And I should not see "Roles" in the "participants" "table"
+    And I should not see "Student" in the "participants" "table"
+    And I should not see "matilda@example.com" in the "participants" "table"
+    And I follow "Show Email address"
+    And I should see "Email address" in the "participants" "table"
+    And I should see "matilda@example.com" in the "participants" "table"
+    And I should not see "Roles" in the "participants" "table"
+    And I should not see "Student" in the "participants" "table"
+    And I follow "Show Roles"
+    And I should see "Roles" in the "participants" "table"
+    And I should see "Student" in the "participants" "table"
+    And I should see "Email address" in the "participants" "table"
+    And I should see "matilda@example.com" in the "participants" "table"
index 96548be..322c6db 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020061501.02;              // 20200615      = branching date YYYYMMDD - do not modify!
+$version  = 2020061501.05;              // 20200615      = branching date YYYYMMDD - do not modify!
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9.1+ (Build: 20200717)'; // Human-friendly version name
+$release  = '3.9.1+ (Build: 20200730)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_STABLE;             // This version's maturity level.