Merge branch 'MDL-65451' of https://github.com/marinaglancy/moodle into master
authorSara Arjona <sara@moodle.com>
Wed, 7 Oct 2020 07:39:46 +0000 (09:39 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 7 Oct 2020 07:39:46 +0000 (09:39 +0200)
55 files changed:
.travis.yml
admin/settings/location.php
admin/tests/behat/invalid_allcountrycodes.feature [new file with mode: 0644]
admin/tool/replace/classes/form.php
admin/tool/replace/cli/replace.php
admin/tool/replace/index.php
admin/tool/replace/lang/en/tool_replace.php
course/classes/local/service/content_item_service.php
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
install/lang/sv/admin.php
lang/en/error.php
lang/en/moodle.php
lib/adminlib.php
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/src/notification.js
lib/classes/notification.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/classes/privacy/provider.php
lib/classes/string_manager_standard.php
lib/db/install.xml
lib/db/upgrade.php
lib/filelib.php
lib/moodlelib.php
lib/outputrenderers.php
lib/tests/adminlib_test.php
lib/tests/notification_test.php
lib/tests/string_manager_standard_test.php
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/build/grading_panel.min.js.map
mod/assign/amd/src/grading_panel.js
mod/data/index.php
mod/data/tests/behat/data_activities.feature [new file with mode: 0644]
mod/forum/templates/inpage_reply.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/lesson/essay.php
mod/lesson/locallib.php
mod/lesson/mod_form.php
mod/lesson/pagetypes/essay.php
mod/lesson/pagetypes/matching.php
mod/lesson/pagetypes/multichoice.php
mod/lesson/pagetypes/numerical.php
mod/lesson/pagetypes/shortanswer.php
mod/lesson/pagetypes/truefalse.php
mod/lesson/tests/behat/lesson_navigation.feature
mod/lesson/tests/locallib_test.php
mod/quiz/renderer.php
repository/googledocs/lib.php
repository/nextcloud/lib.php
repository/onedrive/lib.php
version.php

index 27ee681..7b533fe 100644 (file)
@@ -2,10 +2,9 @@
 # process (which uses our internal CI system) this file is here for the benefit
 # of community developers git clones - see MDL-51458.
 
-# We currently disable Travis notifications entirely until https://github.com/travis-ci/travis-ci/issues/4976
-# is fixed.
 notifications:
-  email: false
+  email:
+    if: env(MOODLE_EMAIL) != no
 
 language: php
 
index 504cbb9..5a4cc90 100644 (file)
@@ -1,21 +1,57 @@
 <?php
+// This file is part of Moodle - https://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/>.
 
-if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
+/**
+ * Define administration settings on the Location settings page.
+ *
+ * @package     core
+ * @category    admin
+ * @copyright   2006 Martin Dougiamas <martin@moodle.com>
+ * @license     https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 
-    // "locations" settingpage
-    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'admin'));
-    $temp->add(new admin_setting_servertimezone());
-    $temp->add(new admin_setting_forcetimezone());
-    $temp->add(new admin_settings_country_select('country', new lang_string('country', 'admin'), new lang_string('configcountry', 'admin'), 0));
-    $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'admin'), new lang_string('defaultcity_help', 'admin'), ''));
+defined('MOODLE_INTERNAL') || die();
 
-    $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'admin'), new lang_string('iplookupinfo', 'admin')));
-    $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'admin'),
-        new lang_string('configgeoipfile', 'admin', $CFG->dataroot.'/geoip/'), $CFG->dataroot.'/geoip/GeoLite2-City.mmdb'));
-    $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'admin'), new lang_string('googlemapkey3_help', 'admin'), '', PARAM_RAW, 60));
+if ($hassiteconfig) {
+    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'core_admin'));
 
-    $temp->add(new admin_setting_configtext('allcountrycodes', new lang_string('allcountrycodes', 'admin'), new lang_string('configallcountrycodes', 'admin'), '', '/^(?:\w+(?:,\w+)*)?$/'));
+    if ($ADMIN->fulltree) {
+        $temp->add(new admin_setting_servertimezone());
 
-    $ADMIN->add('location', $temp);
+        $temp->add(new admin_setting_forcetimezone());
+
+        $temp->add(new admin_settings_country_select('country', new lang_string('country', 'core_admin'),
+            new lang_string('configcountry', 'core_admin'), 0));
+
+        $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'core_admin'),
+            new lang_string('defaultcity_help', 'core_admin'), ''));
+
+        $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'core_admin'),
+            new lang_string('iplookupinfo', 'core_admin')));
 
-} // end of speedup
+        $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'core_admin'),
+            new lang_string('configgeoipfile', 'core_admin', $CFG->dataroot . '/geoip/'),
+            $CFG->dataroot . '/geoip/GeoLite2-City.mmdb'));
+
+        $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'core_admin'),
+            new lang_string('googlemapkey3_help', 'core_admin'), '', PARAM_RAW, 60));
+
+        $temp->add(new admin_setting_countrycodes('allcountrycodes', new lang_string('allcountrycodes', 'core_admin'),
+            new lang_string('configallcountrycodes', 'core_admin')));
+    }
+
+    $ADMIN->add('location', $temp);
+}
diff --git a/admin/tests/behat/invalid_allcountrycodes.feature b/admin/tests/behat/invalid_allcountrycodes.feature
new file mode 100644 (file)
index 0000000..399712f
--- /dev/null
@@ -0,0 +1,29 @@
+@core @core_admin
+Feature: Administrator is warned and when trying to set invalid allcountrycodes value.
+  In order to avoid misconfiguration of the country selector fields
+  As an admin
+  I want to be warned when I try to set an invalid country code in the allcountrycodes field
+
+  Scenario: Attempting to set allcountrycodes field with valid country codes
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    Then I should not see "Invalid country code"
+
+  Scenario: Attempting to set allcountrycodes field with invalid country code
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,FOOBAR,GB,ES |
+    Then I should see "Invalid country code: FOOBAR"
+
+  Scenario: Attempting to unset allcountrycodes field
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    And I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | |
+    Then I should not see "Invalid country code"
index 5c60510..d42fbe2 100644 (file)
@@ -46,6 +46,13 @@ class tool_replace_form extends moodleform {
         $mform->addElement('text', 'replace', get_string('replacewith', 'tool_replace'), 'size="50"', PARAM_RAW);
         $mform->addElement('static', 'replacest', '', get_string('replacewithhelp', 'tool_replace'));
         $mform->setType('replace', PARAM_RAW);
+
+        $mform->addElement('textarea', 'additionalskiptables', get_string("additionalskiptables", "tool_replace"),
+            array('rows' => 5, 'cols' => 50));
+        $mform->addElement('static', 'additionalskiptables_desc', '', get_string('additionalskiptables_desc', 'tool_replace'));
+        $mform->setType('additionalskiptables', PARAM_RAW);
+        $mform->setDefault('additionalskiptables', '');
+
         $mform->addElement('checkbox', 'shorten', get_string('shortenoversized', 'tool_replace'));
         $mform->addRule('replace', get_string('required'), 'required', null, 'client');
 
index 1f9e74b..b4244aa 100644 (file)
@@ -34,6 +34,7 @@ $help =
 Options:
 --search=STRING       String to search for.
 --replace=STRING      String to replace with.
+--skiptables=STRING   Skip these tables (comma separated list of tables).
 --shorten             Shorten result if necessary.
 --non-interactive     Perform the replacement without confirming.
 -h, --help            Print out this help.
@@ -46,6 +47,7 @@ list($options, $unrecognized) = cli_get_params(
     array(
         'search'  => null,
         'replace' => null,
+        'skiptables' => '',
         'shorten' => false,
         'non-interactive' => false,
         'help'    => false,
@@ -71,6 +73,7 @@ if (empty($options['shorten']) && core_text::strlen($options['search']) < core_t
 try {
     $search = validate_param($options['search'], PARAM_RAW);
     $replace = validate_param($options['replace'], PARAM_RAW);
+    $skiptables = validate_param($options['skiptables'], PARAM_RAW);
 } catch (invalid_parameter_exception $e) {
     cli_error(get_string('invalidcharacter', 'tool_replace'));
 }
@@ -85,7 +88,7 @@ if (!$options['non-interactive']) {
     }
 }
 
-if (!db_replace($search, $replace)) {
+if (!db_replace($search, $replace, $skiptables)) {
     cli_heading(get_string('error'));
     exit(1);
 }
index b3013d2..e4f2900 100644 (file)
@@ -57,7 +57,7 @@ if (!$data = $form->get_data()) {
 $PAGE->requires->js_init_code("window.scrollTo(0, 5000000);");
 
 echo $OUTPUT->box_start();
-db_replace($data->search, $data->replace);
+db_replace($data->search, $data->replace, $data->additionalskiptables);
 echo $OUTPUT->box_end();
 
 // Course caches are now rebuilt on the fly.
index 6117521..e8dce48 100644 (file)
@@ -22,7 +22,8 @@
  * @copyright  2011 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-
+$string['additionalskiptables'] = 'Additional skip tables';
+$string['additionalskiptables_desc'] = 'Please specify the additional tables (comma separated list) you want to skip while running DB search and replace.';
 $string['cannotfit'] = 'The replacement is longer than the original and shortening is not allowed; cannot continue.';
 $string['disclaimer'] = 'I understand the risks of this operation';
 $string['doit'] = 'Yes, do it!';
index 20209ae..ca60efb 100644 (file)
@@ -138,8 +138,12 @@ class content_item_service {
             // Add any subplugins to the list of item types.
             $subplugins = $pluginmanager->get_subplugins_of_plugin('mod_' . $plugin->name);
             foreach ($subplugins as $subpluginname => $subplugininfo) {
-                if (component_callback_exists($subpluginname, 'get_course_content_items')) {
-                    $itemtypes[] = $prefix . $subpluginname;
+                try {
+                    if (component_callback_exists($subpluginname, 'get_course_content_items')) {
+                        $itemtypes[] = $prefix . $subpluginname;
+                    }
+                } catch (\moodle_exception $e) {
+                    debugging('Cannot get_course_content_items: ' . $e->getMessage(), DEBUG_DEVELOPER);
                 }
             }
         }
index ac64324..1f631b2 100644 (file)
@@ -611,6 +611,7 @@ class core_course_external extends external_api {
                     $courseinfo['customfields'][] = [
                         'type' => $data->get_type(),
                         'value' => $data->get_value(),
+                        'valueraw' => $data->get_data_controller()->get_value(),
                         'name' => $data->get_name(),
                         'shortname' => $data->get_shortname()
                     ];
@@ -735,6 +736,7 @@ class core_course_external extends external_api {
                                      'shortname' => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
                                      'type'  => new external_value(PARAM_COMPONENT,
                                          'The type of the custom field - text, checkbox...'),
+                                     'valueraw' => new external_value(PARAM_RAW, 'The raw value of the custom field'),
                                      'value' => new external_value(PARAM_RAW, 'The value of the custom field')]
                                 ), 'Custom fields and associated values', VALUE_OPTIONAL),
                         ), 'course'
@@ -2489,6 +2491,7 @@ class core_course_external extends external_api {
                 $coursereturns['customfields'][] = [
                     'type' => $data->get_type(),
                     'value' => $data->get_value(),
+                    'valueraw' => $data->get_data_controller()->get_value(),
                     'name' => $data->get_name(),
                     'shortname' => $data->get_shortname()
                 ];
@@ -2640,6 +2643,7 @@ class core_course_external extends external_api {
                             'The shortname of the custom field - to be able to build the field class in the code'),
                         'type'  => new external_value(PARAM_ALPHANUMEXT,
                             'The type of the custom field - text field, checkbox...'),
+                        'valueraw' => new external_value(PARAM_RAW, 'The raw value of the custom field'),
                         'value' => new external_value(PARAM_RAW, 'The value of the custom field'),
                     )
                 ), 'Custom fields', VALUE_OPTIONAL),
index fcf938b..dcd48ee 100644 (file)
@@ -775,8 +775,16 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                     array('name' => 'coursedisplay', 'value' => $dbcourse->coursedisplay),
                 ));
             }
-            if ($dbcourse->id == 4) {
-                $this->assertEquals($course['customfields'], [array_merge($customfield, $customfieldvalue)]);
+
+            // Assert custom field that we previously added to test course 4.
+            if ($dbcourse->id == $course4->id) {
+                $this->assertEquals([
+                    'shortname' => $customfield['shortname'],
+                    'name' => $customfield['name'],
+                    'type' => $customfield['type'],
+                    'value' => $customfieldvalue['value'],
+                    'valueraw' => $customfieldvalue['value'],
+                ], $course['customfields'][0]);
             }
         }
 
@@ -789,6 +797,49 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals($DB->count_records('course'), count($courses));
     }
 
+    /**
+     * Test retrieving courses returns custom field data
+     */
+    public function test_get_courses_customfields(): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $fieldcategory = $this->getDataGenerator()->create_custom_field_category([]);
+        $datefield = $this->getDataGenerator()->create_custom_field([
+            'categoryid' => $fieldcategory->get('id'),
+            'shortname' => 'mydate',
+            'name' => 'My date',
+            'type' => 'date',
+        ]);
+
+        $newcourse = $this->getDataGenerator()->create_course(['customfields' => [
+            [
+                'shortname' => $datefield->get('shortname'),
+                'value' => 1580389200, // 30/01/2020 13:00 GMT.
+            ],
+        ]]);
+
+        $courses = external_api::clean_returnvalue(
+            core_course_external::get_courses_returns(),
+            core_course_external::get_courses(['ids' => [$newcourse->id]])
+        );
+
+        $this->assertCount(1, $courses);
+        $course = reset($courses);
+
+        $this->assertArrayHasKey('customfields', $course);
+        $this->assertCount(1, $course['customfields']);
+
+        // Assert the received custom field, "value" containing a human-readable version and "valueraw" the unmodified version.
+        $this->assertEquals([
+            'name' => $datefield->get('name'),
+            'shortname' => $datefield->get('shortname'),
+            'type' => $datefield->get('type'),
+            'value' => userdate(1580389200),
+            'valueraw' => 1580389200,
+        ], reset($course['customfields']));
+    }
+
     /**
      * Test get_courses without capability
      */
@@ -912,6 +963,49 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $results = core_course_external::search_courses('blocklist', $blockid);
     }
 
+    /**
+     * Test searching for courses returns custom field data
+     */
+    public function test_search_courses_customfields(): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $fieldcategory = $this->getDataGenerator()->create_custom_field_category([]);
+        $datefield = $this->getDataGenerator()->create_custom_field([
+            'categoryid' => $fieldcategory->get('id'),
+            'shortname' => 'mydate',
+            'name' => 'My date',
+            'type' => 'date',
+        ]);
+
+        $newcourse = $this->getDataGenerator()->create_course(['customfields' => [
+            [
+                'shortname' => $datefield->get('shortname'),
+                'value' => 1580389200, // 30/01/2020 13:00 GMT.
+            ],
+        ]]);
+
+        $result = external_api::clean_returnvalue(
+            core_course_external::search_courses_returns(),
+            core_course_external::search_courses('search', $newcourse->shortname)
+        );
+
+        $this->assertCount(1, $result['courses']);
+        $course = reset($result['courses']);
+
+        $this->assertArrayHasKey('customfields', $course);
+        $this->assertCount(1, $course['customfields']);
+
+        // Assert the received custom field, "value" containing a human-readable version and "valueraw" the unmodified version.
+        $this->assertEquals([
+            'name' => $datefield->get('name'),
+            'shortname' => $datefield->get('shortname'),
+            'type' => $datefield->get('type'),
+            'value' => userdate(1580389200),
+            'valueraw' => 1580389200,
+        ], reset($course['customfields']));
+    }
+
     /**
      * Create a course with contents
      * @return array A list with the course object and course modules objects
@@ -2453,8 +2547,13 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(1, $result['courses']);
         $this->assertEquals($course2->id, $result['courses'][0]['id']);
         // Check custom fields properly returned.
-        unset($customfield['categoryid']);
-        $this->assertEquals([array_merge($customfield, $customfieldvalue)], $result['courses'][0]['customfields']);
+        $this->assertEquals([
+            'shortname' => $customfield['shortname'],
+            'name' => $customfield['name'],
+            'type' => $customfield['type'],
+            'value' => $customfieldvalue['value'],
+            'valueraw' => $customfieldvalue['value'],
+        ], $result['courses'][0]['customfields'][0]);
 
         $result = core_course_external::get_courses_by_field('ids', "$course1->id,$course2->id");
         $result = external_api::clean_returnvalue(core_course_external::get_courses_by_field_returns(), $result);
@@ -2583,6 +2682,49 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(0, $result['courses']);
     }
 
+    /**
+     * Test retrieving courses by field returns custom field data
+     */
+    public function test_get_courses_by_field_customfields(): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $fieldcategory = $this->getDataGenerator()->create_custom_field_category([]);
+        $datefield = $this->getDataGenerator()->create_custom_field([
+            'categoryid' => $fieldcategory->get('id'),
+            'shortname' => 'mydate',
+            'name' => 'My date',
+            'type' => 'date',
+        ]);
+
+        $newcourse = $this->getDataGenerator()->create_course(['customfields' => [
+            [
+                'shortname' => $datefield->get('shortname'),
+                'value' => 1580389200, // 30/01/2020 13:00 GMT.
+            ],
+        ]]);
+
+        $result = external_api::clean_returnvalue(
+            core_course_external::get_courses_by_field_returns(),
+            core_course_external::get_courses_by_field('id', $newcourse->id)
+        );
+
+        $this->assertCount(1, $result['courses']);
+        $course = reset($result['courses']);
+
+        $this->assertArrayHasKey('customfields', $course);
+        $this->assertCount(1, $course['customfields']);
+
+        // Assert the received custom field, "value" containing a human-readable version and "valueraw" the unmodified version.
+        $this->assertEquals([
+            'name' => $datefield->get('name'),
+            'shortname' => $datefield->get('shortname'),
+            'type' => $datefield->get('type'),
+            'value' => userdate(1580389200),
+            'valueraw' => 1580389200,
+        ], reset($course['customfields']));
+    }
+
     public function test_get_courses_by_field_invalid_field() {
         $this->expectException('invalid_parameter_exception');
         $result = core_course_external::get_courses_by_field('zyx', 'x');
index e56837d..69bf923 100644 (file)
@@ -5,6 +5,8 @@ information provided here is intended especially for developers.
 
 * The function make_categories_options() has now been deprecated. Please use \core_course_category::make_categories_list() instead.
 * External function core_course_external::get_course_contents now returns a new field contextid with the module context id.
+* The core_course_external class methods get_courses(), get_courses_by_field() and search_courses() now return a "valueraw" property
+  for each custom course field, which contains the original/unformatted version of the custom field value.
 
 === 3.9 ===
 
index 31ba295..26f5aeb 100644 (file)
@@ -33,10 +33,10 @@ defined('MOODLE_INTERNAL') || die();
 $string['clianswerno'] = 'n';
 $string['cliansweryes'] = 'y';
 $string['cliincorrectvalueerror'] = 'Fel, värdet "{$a->value}" för "{$a->option}" är inte korrekt.';
-$string['cliincorrectvalueretry'] = 'Felaktigt värde, var snäll och försök igen';
+$string['cliincorrectvalueretry'] = 'Felaktigt värde, v.g. försök igen';
 $string['clitypevalue'] = 'Värde för typ';
 $string['clitypevaluedefault'] = 'skriv in värdet, klicka på "Enter" om Du vill använda standardvärdet ({$a})';
-$string['cliunknowoption'] = 'Ej identifierade alternativ: {$a} Var snäll och använd alternativet Hjälp.';
+$string['cliunknowoption'] = 'Ej identifierade alternativ: {$a} V.g. använd alternativet Hjälp.';
 $string['cliyesnoprompt'] = 'skriv in y (betyder ja) eller n (betyder nej)';
 $string['environmentrequireinstall'] = 'är nödvändig att installera/aktivera';
 $string['environmentrequireversion'] = 'version {$a->needed} är nödvändig och Du använder {$a->current}';
index 828a2f5..7530ded 100644 (file)
@@ -324,6 +324,7 @@ $string['invalidcourselevel'] = 'Incorrect context level';
 $string['invalidcourseformat'] = 'Invalid course format';
 $string['invalidcoursemodule'] = 'Invalid course module ID';
 $string['invalidcoursenameshort'] = 'Invalid short course name';
+$string['invalidcountrycode'] = 'Invalid country code: {$a}';
 $string['invaliddata'] = 'Data submitted is invalid';
 $string['invaliddatarootpermissions'] = 'Invalid permissions detected when trying to create a directory. Turn debugging on for further details.';
 $string['invaliddevicetype'] = 'Invalid device type';
index bf203b5..41b2cee 100644 (file)
@@ -1630,6 +1630,13 @@ $string['privacy:metadata:log:module'] = 'module';
 $string['privacy:metadata:log:time'] = 'The time when the action took place';
 $string['privacy:metadata:log:url'] = 'The URL related to the event';
 $string['privacy:metadata:log:userid'] = 'The ID of the user who performed the action';
+$string['privacy:metadata:oauth2_refresh_token'] = 'Refresh token used in OAuth 2.0 communication';
+$string['privacy:metadata:oauth2_refresh_token:issuerid'] = 'The ID of the issuer to which the token corresponds';
+$string['privacy:metadata:oauth2_refresh_token:scopehash'] = 'The ID of the user to whom the token corresponds';
+$string['privacy:metadata:oauth2_refresh_token:token'] = 'The refresh token for the respective scopes and user';
+$string['privacy:metadata:oauth2_refresh_token:timecreated'] = 'The time when the token was created';
+$string['privacy:metadata:oauth2_refresh_token:timemodified'] = 'The time when the token was last updated';
+$string['privacy:metadata:oauth2_refresh_token:userid'] = 'The ID of the user to whom the token corresponds';
 $string['privacy:metadata:task_adhoc'] = 'The status of ad hoc tasks.';
 $string['privacy:metadata:task_adhoc:component'] = 'The component owning the task.';
 $string['privacy:metadata:task_adhoc:nextruntime'] = 'The earliest time to run this task.';
index e820668..bfdd787 100644 (file)
@@ -4947,6 +4947,66 @@ class admin_setting_langlist extends admin_setting_configtext {
 }
 
 
+/**
+ * Allows to specify comma separated list of known country codes.
+ *
+ * This is a simple subclass of the plain input text field with added validation so that all the codes are actually
+ * known codes.
+ *
+ * @package     core
+ * @category    admin
+ * @copyright   2020 David Mudrák <david@moodle.com>
+ * @license     https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_countrycodes extends admin_setting_configtext {
+
+    /**
+     * Construct the instance of the setting.
+     *
+     * @param string $name Name of the admin setting such as 'allcountrycodes' or 'myplugin/countries'.
+     * @param lang_string|string $visiblename Language string with the field label text.
+     * @param lang_string|string $description Language string with the field description text.
+     * @param string $defaultsetting Default value of the setting.
+     * @param int $size Input text field size.
+     */
+    public function __construct($name, $visiblename, $description, $defaultsetting = '', $size = null) {
+        parent::__construct($name, $visiblename, $description, $defaultsetting, '/^(?:\w+(?:,\w+)*)?$/', $size);
+    }
+
+    /**
+     * Validate the setting value before storing it.
+     *
+     * The value is first validated through custom regex so that it is a word consisting of letters, numbers or underscore; or
+     * a comma separated list of such words.
+     *
+     * @param string $data Value inserted into the setting field.
+     * @return bool|string True if the value is OK, error string otherwise.
+     */
+    public function validate($data) {
+
+        $parentcheck = parent::validate($data);
+
+        if ($parentcheck !== true) {
+            return $parentcheck;
+        }
+
+        if ($data === '') {
+            return true;
+        }
+
+        $allcountries = get_string_manager()->get_list_of_countries(true);
+
+        foreach (explode(',', $data) as $code) {
+            if (!isset($allcountries[$code])) {
+                return get_string('invalidcountrycode', 'core_error', $code);
+            }
+        }
+
+        return true;
+    }
+}
+
+
 /**
  * Selection of one of the recognised countries using the list
  * returned by {@link get_list_of_countries()}.
@@ -9069,12 +9129,17 @@ function any_new_admin_settings($node) {
  * @param string $column name
  * @return bool success or fail
  */
-function db_should_replace($table, $column = ''): bool {
+function db_should_replace($table, $column = '', $additionalskiptables = ''): bool {
 
     // TODO: this is horrible hack, we should have a hook and each plugin should be responsible for proper replacing...
     $skiptables = ['config', 'config_plugins', 'filter_config', 'sessions',
         'events_queue', 'repository_instance_config', 'block_instances', 'files'];
 
+    // Additional skip tables.
+    if (!empty($additionalskiptables)) {
+        $skiptables = array_merge($skiptables, explode(',', str_replace(' ', '',  $additionalskiptables)));
+    }
+
     // Don't process these.
     if (in_array($table, $skiptables)) {
         return false;
@@ -9103,7 +9168,7 @@ function db_should_replace($table, $column = ''): bool {
  * @param string $replace string to replace
  * @return bool success or fail
  */
-function db_replace($search, $replace) {
+function db_replace($search, $replace, $additionalskiptables = '') {
     global $DB, $CFG, $OUTPUT;
 
     // Turn off time limits, sometimes upgrades can be slow.
@@ -9114,7 +9179,7 @@ function db_replace($search, $replace) {
     }
     foreach ($tables as $table) {
 
-        if (!db_should_replace($table)) {
+        if (!db_should_replace($table, '', $additionalskiptables)) {
             continue;
         }
 
index 42cdec9..1f774b7 100644 (file)
Binary files a/lib/amd/build/notification.min.js and b/lib/amd/build/notification.min.js differ
index 1d721de..9fe05c4 100644 (file)
Binary files a/lib/amd/build/notification.min.js.map and b/lib/amd/build/notification.min.js.map differ
index 62cec7e..e248b09 100644 (file)
@@ -292,9 +292,8 @@ export const exception = async ex => {
  *
  * @param {Number} contextId
  * @param {Array} notificationList
- * @param {Boolean} userLoggedIn
  */
-export const init = (contextId, notificationList, userLoggedIn) => {
+export const init = (contextId, notificationList) => {
     currentContextId = contextId;
 
     // Setup the message target region if it isn't setup already
@@ -303,11 +302,6 @@ export const init = (contextId, notificationList, userLoggedIn) => {
     // Add provided notifications.
     addNotifications(notificationList);
 
-    // If the user is not logged in then we can not fetch anything for them.
-    if (userLoggedIn) {
-        // Perform an initial poll for any new notifications.
-        fetchNotifications();
-    }
 };
 
 // To maintain backwards compatability we export default here.
index 59fceea..52b1543 100644 (file)
@@ -58,7 +58,8 @@ class notification {
     public static function add($message, $level = null) {
         global $PAGE, $SESSION;
 
-        if ($PAGE && $PAGE->state === \moodle_page::STATE_IN_BODY) {
+        if ($PAGE && ($PAGE->state === \moodle_page::STATE_IN_BODY
+           || $PAGE->state === \moodle_page::STATE_DONE)) {
             // Currently in the page body - just render and exit immediately.
             // We insert some code to immediately insert this into the user-notifications created by the header.
             $id = uniqid();
index 95d69a8..165dc40 100644 (file)
@@ -484,10 +484,12 @@ class api {
      * @param \core\oauth2\issuer $issuer The desired OAuth issuer
      * @param moodle_url $currenturl The url to the current page.
      * @param string $additionalscopes The additional scopes required for authorization.
+     * @param bool $autorefresh Should the client support the use of refresh tokens to persist access across sessions.
      * @return \core\oauth2\client
      */
-    public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
-        $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
+    public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '',
+            $autorefresh = false) {
+        $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes, false, $autorefresh);
 
         return $client;
     }
index 553e8fe..574ea94 100644 (file)
@@ -46,6 +46,9 @@ class client extends \oauth2_client {
     /** @var bool $system */
     protected $system = false;
 
+    /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
+    protected $autorefresh = false;
+
     /**
      * Constructor.
      *
@@ -53,10 +56,12 @@ class client extends \oauth2_client {
      * @param moodle_url|null $returnurl
      * @param string $scopesrequired
      * @param boolean $system
+     * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions.
      */
-    public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false) {
+    public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) {
         $this->issuer = $issuer;
         $this->system = $system;
+        $this->autorefresh = $autorefresh;
         $scopes = $this->get_login_scopes();
         $additionalscopes = explode(' ', $scopesrequired);
 
@@ -98,15 +103,22 @@ class client extends \oauth2_client {
      */
     public function get_additional_login_parameters() {
         $params = '';
-        if ($this->system) {
+
+        if ($this->system || $this->can_autorefresh()) {
+            // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
+            // extra params to the login request, depending on the issuer settings. The extra params allow a refresh
+            // token to be returned during the authorization_code flow.
             if (!empty($this->issuer->get('loginparamsoffline'))) {
                 $params = $this->issuer->get('loginparamsoffline');
             }
         } else {
+            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
+            // vanilla login params.
             if (!empty($this->issuer->get('loginparams'))) {
                 $params = $this->issuer->get('loginparams');
             }
         }
+
         if (empty($params)) {
             return [];
         }
@@ -121,9 +133,14 @@ class client extends \oauth2_client {
      * @return string
      */
     protected function get_login_scopes() {
-        if ($this->system) {
+        if ($this->system || $this->can_autorefresh()) {
+            // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
+            // extra scopes to the login request, depending on the issuer settings. The extra params allow a refresh
+            // token to be returned during the authorization_code flow.
             return $this->issuer->get('loginscopesoffline');
         } else {
+            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
+            // vanilla login scopes.
             return $this->issuer->get('loginscopes');
         }
     }
@@ -224,15 +241,148 @@ class client extends \oauth2_client {
     }
 
     /**
-     * Upgrade a refresh token from oauth 2.0 to an access token
+     * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
      *
-     * @param \core\oauth2\system_account $systemaccount
-     * @return boolean true if token is upgraded succesfully
-     * @throws moodle_exception Request for token upgrade failed for technical reasons
+     * @param string $code the authorisation code
+     * @return bool true if the token could be upgraded
+     * @throws moodle_exception
      */
-    public function upgrade_refresh_token(system_account $systemaccount) {
-        $refreshtoken = $systemaccount->get('refreshtoken');
+    public function upgrade_token($code) {
+        $upgraded = parent::upgrade_token($code);
+        if (!$this->can_autorefresh()) {
+            return $upgraded;
+        }
+
+        // For clients supporting auto-refresh, try to store a refresh token.
+        if (!empty($this->refreshtoken)) {
+            $refreshtoken = (object) [
+                'token' => $this->refreshtoken,
+                'scope' => $this->scope
+            ];
+            $this->store_user_refresh_token($refreshtoken);
+        }
+
+        return $upgraded;
+    }
+
+    /**
+     * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token.
+     *
+     * @return bool true if the user is logged in as a result, false otherwise.
+     */
+    public function is_logged_in() {
+        global $DB, $USER;
+
+        $isloggedin = parent::is_logged_in();
+
+        // Attempt to exchange a user refresh token, but only if required and supported.
+        if ($isloggedin || !$this->can_autorefresh()) {
+            return $isloggedin;
+        }
+
+        // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token.
+        $issuerid = $this->issuer->get('id');
+        $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]);
+        if ($refreshtoken) {
+            try {
+                $tokensreceived = $this->exchange_refresh_token($refreshtoken->token);
+                if (empty($tokensreceived)) {
+                    // No access token was returned, so invalidate the refresh token and return false.
+                    $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
+                    return false;
+                }
+
+                // Otherwise, save the access token and, if provided, the new refresh token.
+                $this->store_token($tokensreceived['access_token']);
+                if (!empty($tokensreceived['refresh_token'])) {
+                    $this->store_user_refresh_token($tokensreceived['refresh_token']);
+                }
+                return true;
+            } catch (\moodle_exception $e) {
+                // The refresh attempt failed either due to an error or a bad request. A bad request could be received
+                // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec),
+                // scope change or if app access has been revoked manually by the user (tokens revoked).
+                // Remove the refresh token and suppress the exception, allowing the user to be taken through the
+                // authorization_code flow again.
+                $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Whether this client should automatically exchange a refresh token for an access token as part of login checks.
+     *
+     * @return bool true if supported, false otherwise.
+     */
+    protected function can_autorefresh(): bool {
+        global $USER;
+
+        // Auto refresh is only supported when the follow criteria are met:
+        // a) The client is not a system client. The exchange process for system client refresh tokens is handled
+        // externally, via a call to client->upgrade_refresh_token().
+        // b) The user is authenticated.
+        // c) The client has been configured with autorefresh enabled.
+        return !$this->system && ($this->autorefresh && !empty($USER->id));
+    }
+
+    /**
+     * Store the user's refresh token for later use.
+     *
+     * @param stdClass $token a refresh token.
+     */
+    protected function store_user_refresh_token(stdClass $token): void {
+        global $DB, $USER;
+
+        $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id,
+            'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]);
+        $time = time();
+        if ($id) {
+            $record = [
+                'id' => $id,
+                'timemodified' => $time,
+                'token' => $token->token
+            ];
+            $DB->update_record('oauth2_refresh_token', $record);
+        } else {
+            $record = [
+                'timecreated' => $time,
+                'timemodified' => $time,
+                'userid' => $USER->id,
+                'issuerid' => $this->issuer->get('id'),
+                'token' => $token->token,
+                'scopehash' => sha1($token->scope)
+            ];
+            $DB->insert_record('oauth2_refresh_token', $record);
+        }
+    }
 
+    /**
+     * Attempt to exchange a refresh token for a new access token.
+     *
+     * If successful, will return an array of token objects in the form:
+     * Array
+     * (
+     *     [access_token] => stdClass object
+     *         (
+     *             [token] => 'the_token_string'
+     *             [expires] => 123456789
+     *             [scope] => 'openid files etc'
+     *         )
+     *     [refresh_token] => stdClass object
+     *         (
+     *             [token] => 'the_refresh_token_string'
+     *             [scope] => 'openid files etc'
+     *         )
+     *  )
+     * where the 'refresh_token' will only be provided if supplied by the auth server in the response.
+     *
+     * @param string $refreshtoken the refresh token to exchange.
+     * @return null|array array containing access token and refresh token if provided, null if the exchange was denied.
+     * @throws moodle_exception if an invalid response is received or if the response contains errors.
+     */
+    protected function exchange_refresh_token(string $refreshtoken): ?array {
         $params = array('refresh_token' => $refreshtoken,
             'grant_type' => 'refresh_token'
         );
@@ -263,24 +413,69 @@ class client extends \oauth2_client {
         }
 
         if (!isset($r->access_token)) {
-            return false;
+            return null;
         }
 
         // Store the token an expiry time.
-        $accesstoken = new stdClass;
+        $accesstoken = new stdClass();
         $accesstoken->token = $r->access_token;
         if (isset($r->expires_in)) {
             // Expires 10 seconds before actual expiry.
             $accesstoken->expires = (time() + ($r->expires_in - 10));
         }
         $accesstoken->scope = $this->scope;
-        // Also add the scopes.
-        $this->store_token($accesstoken);
+
+        $tokens = ['access_token' => $accesstoken];
 
         if (isset($r->refresh_token)) {
-            $systemaccount->set('refreshtoken', $r->refresh_token);
-            $systemaccount->update();
             $this->refreshtoken = $r->refresh_token;
+            $newrefreshtoken = new stdClass();
+            $newrefreshtoken->token = $this->refreshtoken;
+            $newrefreshtoken->scope = $this->scope;
+            $tokens['refresh_token'] = $newrefreshtoken;
+        }
+
+        return $tokens;
+    }
+
+    /**
+     * Override which, in addition to deleting access tokens, also deletes any stored refresh token.
+     */
+    public function log_out() {
+        global $DB, $USER;
+        parent::log_out();
+        if (!$this->can_autorefresh()) {
+            return;
+        }
+
+        // For clients supporting autorefresh, delete the stored refresh token too.
+        $issuerid = $this->issuer->get('id');
+        $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid,
+            'scopehash' => sha1($this->scope)]);
+        if ($refreshtoken) {
+            $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
+        }
+    }
+
+    /**
+     * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only.
+     *
+     * @param \core\oauth2\system_account $systemaccount
+     * @return boolean true if token is upgraded succesfully
+     */
+    public function upgrade_refresh_token(system_account $systemaccount) {
+        $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken'));
+
+        // No access token received, so return false.
+        if (empty($receivedtokens)) {
+            return false;
+        }
+
+        // Store the access token and, if provided by the server, the new refresh token.
+        $this->store_token($receivedtokens['access_token']);
+        if (isset($receivedtokens['refreshtoken'])) {
+            $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
+            $systemaccount->update();
         }
 
         return true;
index bc9b8f8..e49598b 100644 (file)
@@ -114,6 +114,17 @@ class provider implements
             'info' => 'privacy:metadata:log:info'
         ], 'privacy:metadata:log');
 
+        // The oauth2_refresh_token stores refresh tokens, allowing ongoing access to select oauth2 services.
+        // Such tokens are not considered to be user data.
+        $collection->add_database_table('oauth2_refresh_token', [
+            'timecreated' => 'privacy:metadata:oauth2_refresh_token:timecreated',
+            'timemodified' => 'privacy:metadata:oauth2_refresh_token:timemodified',
+            'userid' => 'privacy:metadata:oauth2_refresh_token:userid',
+            'issuerid' => 'privacy:metadata:oauth2_refresh_token:issuerid',
+            'token' => 'privacy:metadata:oauth2_refresh_token:token',
+            'scopehash' => 'privacy:metadata:oauth2_refresh_token:scopehash'
+        ], 'privacy:metadata:oauth2_refresh_token');
+
         return $collection;
     }
 
index d34c09e..1a3f364 100644 (file)
@@ -427,6 +427,7 @@ class core_string_manager_standard implements core_string_manager {
 
         $countries = $this->load_component_strings('core_countries', $lang);
         core_collator::asort($countries);
+
         if (!$returnall and !empty($CFG->allcountrycodes)) {
             $enabled = explode(',', $CFG->allcountrycodes);
             $return = array();
@@ -435,7 +436,10 @@ class core_string_manager_standard implements core_string_manager {
                     $return[$c] = $countries[$c];
                 }
             }
-            return $return;
+
+            if (!empty($return)) {
+                return $return;
+            }
         }
 
         return $countries;
index 0643897..a78c07b 100644 (file)
         <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="Foreign key for the userid"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="oauth2_refresh_token" COMMENT="Stores refresh tokens which can be exchanged for access tokens">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Time this record was created."/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Time this record was modified."/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user to whom this refresh token belongs."/>
+        <FIELD NAME="issuerid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Corresponding oauth2 issuer"/>
+        <FIELD NAME="token" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="refresh token"/>
+        <FIELD NAME="scopehash" TYPE="char" LENGTH="40" NOTNULL="true" SEQUENCE="false" COMMENT="sha1 hash of the scopes used when requesting the refresh token"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="issueridkey" TYPE="foreign" FIELDS="issuerid" REFTABLE="oauth2_issuer" REFFIELDS="id" COMMENT="Issuer id foreign key"/>
+        <KEY NAME="useridkey" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="User id foreign key"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="userid-issuerid-scopehash" UNIQUE="true" FIELDS="userid, issuerid, scopehash"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index 41b2f5c..2a27ce6 100644 (file)
@@ -2708,5 +2708,35 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021052500.15);
     }
 
+    if ($oldversion < 2021052500.19) {
+        // Define table oauth2_refresh_token to be created.
+        $table = new xmldb_table('oauth2_refresh_token');
+
+        // Adding fields to table oauth2_refresh_token.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('token', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('scopehash', XMLDB_TYPE_CHAR, 40, null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table oauth2_refresh_token.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('issueridkey', XMLDB_KEY_FOREIGN, ['issuerid'], 'oauth2_issuer', ['id']);
+        $table->add_key('useridkey', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+
+        // Adding indexes to table oauth2_refresh_token.
+        $table->add_index('userid-issuerid-scopehash', XMLDB_INDEX_UNIQUE, array('userid', 'issuerid', 'scopehash'));
+
+        // Conditionally launch create table for oauth2_refresh_token.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.19);
+    }
+
     return true;
 }
index 6ec5d7b..2ea5d61 100644 (file)
@@ -2234,23 +2234,27 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
-    if ($filesize > 10000000) {
-        // for large files try to flush and close all buffers to conserve memory
-        while(@ob_get_level()) {
-            if (!@ob_end_flush()) {
-                break;
-            }
-        }
-    }
-
-    // Send this header after we have flushed the buffers so that if we fail
-    // later can remove this because it wasn't sent.
     header('Content-Length: ' . $filesize);
 
     if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
         exit;
     }
 
+    while (ob_get_level()) {
+        $handlerstack = ob_list_handlers();
+        $activehandler = array_pop($handlerstack);
+        if ($activehandler === 'default output handler') {
+            // We do not expect any content in the buffer when we are serving files.
+            $buffercontents = ob_get_clean();
+            if ($buffercontents !== '') {
+                error_log('Non-empty default output handler buffer detected while serving the file ' . $file);
+            }
+        } else {
+            // Some handlers such as zlib output compression may have file signature buffered - flush it.
+            ob_end_flush();
+        }
+    }
+
     // send the whole file content
     if (is_object($file)) {
         $file->readfile();
index 8afd063..8634bfb 100644 (file)
@@ -4306,6 +4306,9 @@ function delete_user(stdClass $user) {
     // Remove users customised pages.
     $DB->delete_records('my_pages', array('userid' => $user->id, 'private' => 1));
 
+    // Remove user's oauth2 refresh tokens, if present.
+    $DB->delete_records('oauth2_refresh_token', array('userid' => $user->id));
+
     // Delete user from $SESSION->bulk_users.
     if (isset($SESSION->bulk_users[$user->id])) {
         unset($SESSION->bulk_users[$user->id]);
index d81e43a..6a64c28 100644 (file)
@@ -1425,8 +1425,7 @@ class core_renderer extends renderer_base {
         if (!empty($this->page->context->id)) {
             $this->page->requires->js_call_amd('core/notification', 'init', array(
                 $this->page->context->id,
-                \core\notification::fetch_as_array($this),
-                isloggedin()
+                \core\notification::fetch_as_array($this)
             ));
         }
         $footer = str_replace($this->unique_end_html_token, $this->page->requires->get_end_code(), $footer);
index 3f65921..89e29e3 100644 (file)
@@ -85,5 +85,48 @@ class core_adminlib_testcase extends advanced_testcase {
         $this->assertSame($actual, $expected);
     }
 
-}
+    /**
+     * Data provider for additional skip tables.
+     *
+     * @return array
+     */
+    public function db_should_replace_additional_skip_tables_dataprovider() {
+        return [
+            // Skipped tables.
+            ['block_instances', '', false],
+            ['config',          '', false],
+            ['config_plugins',  '', false],
+            ['config_log',      '', false],
+            ['events_queue',    '', false],
+            ['filter_config',   '', false],
+            ['log',             '', false],
+            ['repository_instance_config', '', false],
+            ['sessions',        '', false],
+            ['upgrade_log',     '', false],
+
+            // Additional skipped tables.
+            ['context',      '', false],
+            ['quiz_attempts',     '', false],
+            ['role_assignments',     '', false],
+
+            // Normal tables.
+            ['assign',          '', true],
+            ['book',          '', true],
+        ];
+    }
 
+    /**
+     * Test additional skip tables.
+     *
+     * @dataProvider db_should_replace_additional_skip_tables_dataprovider
+     * @param string $table name
+     * @param string $column name
+     * @param bool $expected whether it should be replaced
+     */
+    public function test_db_should_replace_additional_skip_tables(string $table, string $column, bool $expected) {
+        $this->resetAfterTest();
+        $additionalskiptables = 'context, quiz_attempts, role_assignments ';
+        $actual = db_should_replace($table, $column, $additionalskiptables);
+        $this->assertSame($actual, $expected);
+    }
+}
index 2cbc131..50d5adc 100644 (file)
@@ -86,7 +86,16 @@ class core_notification_testcase extends advanced_testcase {
 
         $PAGE->set_state(\moodle_page::STATE_DONE);
         \core\notification::add('Example after page', \core\notification::INFO);
-        $this->assertCount(3, $SESSION->notifications);
+        $this->assertCount(2, $SESSION->notifications);
+        $this->expectOutputRegex('/Example after page/');
+
+        \core\session\manager::write_close();
+        \core\notification::add('Example after write_close', \core\notification::INFO);
+        $this->assertCount(2, $SESSION->notifications);
+        $this->expectOutputRegex('/Example after write_close/');
+
+        // Simulate shutdown handler which calls fetch.
+        $this->assertCount(2, \core\notification::fetch());
     }
 
     /**
index 54b48bb..aa73a0e 100644 (file)
@@ -143,6 +143,57 @@ class core_string_manager_standard_testcase extends advanced_testcase {
         set_config('langlist', '');
         get_string_manager(true);
     }
+
+    /**
+     * Test {@see core_string_manager_standard::get_list_of_countries()} under different conditions.
+     */
+    public function test_get_list_of_countries() {
+
+        $this->resetAfterTest();
+        $stringman = get_string_manager();
+
+        $countries = $stringman->get_list_of_countries(true);
+        $this->assertIsArray($countries);
+        $this->assertArrayHasKey('AU', $countries);
+        $this->assertArrayHasKey('BE', $countries);
+        $this->assertArrayHasKey('CZ', $countries);
+        $this->assertArrayHasKey('ES', $countries);
+        $this->assertGreaterThan(4, count($countries));
+
+        set_config('allcountrycodes', '');
+        $countries = $stringman->get_list_of_countries(false);
+        $this->assertArrayHasKey('AU', $countries);
+        $this->assertArrayHasKey('BE', $countries);
+        $this->assertArrayHasKey('CZ', $countries);
+        $this->assertArrayHasKey('ES', $countries);
+        $this->assertGreaterThan(4, count($countries));
+
+        set_config('allcountrycodes', 'CZ,BE');
+        $countries = $stringman->get_list_of_countries(true);
+        $this->assertArrayHasKey('AU', $countries);
+        $this->assertArrayHasKey('BE', $countries);
+        $this->assertArrayHasKey('CZ', $countries);
+        $this->assertArrayHasKey('ES', $countries);
+        $this->assertGreaterThan(4, count($countries));
+
+        $countries = $stringman->get_list_of_countries(false);
+        $this->assertEquals(2, count($countries));
+        $this->assertArrayHasKey('BE', $countries);
+        $this->assertArrayHasKey('CZ', $countries);
+
+        set_config('allcountrycodes', 'CZ,UVWXYZ');
+        $countries = $stringman->get_list_of_countries();
+        $this->assertArrayHasKey('CZ', $countries);
+        $this->assertEquals(1, count($countries));
+
+        set_config('allcountrycodes', 'UVWXYZ');
+        $countries = $stringman->get_list_of_countries();
+        $this->assertArrayHasKey('AU', $countries);
+        $this->assertArrayHasKey('BE', $countries);
+        $this->assertArrayHasKey('CZ', $countries);
+        $this->assertArrayHasKey('ES', $countries);
+        $this->assertGreaterThan(4, count($countries));
+    }
 }
 
 /**
index e30c713..59b3976 100644 (file)
Binary files a/mod/assign/amd/build/grading_panel.min.js and b/mod/assign/amd/build/grading_panel.min.js differ
index f856ec5..49942bd 100644 (file)
Binary files a/mod/assign/amd/build/grading_panel.min.js.map and b/mod/assign/amd/build/grading_panel.min.js.map differ
index da11d9d..dbd75ff 100644 (file)
@@ -25,8 +25,8 @@
  */
 define(['jquery', 'core/yui', 'core/notification', 'core/templates', 'core/fragment',
         'core/ajax', 'core/str', 'mod_assign/grading_form_change_checker',
-        'mod_assign/grading_events', 'core/event'],
-       function($, Y, notification, templates, fragment, ajax, str, checker, GradingEvents, Event) {
+        'mod_assign/grading_events', 'core/event', 'core/toast'],
+       function($, Y, notification, templates, fragment, ajax, str, checker, GradingEvents, Event, Toast) {
 
     /**
      * GradingPanel class.
@@ -148,10 +148,9 @@ define(['jquery', 'core/yui', 'core/notification', 'core/templates', 'core/fragm
             $(document).trigger('reset', [this._lastUserId, formdata]);
         } else {
             str.get_strings([
-                {key: 'changessaved', component: 'core'},
                 {key: 'gradechangessaveddetail', component: 'mod_assign'},
             ]).done(function(strs) {
-                notification.alert(strs[0], strs[1]);
+                Toast.add(strs[0]);
             }).fail(notification.exception);
             Y.use('moodle-core-formchangechecker', function() {
                 M.core_formchangechecker.reset_form_dirty_state();
index 80eecf7..292098d 100644 (file)
@@ -87,9 +87,6 @@ if ($rss) {
     array_push($table->align, 'center');
 }
 
-$options = new stdClass();
-$options->noclean = true;
-
 $currentsection = "";
 
 foreach ($datas as $data) {
@@ -130,10 +127,12 @@ foreach ($datas as $data) {
             }
             $currentsection = $data->section;
         }
-        $row = array ($printsection, $link, format_text($data->intro, $data->introformat, $options), $numrecords, $numunapprovedrecords);
+        $row = array($printsection, $link, format_module_intro('data', $data, $data->coursemodule),
+            $numrecords, $numunapprovedrecords);
 
     } else {
-        $row = array ($link, format_text($data->intro, $data->introformat, $options), $numrecords, $numunapprovedrecords);
+        $row = array($link, format_module_intro('data', $data, $data->coursemodule),
+            $numrecords, $numunapprovedrecords);
     }
 
     if ($rss) {
diff --git a/mod/data/tests/behat/data_activities.feature b/mod/data/tests/behat/data_activities.feature
new file mode 100644 (file)
index 0000000..5274cff
--- /dev/null
@@ -0,0 +1,47 @@
+@mod @mod_data
+Feature: Users can view the list of data activities and their formatted descriptions
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Bob       | 1        | student1@example.com |
+      | teacher1 | Teacher   | 1        | teacher1@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 "activities" exist:
+      | activity | name            | intro                                                                     | course | idnumber |
+      | data     | Test database 1 | This is an intro without an image                                         | C1     | data1    |
+      | data     | Test database 2 | This is an intro with an image: <img src="@@PLUGINFILE@@/some_image.jpg"> | C1     | data2    |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Activities" block
+    And I log out
+
+  Scenario: Teachers can view the list of data activities and their formatted descriptions
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    When I follow "Databases"
+    Then I should see "Test database 1"
+    And I should see "Test database 2"
+    And I should see "This is an intro without an image"
+    And I should see "This is an intro with an image: "
+    And "//img[contains(@src, 'some_image.jpg')]" "xpath_element" should exist
+    And "//img[contains(@src, '@@PLUGINFILE@@/some_image.jpg')]" "xpath_element" should not exist
+    And I log out
+
+  Scenario: Students can view the list of data activities and their formatted descriptions
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    When I follow "Databases"
+    Then I should see "Test database 1"
+    And I should see "Test database 2"
+    And I should see "This is an intro without an image"
+    And I should see "This is an intro with an image: "
+    And "//img[contains(@src, 'some_image.jpg')]" "xpath_element" should exist
+    And "//img[contains(@src, '@@PLUGINFILE@@/some_image.jpg')]" "xpath_element" should not exist
+    And I log out
index ddcb978..0a6a2d3 100644 (file)
@@ -38,7 +38,7 @@
         <form data-post-id="{{postid}}" id="inpage-reply-{{postid}}" data-content="inpage-reply-form" action="{{{reply_url}}}">
             <div class="row pb-1">
                 <span>
-                    <textarea rows="5" name="post" title="post" class="form-control" placeholder="{{#str}} replyplaceholder, forum {{/str}}"></textarea>
+                    <textarea rows="5" name="post" title="{{#str}} message, mod_forum {{/str}}" class="form-control" placeholder="{{#str}} replyplaceholder, forum {{/str}}"></textarea>
                     <input type="hidden" name="postformat" value="{{postformat}}"/>
                 </span>
                 <input type="hidden" name="subject" value="{{parentsubject}}"/>
                 <input type="hidden" name="sesskey" value="{{sesskey}}"/>
             </div>
             <div class="row">
-                <button class="btn btn-primary" title="{{#str}} submit, core {{/str}}" data-action="forum-inpage-submit">
-                    <span data-region="submit-text">{{#str}} submit, core {{/str}}</span>
+                <button class="btn btn-primary" data-action="forum-inpage-submit">
+                    <span data-region="submit-text">{{#str}} posttoforum, mod_forum {{/str}}</span>
                     <span data-region="loading-icon-container" class="hidden">{{> core/loading }}</span>
                 </button>
-                <button class="btn btn-secondary" title="{{#str}} cancel, core {{/str}}" data-action="collapsible-link">
+                <button class="btn btn-secondary" data-action="collapsible-link">
                     {{#str}} cancel, core {{/str}}
                 </button>
                 {{#canreplyprivately}}
@@ -59,7 +59,7 @@
                     <label class="form-check-label" for="private-reply-checkbox-{{uniqid}}">{{#str}} privatereply, forum {{/str}}</label>
                 </div>
                 {{/canreplyprivately}}
-                <button title="{{#str}} advanced, core {{/str}}" data-action="forum-advanced-reply" class="btn btn-link float-right" type="submit">
+                <button data-action="forum-advanced-reply" class="btn btn-link float-right" type="submit">
                     {{#str}} advanced, core {{/str}}
                 </button>
             </div>
index 4794aa3..316d5e4 100644 (file)
@@ -74,7 +74,7 @@
                 <input type="hidden" name="sesskey" value="{{sesskey}}"/>
                 <div class="d-flex mt-3 align-items-center flex-wrap">
                     <button class="btn btn-primary font-weight-bold px-3" data-action="forum-inpage-submit">
-                        <span data-region="submit-text">{{#str}} post, core {{/str}}</span>
+                        <span data-region="submit-text">{{#str}} posttoforum, mod_forum {{/str}}</span>
                         <span data-region="loading-icon-container" class="hidden">{{> core/loading }}</span>
                     </button>
                     <button data-action="forum-advanced-reply" class="btn btn-link mr-auto" type="submit">
index 454743e..a45c0ab 100644 (file)
@@ -121,7 +121,7 @@ class behat_mod_forum extends behat_base {
         // Fill form and post.
         $this->execute('behat_forms::i_set_the_following_fields_to_these_values', $table);
 
-        $this->execute('behat_forms::press_button', get_string('submit', 'core'));
+        $this->execute('behat_forms::press_button', get_string('posttoforum', 'mod_forum'));
     }
 
     /**
index f295735..df2ce8c 100644 (file)
@@ -406,12 +406,8 @@ switch ($mode) {
                     }
                     $count++;
 
-                    // Make sure they didn't answer it more than the max number of attmepts
-                    if (count($try) > $lesson->maxattempts) {
-                        $essay = $try[$lesson->maxattempts-1];
-                    } else {
-                        $essay = end($try);
-                    }
+                    // Make sure they didn't answer it more than the max number of attempts.
+                    $essay = $lesson->get_last_attempt($try);
 
                     // Start processing the attempt
                     $essayinfo = lesson_page_type_essay::extract_useranswer($essay->useranswer);
index 5f001e1..784678d 100644 (file)
@@ -301,9 +301,11 @@ function lesson_grade($lesson, $ntries, $userid = 0) {
             $attemptset[$useranswer->pageid][] = $useranswer;
         }
 
-        // Drop all attempts that go beyond max attempts for the lesson
-        foreach ($attemptset as $key => $set) {
-            $attemptset[$key] = array_slice($set, 0, $lesson->maxattempts);
+        if (!empty($lesson->maxattempts)) {
+            // Drop all attempts that go beyond max attempts for the lesson.
+            foreach ($attemptset as $key => $set) {
+                $attemptset[$key] = array_slice($set, 0, $lesson->maxattempts);
+            }
         }
 
         // get only the pages and their answers that the user answered
@@ -3659,6 +3661,23 @@ class lesson extends lesson_base {
         }
         return $data;
     }
+
+    /**
+     * Returns the last "legal" attempt from the list of student attempts.
+     *
+     * @param array $attempts The list of student attempts.
+     * @return stdClass The updated fom data.
+     */
+    public function get_last_attempt(array $attempts): stdClass {
+        // If there are more tries than the max that is allowed, grab the last "legal" attempt.
+        if (!empty($this->maxattempts) && (count($attempts) > $this->maxattempts)) {
+            $lastattempt = $attempts[$this->maxattempts - 1];
+        } else {
+            // Grab the last attempt since there's no limit to the max attempts or the user has made fewer attempts than the max.
+            $lastattempt = end($attempts);
+        }
+        return $lastattempt;
+    }
 }
 
 
@@ -4132,7 +4151,7 @@ abstract class lesson_page extends lesson_base {
                     'userid' => $USER->id, 'pageid' => $this->properties->id, 'retry' => $nretakes));
 
                 // Check if they have reached (or exceeded) the maximum number of attempts allowed.
-                if ($nattempts >= $this->lesson->maxattempts) {
+                if (!empty($this->lesson->maxattempts) && $nattempts >= $this->lesson->maxattempts) {
                     $result->maxattemptsreached = true;
                     $result->feedback = get_string('maximumnumberofattemptsreached', 'lesson');
                     $result->newpageid = $this->lesson->get_next_page($this->properties->nextpageid);
@@ -4200,8 +4219,8 @@ abstract class lesson_page extends lesson_base {
                 // "number of attempts remaining" message if $this->lesson->maxattempts > 1
                 // displaying of message(s) is at the end of page for more ergonomic display
                 if (!$result->correctanswer && ($result->newpageid == 0)) {
-                    // retreive the number of attempts left counter for displaying at bottom of feedback page
-                    if ($nattempts >= $this->lesson->maxattempts) {
+                    // Retrieve the number of attempts left counter for displaying at bottom of feedback page.
+                    if (!empty($this->lesson->maxattempts) && $nattempts >= $this->lesson->maxattempts) {
                         if ($this->lesson->maxattempts > 1) { // don't bother with message if only one attempt
                             $result->maxattemptsreached = true;
                         }
index 21c5646..be2cca2 100644 (file)
@@ -278,7 +278,7 @@ class mod_lesson_mod_form extends moodleform_mod {
         $mform->setDefault('review', $lessonconfig->displayreview);
         $mform->setAdvanced('review', $lessonconfig->displayreview_adv);
 
-        $numbers = array();
+        $numbers = array('0' => get_string('unlimited'));
         for ($i = 10; $i > 0; $i--) {
             $numbers[$i] = $i;
         }
index cf5d44a..41b3d6f 100644 (file)
@@ -269,12 +269,7 @@ class lesson_page_type_essay extends lesson_page {
         return true;
     }
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         $essayinfo = self::extract_useranswer($temp->useranswer);
         if ($essayinfo->graded) {
             if (isset($pagestats[$temp->pageid])) {
index 69d92bc..29ed335 100644 (file)
@@ -389,12 +389,7 @@ class lesson_page_type_matching extends lesson_page {
         return true;
     }
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         if ($temp->correct) {
             if (isset($pagestats[$temp->pageid]["correct"])) {
                 $pagestats[$temp->pageid]["correct"]++;
index af7cefa..232add5 100644 (file)
@@ -311,12 +311,7 @@ class lesson_page_type_multichoice extends lesson_page {
         return $table;
     }
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         if ($this->properties->qoption) {
             $userresponse = explode(",", $temp->useranswer);
             foreach ($userresponse as $response) {
index ff7bfa2..9a6f20f 100644 (file)
@@ -229,12 +229,7 @@ class lesson_page_type_numerical extends lesson_page {
         return $table;
     }
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         if (isset($pagestats[$temp->pageid][$temp->useranswer])) {
             $pagestats[$temp->pageid][$temp->useranswer]++;
         } else {
index f59d94b..bc7ce1f 100644 (file)
@@ -292,12 +292,7 @@ class lesson_page_type_shortanswer extends lesson_page {
         return $table;
     }
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         if (isset($pagestats[$temp->pageid][$temp->useranswer])) {
             $pagestats[$temp->pageid][$temp->useranswer]++;
         } else {
index 766ad23..3e9fab4 100644 (file)
@@ -225,12 +225,7 @@ class lesson_page_type_truefalse extends lesson_page {
     }
 
     public function stats(array &$pagestats, $tries) {
-        if(count($tries) > $this->lesson->maxattempts) { // if there are more tries than the max that is allowed, grab the last "legal" attempt
-            $temp = $tries[$this->lesson->maxattempts - 1];
-        } else {
-            // else, user attempted the question less than the max, so grab the last one
-            $temp = end($tries);
-        }
+        $temp = $this->lesson->get_last_attempt($tries);
         if ($this->properties->qoption) {
             $userresponse = explode(",", $temp->useranswer);
             foreach ($userresponse as $response) {
index 61892e3..2a88e51 100644 (file)
@@ -127,3 +127,37 @@ Feature: In a lesson activity, students can navigate through a series of pages i
     And I should not see "Yes, I'd like to try again"
     And I press "Continue"
     And I should see "Congratulations - end of lesson reached"
+
+  Scenario: Student should not see remaining attempts notification if maximum number of attempts is set to unlimited
+    Given I add a "Lesson" to section "1" and I fill the form with:
+      | Name | Test lesson name |
+      | Description | Test lesson description |
+      | id_review | Yes |
+      | id_maxattempts | 0 |
+    And I follow "Test lesson name"
+    And I follow "Add a question page"
+    And I set the following fields to these values:
+      | id_qtype | True/false |
+    And I press "Add a question page"
+    And I set the following fields to these values:
+      | Page title | Test question |
+      | Page contents | Test content |
+      | id_answer_editor_0 | right |
+      | id_answer_editor_1 | wrong |
+    And I press "Save page"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    When I follow "Test lesson name"
+    Then I should see "Test content"
+    And I set the following fields to these values:
+      | wrong | 1 |
+    And I press "Submit"
+    And I should not see "attempt(s) remaining"
+    And I press "Yes, I'd like to try again"
+    And I should see "Test content"
+    And I set the following fields to these values:
+      | right | 1 |
+    And I press "Submit"
+    And I should not see "Yes, I'd like to try again"
+    And I should see "Congratulations - end of lesson reached"
index 0ea349b..56e66ab 100644 (file)
@@ -251,4 +251,36 @@ class mod_lesson_locallib_testcase extends advanced_testcase {
         $this->assertEquals(true, $lesson->is_participant($USER->id),
             'Admin is enrolled, suspended and can participate');
     }
+
+    /**
+     * Data provider for test_get_last_attempt.
+     *
+     * @return array
+     */
+    public function test_get_last_attempt_dataprovider() {
+        return [
+            [0, [(object)['id' => 1], (object)['id' => 2], (object)['id' => 3]], (object)['id' => 3]],
+            [1, [(object)['id' => 1], (object)['id' => 2], (object)['id' => 3]], (object)['id' => 1]],
+            [2, [(object)['id' => 1], (object)['id' => 2], (object)['id' => 3]], (object)['id' => 2]],
+            [3, [(object)['id' => 1], (object)['id' => 2], (object)['id' => 3]], (object)['id' => 3]],
+            [4, [(object)['id' => 1], (object)['id' => 2], (object)['id' => 3]], (object)['id' => 3]],
+        ];
+    }
+
+    /**
+     * Test the get_last_attempt() method.
+     *
+     * @dataProvider test_get_last_attempt_dataprovider
+     * @param int $maxattempts Lesson setting.
+     * @param array $attempts The list of student attempts.
+     * @param object $expected Expected result.
+     */
+    public function test_get_last_attempt($maxattempts, $attempts, $expected) {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $course = $this->getDataGenerator()->create_course();
+        $lesson = $this->getDataGenerator()->create_module('lesson', ['course' => $course, 'maxattempts' => $maxattempts]);
+        $lesson = new lesson($lesson);
+        $this->assertEquals($expected, $lesson->get_last_attempt($attempts));
+    }
 }
index bedfad8..3631596 100644 (file)
@@ -349,7 +349,7 @@ class mod_quiz_renderer extends plugin_renderer_base {
      * @return string HTML fragment.
      */
     protected function render_quiz_nav_question_button(quiz_nav_question_button $button) {
-        $classes = array('qnbutton', $button->stateclass, $button->navmethod, 'btn', 'btn-secondary');
+        $classes = array('qnbutton', $button->stateclass, $button->navmethod, 'btn');
         $extrainfo = array();
 
         if ($button->currentpage) {
index 88e5c9a..0f62e8e 100644 (file)
@@ -97,7 +97,7 @@ class repository_googledocs extends repository {
             $returnurl->param('sesskey', sesskey());
         }
 
-        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
+        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true);
 
         return $this->client;
     }
index e2ad61f..62f38c0 100644 (file)
@@ -574,7 +574,7 @@ class repository_nextcloud extends repository {
             $returnurl->param('repo_id', $this->id);
             $returnurl->param('sesskey', sesskey());
         }
-        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
+        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true);
         return $this->client;
     }
 
index f03c8dc..b2c6d80 100644 (file)
@@ -93,7 +93,7 @@ class repository_onedrive extends repository {
             $returnurl->param('sesskey', sesskey());
         }
 
-        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
+        $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true);
 
         return $this->client;
     }
index 6f90f71..2e3cda2 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.18;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.19;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '4.0dev (Build: 20201002)'; // Human-friendly version name
+$release  = '4.0dev (Build: 20201006)'; // Human-friendly version name
 $branch   = '400';                      // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.