Merge branch 'MDL-70476' of https://github.com/timhunt/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 6 Jan 2021 23:27:48 +0000 (00:27 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 6 Jan 2021 23:27:48 +0000 (00:27 +0100)
146 files changed:
admin/settings/server.php
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/templatelibrary/amd/build/search.min.js
admin/tool/templatelibrary/amd/build/search.min.js.map
admin/tool/templatelibrary/amd/src/search.js
admin/tool/templatelibrary/classes/output/list_templates_page.php
admin/tool/templatelibrary/templates/list_templates_page.mustache
analytics/classes/local/analyser/base.php
analytics/tests/behat/manage_models.feature
analytics/upgrade.txt
backup/tests/quiz_restore_decode_links_test.php
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/build/view.min.js.map
blocks/myoverview/amd/src/view.js
blocks/myoverview/classes/output/main.php
blocks/myoverview/tests/behat/block_myoverview_pagelimit_persistence.feature
blocks/myoverview/tests/behat/block_myoverview_pagination.feature [new file with mode: 0644]
blocks/timeline/templates/event-list-item.mustache
calendar/classes/external/calendar_event_exporter.php
course/classes/customfield/course_handler.php
course/management.php
course/tests/behat/coursetags.feature
course/tests/behat/rename_roles.feature
enrol/fee/classes/plugin.php
enrol/manual/tests/behat/quickenrolment.feature
files/classes/archive_writer.php [new file with mode: 0644]
files/classes/local/archive_writer/file_writer_interface.php [new file with mode: 0644]
files/classes/local/archive_writer/stream_writer_interface.php [new file with mode: 0644]
files/classes/local/archive_writer/zip_writer.php [new file with mode: 0644]
files/tests/archive_writer_test.php [new file with mode: 0644]
files/tests/fixtures/awesome_file.txt [new file with mode: 0644]
files/tests/local/archive_writer/zip_writer_test.php [new file with mode: 0644]
grade/grading/form/rubric/styles.css
group/tests/behat/create_groups.feature
group/tests/behat/group_description.feature
install/lang/es/langconfig.php
install/lang/no/install.php
install/lang/ro/install.php
install/lang/ro/langconfig.php
install/lang/ro_wp/langconfig.php
install/lang/sv/error.php
install/lang/sv/install.php
lang/en/admin.php
lang/en/moodle.php
lib/adminlib.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/network.min.js
lib/amd/build/network.min.js.map
lib/amd/build/paged_content_factory.min.js
lib/amd/build/paged_content_factory.min.js.map
lib/amd/src/form-autocomplete.js
lib/amd/src/network.js
lib/amd/src/paged_content_factory.js
lib/behat/behat_field_manager.php
lib/behat/form_field/behat_form_autocomplete.php
lib/classes/oauth2/client.php
lib/classes/output/notification.php
lib/classes/task/manager.php
lib/cronlib.php
lib/dml/moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/oci_native_moodle_database.php
lib/dml/pdo_moodle_database.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/sqlsrv_native_moodle_database.php
lib/dml/tests/fixtures/test_moodle_database.php
lib/form/templates/element-advcheckbox.mustache
lib/form/templates/element-checkbox.mustache
lib/form/templates/element-radio.mustache
lib/form/templates/element-template.mustache
lib/moodlelib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/setup.php
lib/templates/form_autocomplete_input.mustache
lib/templates/paged_content_paging_bar.mustache
lib/templates/search_input_auto.mustache
lib/templates/search_input_navbar.mustache
lib/tests/analysers_test.php
lib/tests/behat/behat_forms.php
lib/tests/behat/behat_hooks.php
lib/tests/fixtures/deprecated_analyser.php [deleted file]
lib/tests/outputcomponents_test.php
lib/tests/scheduled_task_test.php
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/chat/gui_header_js/jsupdate.php
mod/chat/gui_header_js/jsupdated.php
mod/chat/gui_sockets/chatinput.php
mod/data/export_form.php
mod/data/lang/en/data.php
mod/data/lang/en/deprecated.txt
mod/data/tests/behat/view_entries.feature
mod/feedback/tests/behat/coursemapping.feature
mod/folder/download_folder.php
mod/folder/renderer.php
mod/forum/externallib.php
mod/forum/tests/behat/advanced_search.feature
mod/forum/upgrade.txt
mod/lti/amd/build/contentitem.min.js
mod/lti/amd/build/contentitem.min.js.map
mod/lti/amd/src/contentitem.js
mod/lti/classes/local/ltiopenid/registration_helper.php
mod/lti/locallib.php
mod/lti/openid-configuration.php
mod/lti/tests/openidregistration_test.php
mod/quiz/attemptlib.php
mod/quiz/db/access.php
mod/quiz/lang/en/deprecated.txt
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/overrides.php
mod/quiz/renderer.php
mod/quiz/tests/attempt_test.php
mod/quiz/tests/attempts_test.php
mod/quiz/tests/behat/quiz_group_override.feature
mod/quiz/tests/behat/quiz_user_override.feature
mod/quiz/tests/generator/lib.php
mod/quiz/tests/locallib_test.php
mod/quiz/version.php
payment/classes/helper.php
question/engine/lib.php
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/build/question.min.js.map
question/type/ddmarker/amd/src/question.js
question/type/essay/renderer.php
report/competency/tests/behat/breakdown_by_activity.feature
theme/boost/config.php
theme/boost/layout/embedded.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/boost/templates/embedded.mustache
theme/classic/style/moodle.css
theme/upgrade.txt
user/tests/behat/edit_user_roles.feature
user/tests/behat/filter_participants.feature
user/tests/behat/filter_participants_showall.feature
user/tests/behat/full_name_display.feature
user/tests/behat/view_participants_groups.feature
version.php

index 11541bb..beb70f3 100644 (file)
@@ -73,6 +73,22 @@ if ($hassiteconfig) {
     $temp->add(new admin_setting_configduration('sessiontimeout', new lang_string('sessiontimeout', 'admin'),
         new lang_string('configsessiontimeout', 'admin'), 8 * 60 * 60));
 
+    $sessiontimeoutwarning = new admin_setting_configduration('sessiontimeoutwarning',
+        new lang_string('sessiontimeoutwarning', 'admin'),
+        new lang_string('configsessiontimeoutwarning', 'admin'), 20 * 60);
+
+    $sessiontimeoutwarning->set_validate_function(function(int $value): string {
+        global $CFG;
+        // Check sessiontimeoutwarning is less than sessiontimeout.
+        if ($CFG->sessiontimeout <= $value) {
+            return get_string('configsessiontimeoutwarningcheck', 'admin');
+        } else {
+            return '';
+        }
+    });
+
+    $temp->add($sessiontimeoutwarning);
+
     $temp->add(new admin_setting_configtext('sessioncookie', new lang_string('sessioncookie', 'admin'),
         new lang_string('configsessioncookie', 'admin'), '', PARAM_ALPHANUM));
     $temp->add(new admin_setting_configtext('sessioncookiepath', new lang_string('sessioncookiepath', 'admin'),
index 6619bda..ff4b208 100644 (file)
@@ -10,16 +10,12 @@ Feature: Manage data storage purposes
     And I open the action menu in "region-main" "region"
     And I choose "Purposes" in the open action menu
     And I press "Add purpose"
-    And I set the field "Name" to "Purpose 1"
-    And I set the field "Description" to "Purpose 1 description"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
-    And I click on "Contract (GDPR Art. 6.1(b))" "list_item"
-    And I click on "Legal obligation (GDPR Art 6.1(c))" "list_item"
-    And I press the escape key
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Sensitive personal data processing reasons" "form_row"
-    And I click on "Explicit consent (GDPR Art. 9.2(a))" "list_item"
-    And I press the escape key
-    And I set the field "retentionperiodnumber" to "2"
+    And I set the following fields to these values:
+      | Name                                       | Purpose 1                                                      |
+      | Description                                | Purpose 1 description                                          |
+      | Lawful bases                               | Contract (GDPR Art. 6.1(b)),Legal obligation (GDPR Art 6.1(c)) |
+      | Sensitive personal data processing reasons | Explicit consent (GDPR Art. 9.2(a))                            |
+      | retentionperiodnumber                      | 2                                                              |
     When I press "Save"
     Then I should see "Purpose 1" in the "List of data purposes" "table"
     And I should see "Contract (GDPR Art. 6.1(b))" in the "Purpose 1" "table_row"
@@ -31,14 +27,13 @@ Feature: Manage data storage purposes
   Scenario: Update a data storage purpose
     Given I open the action menu in "Purpose 1" "table_row"
     And I choose "Edit" in the open action menu
-    And I set the field "Name" to "Purpose 1 edited"
-    And I set the field "Description" to "Purpose 1 description edited"
-    And I click on "Legal obligation (GDPR Art 6.1(c))" "text" in the ".form-autocomplete-selection" "css_element"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
-    And I click on "Vital interests (GDPR Art. 6.1(d))" "list_item"
-    And I press the escape key
-    And I set the field "retentionperiodnumber" to "3"
-    And I click on "protected" "checkbox"
+    And I set the following fields to these values:
+      | Name                                       | Purpose 1 edited                                                |
+      | Description                                | Purpose 1 description edited                                    |
+      | Lawful bases                               | Contract (GDPR Art. 6.1(b)), Vital interests (GDPR Art. 6.1(d)) |
+      | Sensitive personal data processing reasons | Explicit consent (GDPR Art. 9.2(a))                             |
+      | retentionperiodnumber                      | 3                                                               |
+      | protected                                  | 1                                                               |
     When I press "Save changes"
     Then I should see "Purpose 1 edited" in the "List of data purposes" "table"
     And I should see "Purpose 1 description edited" in the "Purpose 1 edited" "table_row"
index 55c085a..9f3dc07 100644 (file)
@@ -53,9 +53,7 @@ Feature: Manage plearning plan
     And I follow "Home"
     And I navigate to "Competencies > Learning plan templates" in site administration
     And I click on ".template-cohorts" "css_element" in the "Science template cohort" "table_row"
-    And I click on ".form-autocomplete-downarrow" "css_element"
-    And I click on "cohort plan" item in the autocomplete list
-    And I press the escape key
+    And I set the field "Select cohorts to sync" to "cohort plan"
     When I click on "Add cohorts" "button"
     Then I should see "2 learning plans were created."
     And I follow "Learning plan templates"
index 74f8c50..9645fa6 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js and b/admin/tool/templatelibrary/amd/build/search.min.js differ
index 0245bb8..c16fd85 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js.map and b/admin/tool/templatelibrary/amd/build/search.min.js.map differ
index 9caf344..1865457 100644 (file)
@@ -52,9 +52,8 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
         } else {
             $('[data-region="list-templates"] [data-action="clearsearch"]').addClass('d-none');
         }
-        // Trigger the search.
-        document.location.hash = searchStr;
 
+        // Trigger the search.
         ajax.call([
             {methodname: 'tool_templatelibrary_list_templates',
               args: {component: componentStr, search: searchStr, themename: themename},
@@ -96,7 +95,6 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
         $(this).addClass('d-none');
     });
 
-    $('[data-region="input"]').val(document.location.hash.replace('#', ''));
     refreshSearch(config.theme);
     return {};
 });
index ebd4500..71035d8 100644 (file)
@@ -40,6 +40,22 @@ use tool_templatelibrary\api;
  */
 class list_templates_page implements renderable, templatable {
 
+    /** @var string $component The currently selected component */
+    protected $component;
+    /** @var string $search The current search */
+    protected $search;
+
+    /**
+     * Template page constructor
+     *
+     * @param string $component
+     * @param string $search
+     */
+    public function __construct(string $component = '', string $search = '') {
+        $this->component = $component;
+        $this->search = $search;
+    }
+
     /**
      * Export this data so it can be used as the context for a mustache template.
      *
@@ -75,6 +91,7 @@ class list_templates_page implements renderable, templatable {
             $components[$type]->plugins[$component] = (object) [
                 'name' => $pluginname,
                 'component' => $component,
+                'selected' => ($component === $this->component),
             ];
         }
 
@@ -87,6 +104,7 @@ class list_templates_page implements renderable, templatable {
 
         return (object) [
             'allcomponents' => array_values($components),
+            'search' => $this->search,
         ];
     }
 }
index 71d8042..0accf0a 100644 (file)
         {{/label}}
 
         {{$element}}
-            <select id="selectcomponent" class="form-control" data-field="component">
+            <select id="selectcomponent" name="component" class="form-control" data-field="component">
                 <option value="">{{#str}}all, tool_templatelibrary{{/str}}</option>
                 {{#allcomponents}}
                     <optgroup label="{{type}}">
                         {{#plugins}}
-                            <option value="{{component}}">{{name}}</option>
+                            <option value="{{component}}" {{#selected}}selected{{/selected}}>{{name}}</option>
                         {{/plugins}}
                     </optgroup>
                 {{/allcomponents}}
     {{< core_form/element-template }}
         {{$element}}
             {{< core/search_input_auto }}
-                {{$label}}{{{ searchstring }}}{{/label}}
-                {{$placeholder}}{{#str}}
-                    search, core
-                {{/str}}{{/placeholder}}
+                {{$label}}
+                    {{#str}} search, tool_templatelibrary {{/str}}
+                {{/label}}
+                {{$value}}{{ search }}{{/value}}
             {{/ core/search_input_auto }}
         {{/element}}
     {{/ core_form/element-template }}
index 7a958f9..6908c19 100644 (file)
@@ -106,21 +106,10 @@ abstract class base {
     }
 
     /**
-     * Returns the list of analysable elements available on the site.
-     *
-     * \core_analytics\local\analyser\by_course and \core_analytics\local\analyser\sitewide are implementing
-     * this method returning site courses (by_course) and the whole system (sitewide) as analysables.
-     *
-     * @todo MDL-65284 This will be removed in Moodle 3.11
-     * @deprecated
-     * @see get_analysables_iterator
-     * @throws  \coding_exception
-     * @return \core_analytics\analysable[] Array of analysable elements using the analysable id as array key.
+     * @deprecated since Moodle 3.7
      */
     public function get_analysables() {
-        // This function should only be called from get_analysables_iterator and we keep it here until Moodle 3.11
-        // for backwards compatibility.
-        throw new \coding_exception('This method is deprecated in favour of get_analysables_iterator.');
+        throw new \coding_exception('get_analysables() method has been removed and cannot be used any more.');
     }
 
     /**
@@ -134,15 +123,7 @@ abstract class base {
      * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
-
-        debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
-            implementation for backwards compatibility purposes with get_analysables(). note that $action param will
-            be ignored so the analysable elements will be processed using get_analysables order, regardless of the
-            last time they were processed.');
-
-        return new \ArrayIterator($this->get_analysables());
-    }
+    abstract public function get_analysables_iterator(?string $action = null, array $contexts = []);
 
     /**
      * This function returns this analysable list of samples.
index 816a253..d68ed78 100644 (file)
@@ -55,10 +55,7 @@ Feature: Manage analytics models
     And I choose "Create model" in the open action menu
     And I set the field "Enabled" to "Enable"
     And I select "__core_course__analytics__target__course_completion" from the "target" singleselect
-    And I open the autocomplete suggestions list
-    And I click on "Read actions amount" item in the autocomplete list
-    And I open the autocomplete suggestions list
-    And I click on "Any write action in the course" item in the autocomplete list
+    And I set the field "Indicators" to "Read actions amount, Any write action in the course"
     And I select "__core__analytics__time_splitting__single_range" from the "timesplitting" singleselect
     And I press "Save changes"
     Then I should see "No predictions available yet" in the "Students at risk of not meeting the course completion conditions" "table_row"
index ff24228..b23200c 100644 (file)
@@ -2,12 +2,15 @@ This files describes API changes in analytics sub system,
 information provided here is intended especially for developers.
 
 === 3.11 ===
+
 * Final deprecation get_enabled_time_splitting_methods. Method has been removed. Use
   get_time_splitting_methods_for_evaluation instead.
 * Final deprecation add_builtin_models. Method has been removed. The functionality
   has been replaced with automatic update of models provided by the core moodle component.
   There is no need to call this method explicitly any more. Instead, adding new models can be achieved
   by updating the lib/db/analytics.php file and bumping the core version.
+* Final deprecation - get_analysables(). Please see get_analysables_interator() instead.
+  get_analysables_iterator() needs to be overridden by the child class.
 
 === 3.8 ===
 
index 19a142d..46a6436 100644 (file)
@@ -82,16 +82,17 @@ class restore_quiz_decode_testcase extends \core_privacy\tests\provider_testcase
 
         $newcm = duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid));
 
-        $sql = "SELECT qa.answer
+        $sql = "SELECT qa.id, qa.answer
                   FROM {quiz} q
              LEFT JOIN {quiz_slots} qs ON qs.quizid = q.id
              LEFT JOIN {question_answers} qa ON qa.question = qs.questionid
                  WHERE q.id = :quizid";
         $params = array('quizid' => $newcm->instance);
-        $answers = $DB->get_fieldset_sql($sql, $params);
-        $this->assertEquals($CFG->wwwroot . '/course/view.php?id=' . $course->id, $answers[0]);
-        $this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $answers[1]);
-        $this->assertEquals($CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid, $answers[2]);
-        $this->assertEquals($CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid, $answers[3]);
+        $answers = $DB->get_records_sql_menu($sql, $params);
+
+        $this->assertEquals($CFG->wwwroot . '/course/view.php?id=' . $course->id, $answers[$firstanswer->id]);
+        $this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $answers[$secondanswer->id]);
+        $this->assertEquals($CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid, $answers[$thirdanswer->id]);
+        $this->assertEquals($CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid, $answers[$fourthanswer->id]);
     }
 }
index 8e18c99..17ed845 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 723993f..e238948 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js.map and b/blocks/myoverview/amd/build/view.min.js.map differ
index 524c4ba..1c92361 100644 (file)
@@ -543,11 +543,9 @@ function(
 
         // Filter out all pagination options which are too large for the amount of courses user is enrolled in.
         var totalCourseCount = parseInt(root.find(Selectors.courseView.region).attr('data-totalcoursecount'), 10);
-        if (totalCourseCount) {
-            itemsPerPage = itemsPerPage.filter(function(pagingOption) {
-                return pagingOption.value < totalCourseCount;
-            });
-        }
+        itemsPerPage = itemsPerPage.filter(function(pagingOption) {
+            return pagingOption.value < totalCourseCount || pagingOption.value === 0;
+        });
 
         var filters = getFilterValues(root);
         var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
@@ -602,7 +600,8 @@ function(
                                 pageCourses = $.merge(loadedPages[currentPage].courses, courses.slice(0, nextPageStart));
                             }
                         } else {
-                            nextPageStart = pageData.limit;
+                            // When the page limit is zero, there is only one page of courses, no start for next page.
+                            nextPageStart = pageData.limit || false;
                             pageCourses = (pageData.limit > 0) ? courses.slice(0, pageData.limit) : courses;
                         }
 
@@ -611,8 +610,8 @@ function(
                             courses: pageCourses
                         };
 
-                        // Set up the next page
-                        var remainingCourses = nextPageStart ? courses.slice(nextPageStart, courses.length) : [];
+                        // Set up the next page (if there is more than one page).
+                        var remainingCourses = nextPageStart !== false ? courses.slice(nextPageStart, courses.length) : [];
                         if (remainingCourses.length) {
                             loadedPages[currentPage + 1] = {
                                 courses: remainingCourses
index c290c98..b0236fd 100644 (file)
@@ -202,8 +202,9 @@ class main implements renderable, templatable {
         // Check and remember the given view.
         $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
 
-        // Check and remember the given page size.
-        if ($paging == BLOCK_MYOVERVIEW_PAGING_ALL) {
+        // Check and remember the given page size, `null` indicates no page size set
+        // while a `0` indicates a paging size of `All`.
+        if (!is_null($paging) && $paging == BLOCK_MYOVERVIEW_PAGING_ALL) {
             $this->paging = BLOCK_MYOVERVIEW_PAGING_ALL;
         } else {
             $this->paging = $paging ? $paging : BLOCK_MYOVERVIEW_PAGING_12;
index a192c3d..fef9a8b 100644 (file)
@@ -7,30 +7,30 @@ Feature: The my overview block allows users to persistence of their page limits
       | student1 | Student   | X        | student1@example.com | S1       |
     And the following "courses" exist:
       | fullname | shortname | category |
-      | Course 1 | C1        | 0        |
-      | Course 2 | C2        | 0        |
-      | Course 3 | C3        | 0        |
-      | Course 4 | C4        | 0        |
-      | Course 5 | C5        | 0        |
-      | Course 6 | C6        | 0        |
-      | Course 7 | C7        | 0        |
-      | Course 8 | C8        | 0        |
-      | Course 9 | C9        | 0        |
+      | Course 1 | C01        | 0        |
+      | Course 2 | C02        | 0        |
+      | Course 3 | C03        | 0        |
+      | Course 4 | C04        | 0        |
+      | Course 5 | C05        | 0        |
+      | Course 6 | C06        | 0        |
+      | Course 7 | C07        | 0        |
+      | Course 8 | C08        | 0        |
+      | Course 9 | C09        | 0        |
       | Course 10 | C10        | 0        |
       | Course 11 | C11        | 0        |
       | Course 12 | C12        | 0        |
       | Course 13 | C13        | 0        |
     And the following "course enrolments" exist:
       | user | course | role |
-      | student1 | C1 | student |
-      | student1 | C2 | student |
-      | student1 | C3 | student |
-      | student1 | C4 | student |
-      | student1 | C5 | student |
-      | student1 | C6 | student |
-      | student1 | C7 | student |
-      | student1 | C8 | student |
-      | student1 | C9 | student |
+      | student1 | C01 | student |
+      | student1 | C02 | student |
+      | student1 | C03 | student |
+      | student1 | C04 | student |
+      | student1 | C05 | student |
+      | student1 | C06 | student |
+      | student1 | C07 | student |
+      | student1 | C08 | student |
+      | student1 | C09 | student |
       | student1 | C10 | student |
       | student1 | C11 | student |
       | student1 | C12 | student |
@@ -38,8 +38,8 @@ Feature: The my overview block allows users to persistence of their page limits
 
   Scenario: Toggle the page limit between page reloads
     Given I log in as "student1"
-    When I click on "[data-toggle='dropdown']" "css_element" in the "Course overview" "block"
-    And I click on "All" "link"
+    When I click on "[data-action='limit-toggle']" "css_element" in the "Course overview" "block"
+    And I click on "All" "link" in the ".dropdown-menu.show" "css_element"
     Then I should see "Course 13"
     And I reload the page
     Then I should see "Course 13"
@@ -47,8 +47,8 @@ Feature: The my overview block allows users to persistence of their page limits
 
   Scenario: Toggle the page limit between grouping changes
     Given I log in as "student1"
-    When I click on "[data-toggle='dropdown']" "css_element" in the "Course overview" "block"
-    And I click on "All" "link"
+    When I click on "[data-action='limit-toggle']" "css_element" in the "Course overview" "block"
+    And I click on "All" "link" in the ".dropdown-menu.show" "css_element"
     And I click on "All (except removed from view)" "button" in the "Course overview" "block"
     And I click on "In progress" "link" in the "Course overview" "block"
     Then I should see "Course 13"
diff --git a/blocks/myoverview/tests/behat/block_myoverview_pagination.feature b/blocks/myoverview/tests/behat/block_myoverview_pagination.feature
new file mode 100644 (file)
index 0000000..9792252
--- /dev/null
@@ -0,0 +1,191 @@
+@block @block_myoverview @javascript
+Feature: My overview block pagination
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                | idnumber |
+      | student1 | Student   | X        | student1@example.com | S1       |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 01 | C1       | 0        |
+      | Course 02 | C2       | 0        |
+      | Course 03 | C3       | 0        |
+      | Course 04 | C4       | 0        |
+      | Course 05 | C5       | 0        |
+      | Course 06 | C6       | 0        |
+      | Course 07 | C7       | 0        |
+      | Course 08 | C8       | 0        |
+      | Course 09 | C9       | 0        |
+      | Course 10 | C10      | 0        |
+      | Course 11 | C11      | 0        |
+      | Course 12 | C12      | 0        |
+      | Course 13 | C13      | 0        |
+      | Course 14 | C14      | 0        |
+      | Course 15 | C15      | 0        |
+      | Course 16 | C16      | 0        |
+      | Course 17 | C17      | 0        |
+      | Course 18 | C18      | 0        |
+      | Course 19 | C19      | 0        |
+      | Course 20 | C20      | 0        |
+      | Course 21 | C21      | 0        |
+      | Course 22 | C22      | 0        |
+      | Course 23 | C23      | 0        |
+      | Course 24 | C24      | 0        |
+      | Course 25 | C25      | 0        |
+
+  Scenario: The pagination controls should be hidden if I am not enrolled in any courses
+    When I log in as "student1"
+    Then I should see "No courses" in the "Course overview" "block"
+    And I should not see "Show" in the "Course overview" "block"
+    And ".block_myoverview .dropdown-menu.show" "css_element" should not be visible
+    And ".block_myoverview [data-control='next']" "css_element" should not be visible
+    And ".block_myoverview [data-control='previous']" "css_element" should not be visible
+    And I log out
+
+  Scenario: The pagination controls should be hidden if I am enrolled in 12 courses or less
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+    When I log in as "student1"
+    Then I should not see "Show" in the "Course overview" "block"
+    And ".block_myoverview .dropdown-menu.show" "css_element" should not be visible
+    And ".block_myoverview [data-control='next']" "css_element" should not be visible
+    And ".block_myoverview [data-control='previous']" "css_element" should not be visible
+    And I log out
+
+  Scenario: The default pagination should be 12 courses
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+    When I log in as "student1"
+    Then I should see "12" in the "[data-action='limit-toggle']" "css_element"
+    And I log out
+
+  Scenario: I should only see pagination limit options less than total number of enrolled courses
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+    And I log in as "student1"
+    When I click on "[data-action='limit-toggle']" "css_element" in the "Course overview" "block"
+    Then I should see "All" in the ".dropdown-menu.show" "css_element"
+    And I should see "12" in the ".dropdown-menu.show" "css_element"
+    And ".block_myoverview [data-control='next']" "css_element" should be visible
+    And ".block_myoverview [data-control='previous']" "css_element" should be visible
+    But I should not see "24" in the ".block_myoverview .dropdown-menu.show" "css_element"
+    And I log out
+
+  Scenario: Previous page button should be disabled when on the first page of courses
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+    When I log in as "student1"
+    Then the "class" attribute of ".block_myoverview [data-control='previous']" "css_element" should contain "disabled"
+    And I log out
+
+  Scenario: Next page button should be disabled when on the last page of courses
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+    When I log in as "student1"
+    And I click on "[data-control='next']" "css_element" in the "Course overview" "block"
+    And I wait until ".block_myoverview [data-control='next']" "css_element" exists
+    Then the "class" attribute of ".block_myoverview [data-control='next']" "css_element" should contain "disabled"
+    And I log out
+
+  Scenario: Next and previous page buttons should both be enabled when not on last or first page of courses
+    Given the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+      | student1 | C14 | student |
+      | student1 | C15 | student |
+      | student1 | C16 | student |
+      | student1 | C17 | student |
+      | student1 | C18 | student |
+      | student1 | C19 | student |
+      | student1 | C20 | student |
+      | student1 | C21 | student |
+      | student1 | C22 | student |
+      | student1 | C23 | student |
+      | student1 | C24 | student |
+      | student1 | C25 | student |
+    When I log in as "student1"
+    And I click on "[data-control='next']" "css_element" in the "Course overview" "block"
+    And I wait until ".block_myoverview [data-control='next']" "css_element" exists
+    Then the "class" attribute of ".block_myoverview [data-control='next']" "css_element" should not contain "disabled"
+    And the "class" attribute of ".block_myoverview [data-control='previous']" "css_element" should not contain "disabled"
+    And I should see "Course 13" in the "Course overview" "block"
+    And I should see "Course 24" in the "Course overview" "block"
+    But I should not see "Course 12" in the "Course overview" "block"
+    And I should not see "Course 25" in the "Course overview" "block"
+    And I log out
index c483c28..a33a10e 100644 (file)
@@ -52,9 +52,9 @@
             <a href="{{url}}"
                title={{#quote}}{{{name}}}{{/quote}}
                aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": {{#quote}}{{{name}}}{{/quote}}, "course": {{#quote}}{{{course.fullnamedisplay}}}{{/quote}}, "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}'
-            ><h6 class="event-name text-truncate mb-0">{{#quote}}{{{name}}}{{/quote}}</h6></a>
+            ><h6 class="event-name text-truncate mb-0">{{{name}}}</h6></a>
             {{#course.fullnamedisplay}}
-            <small class="text-muted text-truncate mb-0">{{#quote}}{{{course.fullnamedisplay}}}{{/quote}}</small>
+            <small class="text-muted text-truncate mb-0">{{{course.fullnamedisplay}}}</small>
             {{/course.fullnamedisplay}}
             {{#action.actionable}}
             <h6 class="mb-0 pt-2">
index e943e9c..bfa2368 100644 (file)
@@ -116,7 +116,11 @@ class calendar_event_exporter extends event_exporter_base {
         $values['islastday'] = false;
         $today = $this->related['type']->timestamp_to_date_array($this->related['today']);
 
-        $values['popupname'] = $this->event->get_name();
+        if ($hascourse) {
+            $values['popupname'] = external_format_string($this->event->get_name(), \context_course::instance($course->id), true);
+        } else {
+            $values['popupname'] = external_format_string($this->event->get_name(), \context_system::instance(), true);
+        }
 
         $times = $this->event->get_times();
         if ($duration = $times->get_duration()) {
index dede44d..c30da88 100644 (file)
@@ -102,8 +102,13 @@ class course_handler extends \core_customfield\handler {
                     has_capability('moodle/course:changelockedcustomfields', $context));
         } else {
             $context = $this->get_parent_context();
-            return (!$field->get_configdata_property('locked') ||
-                guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context));
+            if ($context->contextlevel == CONTEXT_SYSTEM) {
+                return (!$field->get_configdata_property('locked') ||
+                    has_capability('moodle/course:changelockedcustomfields', $context));
+            } else {
+                return (!$field->get_configdata_property('locked') ||
+                    guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context));
+            }
         }
     }
 
index 5afc685..94e02aa 100644 (file)
@@ -455,7 +455,7 @@ if ($viewmode === 'default' || $viewmode === 'combined') {
     }
 }
 if ($viewmode === 'default' || $viewmode === 'combined') {
-    $class .= ' viewmode-cobmined';
+    $class .= ' viewmode-combined';
 } else {
     $class .= ' viewmode-'.$viewmode;
 }
index c2655fc..3506893 100644 (file)
@@ -38,7 +38,7 @@ Feature: Tagging courses
     And I expand all fieldsets
     Then "Mathematics" "autocomplete_suggestions" should exist
     And I set the following fields to these values:
-      | Tags | Algebra |
+      | Tags | Mathematics, Algebra |
     And I press "Save and display"
     And I am on "Course 2" course homepage
     And I navigate to "Edit settings" in current page administration
@@ -75,7 +75,7 @@ Feature: Tagging courses
     And I navigate to "Course tags" in current page administration
     Then I should see "Mathematics" in the ".form-autocomplete-selection" "css_element"
     And I set the following fields to these values:
-      | Tags | Algebra |
+      | Tags | Mathematics, Algebra |
     And I press "Save changes"
     And I am on "Course 2" course homepage
     And I navigate to "Course tags" in current page administration
index 1b3c581..00f7c81 100644 (file)
@@ -31,9 +31,11 @@ Feature: Rename roles within a course
     And "Learner" "button" should exist
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I should see "Tutor (Non-editing teacher)" in the ".form-autocomplete-suggestions" "css_element"
+    And I open the autocomplete suggestions list in the "Filter 1" "fieldset"
     And I should see "Learner (Student)" in the ".form-autocomplete-suggestions" "css_element"
+    And I press the escape key
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Tutor (Non-editing teacher)"
+
     And I click on "Student 1's role assignments" "link"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Student 1" "table_row"
     And "Tutor (Non-editing teacher)" "autocomplete_suggestions" should exist
@@ -53,6 +55,6 @@ Feature: Rename roles within a course
     And "Learner" "button" should not exist
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I open the autocomplete suggestions list in the "Filter 1" "fieldset"
     And I should see "Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
     And I should see "Student" in the ".form-autocomplete-suggestions" "css_element"
index 78cbbcd..549247c 100644 (file)
@@ -45,6 +45,10 @@ class enrol_fee_plugin extends enrol_plugin {
             $currencies[$c] = new lang_string($c, 'core_currencies');
         }
 
+        uasort($currencies, function($a, $b) {
+            return strcmp($a, $b);
+        });
+
         return $currencies;
     }
 
index cd3c979..564f69f 100644 (file)
@@ -130,18 +130,17 @@ Feature: Teacher can search and enrol users one by one into the course
   Scenario: Searching for a non-existing user
     Given I navigate to course participants
     And I press "Enrol users"
-    And I set the field "Select users" to "qwertyuiop"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    And I click on "Select users" "field"
+    And I type "qwertyuiop"
     Then I should see "No suggestions"
 
   @javascript
   Scenario: If there are less than 100 matching users, all are displayed for selection
     Given I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
-    And I click on "Student 099" item in the autocomplete list
-    Then I should see "Student 099"
+    When I click on "Select users" "field"
+    And I type "example.com"
+    Then "Student 099" "autocomplete_suggestions" should exist
 
   @javascript
   Scenario: If there are more than 100 matching users, inform there are too many.
@@ -151,8 +150,8 @@ Feature: Teacher can search and enrol users one by one into the course
       | student101  | Student   | 101      | student101@example.com  |
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    When I click on "Select users" "field"
+    And I type "example.com"
     Then I should see "Too many users (>100) to show"
 
   @javascript
@@ -161,8 +160,8 @@ Feature: Teacher can search and enrol users one by one into the course
       | maxusersperpage | 5 |
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student00"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    When I click on "Select users" "field"
+    And I type "student00"
     Then I should see "Too many users (>5) to show"
 
   @javascript
@@ -177,18 +176,18 @@ Feature: Teacher can search and enrol users one by one into the course
     When I am on "Course 001" course homepage
     Then I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student100@example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    And I click on "Select users" "field"
+    And I type "student100@example.com"
     Then I should see "student100@example.com, CITY1, GB, 1234567892, 1234567893, ABC1, ABC2"
     # Remove identity field in setting User policies
     And the following config values are set as admin:
       | showuseridentity | idnumber,email,phone1,phone2,department,institution |
-    When I am on "Course 001" course homepage
+    And I am on "Course 001" course homepage
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student100@example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
-    Then I should see "student100@example.com, 1234567892, 1234567893, ABC1, ABC2"
+    And I click on "Select users" "field"
+    And I type "student100@example.com"
+    And I should see "student100@example.com, 1234567892, 1234567893, ABC1, ABC2"
 
 # The following tests are commented out as a result of MDL-66339.
 #  @javascript
diff --git a/files/classes/archive_writer.php b/files/classes/archive_writer.php
new file mode 100644 (file)
index 0000000..415fca9
--- /dev/null
@@ -0,0 +1,142 @@
+<?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/>.
+
+/**
+ * Abstraction of general file archives.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files;
+
+use core_files\local\archive_writer\file_writer_interface as file_writer_interface;
+use core_files\local\archive_writer\stream_writer_interface as stream_writer_interface;
+
+/**
+ * Each file archive type must extend this class.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class archive_writer {
+
+    /**
+     * The zip writer class.
+     */
+    public const ZIP_WRITER = 'zip_writer';
+
+    /**
+     * Returns the stream writer.
+     *
+     * @param string $filename
+     * @param string $type
+     * @return stream_writer_interface
+     */
+    public static function get_stream_writer(string $filename, string $type): stream_writer_interface {
+        $classname = self::get_classname_for_type($type);
+
+        if (!is_a($classname, stream_writer_interface::class, true)) {
+            throw new \InvalidArgumentException("{$type} does not support streaming");
+        }
+
+        return $classname::stream_instance($filename);
+    }
+
+    /**
+     * Returns the file writer.
+     *
+     * @param string $filepath
+     * @param string $type
+     * @return file_writer_interface
+     */
+    public static function get_file_writer(string $filepath, string $type): file_writer_interface {
+        $classname = self::get_classname_for_type($type);
+
+        if (!is_a($classname, file_writer_interface::class, true)) {
+            throw new \InvalidArgumentException("{$type} does not support writing to files");
+        }
+
+        return $classname::file_instance($filepath);
+    }
+
+    /**
+     * Sanitise the file path, removing any unsuitable characters.
+     *
+     * @param string $filepath
+     * @return string
+     */
+    public function sanitise_filepath(string $filepath): string {
+        return clean_param($filepath, PARAM_PATH);
+    }
+
+    /**
+     * Returns the class name for the type that was provided in get_file_writer().
+     *
+     * @param string $type
+     * @return string
+     */
+    protected static function get_classname_for_type(string $type): string {
+        return "core_files\local\archive_writer\\" . $type;
+    }
+
+    /**
+     * The archive_writer Constructor.
+     */
+    protected function __construct() {
+
+    }
+
+    /**
+     * Adds a file from a file path.
+     *
+     * @param string $name The path of file in archive (including directory).
+     * @param string $path The path to file on disk (note: paths should be encoded using
+     *                     UNIX-style forward slashes -- e.g '/path/to/some/file').
+     */
+    abstract public function add_file_from_filepath(string $name, string $path): void;
+
+    /**
+     * Adds a file from a string.
+     *
+     * @param string $name The path of file in archive (including directory).
+     * @param string $data The contents of file
+     */
+    abstract public function add_file_from_string(string $name, string $data): void;
+
+    /**
+     * Adds a file from a stream.
+     *
+     * @param string $name The path of file in archive (including directory).
+     * @param resource $stream The contents of file as a stream resource
+     */
+    abstract public function add_file_from_stream(string $name, $stream): void;
+
+    /**
+     * Adds a stored_file to archive.
+     *
+     * @param string $name The path of file in archive (including directory).
+     * @param \stored_file $file
+     */
+    abstract public function add_file_from_stored_file(string $name, \stored_file $file): void;
+
+    /**
+     * Finish writing the zip footer.
+     */
+    abstract public function finish(): void;
+}
diff --git a/files/classes/local/archive_writer/file_writer_interface.php b/files/classes/local/archive_writer/file_writer_interface.php
new file mode 100644 (file)
index 0000000..6e2a175
--- /dev/null
@@ -0,0 +1,50 @@
+<?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/>.
+
+/**
+ * Interface used by archives that write to files.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files\local\archive_writer;
+
+/**
+ * Interface used by archives that write to files.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface file_writer_interface {
+
+    /**
+     * Return the file instance.
+     *
+     * @param string $filename
+     * @return static
+     */
+    public static function file_instance(string $filename): self;
+
+    /**
+     * Get the path of the zip.
+     *
+     * @return string
+     */
+    public function get_path_to_zip(): string;
+}
diff --git a/files/classes/local/archive_writer/stream_writer_interface.php b/files/classes/local/archive_writer/stream_writer_interface.php
new file mode 100644 (file)
index 0000000..4043580
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+/**
+ * Interface used by archives that write to streams.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files\local\archive_writer;
+
+/**
+ * Interface used by archives that write to streams.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface stream_writer_interface {
+
+    /**
+     * Return the stream instance.
+     *
+     * @param string $filename
+     * @return static
+     */
+    public static function stream_instance(string $filename): self;
+}
diff --git a/files/classes/local/archive_writer/zip_writer.php b/files/classes/local/archive_writer/zip_writer.php
new file mode 100644 (file)
index 0000000..f6187f5
--- /dev/null
@@ -0,0 +1,135 @@
+<?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/>.
+
+/**
+ * Class used for creating ZIP archives.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files\local\archive_writer;
+
+use ZipStream\Option\Archive;
+use ZipStream\ZipStream;
+use core_files\archive_writer;
+use core_files\local\archive_writer\file_writer_interface as file_writer_interface;
+use core_files\local\archive_writer\stream_writer_interface as stream_writer_interface;
+
+/**
+ * Class used for creating ZIP archives.
+ *
+ * @package   core_files
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class zip_writer extends archive_writer implements file_writer_interface, stream_writer_interface {
+
+    /**
+     * @var resource File resource for the file handle for a file-based zip stream
+     */
+    private $zipfilehandle = null;
+
+    /**
+     * @var String The location of the zip file.
+     */
+    private $zipfilepath = null;
+
+    /**
+     * @var ZipStream The zip stream.
+     */
+    private $archive;
+
+    /**
+     * The zip_writer constructor.
+     *
+     * @param ZipStream $archive
+     */
+    protected function __construct(ZipStream $archive) {
+        parent::__construct();
+        $this->archive = $archive;
+    }
+
+    public static function stream_instance(string $filename): stream_writer_interface {
+        $options = new Archive();
+        $options->setSendHttpHeaders(true);
+        $options->setContentDisposition('attachment');
+        $options->setContentType('application/x-zip');
+        $zipwriter = new ZipStream($filename, $options);
+
+        return new static($zipwriter);
+    }
+
+    public static function file_instance(string $filename): file_writer_interface {
+        $dir = make_request_directory();
+        $filepath = "$dir/$filename";
+        $fh = fopen($filepath, 'w');
+
+        $exportoptions = new Archive();
+        $exportoptions->setOutputStream($fh);
+        $exportoptions->setSendHttpHeaders(false);
+        $zipstream = new ZipStream($filename, $exportoptions);
+
+        $zipwriter = new static($zipstream);
+        // ZipStream only takes a file handle resource.
+        // It does not close this resource itself, and it does not know the location of this resource on disk.
+        // Store references to the filehandle, and the location of the filepath in the new class so that the `finish()`
+        // function can close the fh, and move the temporary file into place.
+        // The filehandle must be closed when finishing the archive. ZipStream does not close it automatically.
+        $zipwriter->zipfilehandle = $fh;
+        $zipwriter->zipfilepath = $filepath;
+
+        return $zipwriter;
+    }
+
+    public function add_file_from_filepath(string $name, string $path): void {
+        $this->archive->addFileFromPath($this->sanitise_filepath($name), $path);
+    }
+
+    public function add_file_from_string(string $name, string $data): void {
+        $this->archive->addFile($this->sanitise_filepath($name), $data);
+    }
+
+    public function add_file_from_stream(string $name, $stream): void {
+        $this->archive->addFileFromStream($this->sanitise_filepath($name), $stream);
+        fclose($stream);
+    }
+
+    public function add_file_from_stored_file(string $name, \stored_file $file): void {
+        $filehandle = $file->get_content_file_handle();
+        $this->archive->addFileFromStream($this->sanitise_filepath($name), $filehandle);
+        fclose($filehandle);
+    }
+
+    public function finish(): void {
+        $this->archive->finish();
+
+        if ($this->zipfilehandle) {
+            fclose($this->zipfilehandle);
+        }
+    }
+
+    public function get_path_to_zip(): string {
+        return $this->zipfilepath;
+    }
+
+    public function sanitise_filepath(string $filepath): string {
+        $filepath = parent::sanitise_filepath($filepath);
+
+        return \ZipStream\File::filterFilename($filepath);
+    }
+}
diff --git a/files/tests/archive_writer_test.php b/files/tests/archive_writer_test.php
new file mode 100644 (file)
index 0000000..544f58e
--- /dev/null
@@ -0,0 +1,54 @@
+<?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 core_files\local\archive_writer/zip_archive.
+ *
+ * @package core_files
+ * @category test
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+namespace core_files;
+
+use advanced_testcase;
+use core_files\local\archive_writer\zip_writer;
+
+/**
+ * Unit tests for \core_files\archive_writer.
+ *
+ * @coversDefaultClass \core_files\archive_writer
+ */
+class archive_writer_testcase extends advanced_testcase {
+
+    /**
+     * Test get_file_writer().
+     */
+    public function test_get_file_writer(): void {
+        $zipwriter = archive_writer::get_file_writer('file.zip', archive_writer::ZIP_WRITER);
+        $this->assertInstanceOf(zip_writer::class, $zipwriter);
+        $this->assertTrue(file_exists($zipwriter->get_path_to_zip()));
+    }
+
+    /**
+     * Test get_stream_writer().
+     */
+    public function test_get_stream_writer(): void {
+        $zipwriter = archive_writer::get_stream_writer('path/to/file.txt', archive_writer::ZIP_WRITER);
+        $this->assertInstanceOf(zip_writer::class, $zipwriter);
+    }
+}
diff --git a/files/tests/fixtures/awesome_file.txt b/files/tests/fixtures/awesome_file.txt
new file mode 100644 (file)
index 0000000..61193b6
--- /dev/null
@@ -0,0 +1 @@
+Hey, this is an awesome text file. Hello! :)
\ No newline at end of file
diff --git a/files/tests/local/archive_writer/zip_writer_test.php b/files/tests/local/archive_writer/zip_writer_test.php
new file mode 100644 (file)
index 0000000..790d8d1
--- /dev/null
@@ -0,0 +1,187 @@
+<?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 \core_files\local\archive_writer\zip_writer.
+ *
+ * @package core_files
+ * @category test
+ * @copyright 2020 Mark Nelson <mdjnelson@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+namespace core_files\local\archive_writer;
+
+use advanced_testcase;
+use context_module;
+use core_files\archive_writer;
+use ZipArchive;
+
+/**
+ * Unit tests for \core_files\local\archive_writer\zip_writer.
+ *
+ * @coversDefaultClass \core_files\local\archive_writer\zip_writer
+ */
+class zip_writer_testcase extends advanced_testcase {
+
+    /**
+     * Test add_file_from_filepath().
+     */
+    public function test_add_file_from_filepath(): void {
+        global $CFG;
+
+        $pathtofileinzip = '/some/made/up/name.txt';
+        $filetoadd = $CFG->dirroot . '/files/tests/fixtures/awesome_file.txt';
+
+        $zipwriter = archive_writer::get_file_writer('test.zip', archive_writer::ZIP_WRITER);
+        $zipwriter->add_file_from_filepath($pathtofileinzip, $filetoadd);
+        $zipwriter->finish();
+
+        $pathtozip = $zipwriter->get_path_to_zip();
+        $zip = new ZipArchive();
+        $opened = $zip->open($pathtozip);
+        $this->assertTrue($opened);
+
+        $pathtofileinzip = $zipwriter->sanitise_filepath($pathtofileinzip);
+
+        $this->assertEquals("Hey, this is an awesome text file. Hello! :)", $zip->getFromName($pathtofileinzip));
+    }
+
+    /**
+     * Test add_file_from_string().
+     */
+    public function test_add_file_from_string(): void {
+        $pathtofileinzip = "/path/to/my/awesome/file.txt";
+        $mycontent = "This is some real awesome content, ya dig?";
+
+        $zipwriter = archive_writer::get_file_writer('test.zip', archive_writer::ZIP_WRITER);
+        $zipwriter->add_file_from_string($pathtofileinzip, $mycontent);
+        $zipwriter->finish();
+
+        $pathtozip = $zipwriter->get_path_to_zip();
+        $zip = new ZipArchive();
+        $opened = $zip->open($pathtozip);
+        $this->assertTrue($opened);
+
+        $pathtofileinzip = $zipwriter->sanitise_filepath($pathtofileinzip);
+
+        $this->assertEquals($mycontent, $zip->getFromName($pathtofileinzip));
+    }
+
+    /**
+     * Test add_file_from_stream().
+     */
+    public function test_add_file_from_stream(): void {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
+
+        // Add a file to the intro.
+        $filerecord = [
+            'contextid' => context_module::instance($assign->cmid)->id,
+            'component' => 'mod_assign',
+            'filearea'  => 'intro',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => 'fileintro.txt',
+        ];
+        $fs = get_file_storage();
+        $storedfile = $fs->create_file_from_string($filerecord, 'Contents for the assignment, yeow!');
+
+        $pathtofileinzip = $storedfile->get_filepath() . $storedfile->get_filename();
+
+        $zipwriter = archive_writer::get_file_writer('test.zip', archive_writer::ZIP_WRITER);
+        $zipwriter->add_file_from_stream($pathtofileinzip, $storedfile->get_content_file_handle());
+        $zipwriter->finish();
+
+        $pathtozip = $zipwriter->get_path_to_zip();
+        $zip = new ZipArchive();
+        $opened = $zip->open($pathtozip);
+        $this->assertTrue($opened);
+
+        $pathtofileinzip = $zipwriter->sanitise_filepath($pathtofileinzip);
+
+        $this->assertEquals($storedfile->get_content(), $zip->getFromName($pathtofileinzip));
+    }
+
+    /**
+     * Test add_file_from_stored_file().
+     */
+    public function test_add_file_from_stored_file(): void {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
+
+        // Add a file to the intro.
+        $filerecord = [
+            'contextid' => context_module::instance($assign->cmid)->id,
+            'component' => 'mod_assign',
+            'filearea'  => 'intro',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => 'fileintro.txt',
+        ];
+        $fs = get_file_storage();
+        $storedfile = $fs->create_file_from_string($filerecord, 'Contents for the assignment, yeow!');
+
+        $pathtofileinzip = $storedfile->get_filepath() . $storedfile->get_filename();
+
+        $zipwriter = archive_writer::get_file_writer('test.zip', archive_writer::ZIP_WRITER);
+        $zipwriter->add_file_from_stored_file($pathtofileinzip, $storedfile);
+        $zipwriter->finish();
+
+        $pathtozip = $zipwriter->get_path_to_zip();
+        $zip = new ZipArchive();
+        $opened = $zip->open($pathtozip);
+        $this->assertTrue($opened);
+
+        $pathtofileinzip = $zipwriter->sanitise_filepath($pathtofileinzip);
+
+        $this->assertEquals($storedfile->get_content(), $zip->getFromName($pathtofileinzip));
+    }
+
+    /**
+     * Test sanitise_filepath().
+     *
+     * @param string $providedfilepath The provided file path.
+     * @param string $expectedfilepath The expected file path.
+     * @dataProvider sanitise_filepath_provider
+     */
+    public function test_sanitise_filepath(string $providedfilepath, string $expectedfilepath): void {
+        $zipwriter = archive_writer::get_stream_writer('path/to/file.txt', archive_writer::ZIP_WRITER);
+        $this->assertEquals($expectedfilepath, $zipwriter->sanitise_filepath($providedfilepath));
+    }
+
+    /**
+     * Data provider for test_sanitise_filepath.
+     *
+     * @return array
+     */
+    public function sanitise_filepath_provider(): array {
+        return [
+            ['a../../file/path', 'a../file/path'],
+            ['a./file/path', 'a./file/path'],
+            ['../file/path', 'file/path'],
+            ['foo/bar/', 'foo/bar/'],
+            ['\\\\\\a\\\\\\file\\\\\\path', 'a/file/path'],
+            ['//a//file/////path////', 'a/file/path/']
+        ];
+    }
+}
index 460fbb9..e45913b 100644 (file)
 }
 
 .gradingform_rubric .criterion .description {
-    width: 150px;
+    min-width: 150px;
     font-weight: bold;
 }
 
index 7e50089..2b4a65a 100644 (file)
@@ -50,15 +50,12 @@ Feature: Organize students into groups
     And the "members" select box should not contain "Student 1 (student1@example.com)"
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group 1" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group 1"
     And I click on "Apply filters" "button"
     And I should see "Student 0"
     And I should see "Student 1"
     And I should not see "Student 2"
-    And I click on "Group 1" "autocomplete_selection"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group 2" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group 2"
     And I click on "Apply filters" "button"
     And I should see "Student 2"
     And I should see "Student 3"
index e63ee05..087fecb 100644 (file)
@@ -46,8 +46,7 @@ Feature: The description of a group can be viewed by students and teachers
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group B" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group B"
     And I click on "Apply filters" "button"
     And I click on "Student 2" "link" in the "participants" "table"
     And I click on "Group B" "link"
@@ -96,8 +95,7 @@ Feature: The description of a group can be viewed by students and teachers
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group B" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group B"
     And I click on "Apply filters" "button"
     And I click on "Student 2" "link" in the "participants" "table"
     And I click on "Group B" "link"
index 933e7fd..c712eed 100644 (file)
@@ -29,5 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = '';
 $string['thisdirection'] = 'ltr';
 $string['thislanguage'] = 'Español - Internacional';
index 98e3ffb..069ca73 100644 (file)
@@ -32,7 +32,7 @@ defined('MOODLE_INTERNAL') || die();
 $string['admindirname'] = 'Adminmappe';
 $string['availablelangs'] = 'Tilgjengelige språkpakker';
 $string['chooselanguagehead'] = 'Velg et språk';
-$string['chooselanguagesub'] = 'Velg språk (bare for INSTALLASJONEN). Du vil kunne velge språk for nettsted og bruker på et skjermbilde senere.';
+$string['chooselanguagesub'] = 'Velg et språk for installasjonen. Dette språket vil også bli brukt som standardspråk for nettstedet, men det kan endres senere.';
 $string['clialreadyconfigured'] = 'Konfigurasjonsfilen config.php finnes allerede. Vennligst bruk admin/cli/install_database.php hvis du vil installere Moodle på denne portalen.';
 $string['clialreadyinstalled'] = 'Filen config.php eksisterer allerede. Vennligst bruk admin/cli/install_database.php hvis du vil oppgradere Moodle på denne portalen.';
 $string['cliinstallheader'] = 'Moodle {$a} kommandolinje installasjonsprogram';
@@ -47,7 +47,7 @@ $string['environmenthead'] = 'Sjekker "omgivelsene" dine...';
 $string['environmentsub2'] = 'Hver Moodle-utgivelse har minimumskrav til PHP versjon og et sett med obligatoriske PHP-tillegg. En full sjekk av omgivelser gjøres før hver installering og oppgradering. Vær vennlig å kontakte serveradministrator hvis du ikke vet hvordan du skal installere nye versjoner eller slå på PHP-tillegg.';
 $string['errorsinenvironment'] = 'Omgivelseskontroll feilet!';
 $string['installation'] = 'Installasjon';
-$string['langdownloaderror'] = 'Beklageligvis ble ikke språkpakken "{$a}" installert. Installasjonsprosessen vil fortsette på Engelsk.';
+$string['langdownloaderror'] = 'Beklageligvis kunne ikke språkpakken "{$a}" lastes ned. Installasjonsprosessen vil fortsette på Engelsk.';
 $string['memorylimithelp'] = '<p>PHP minnegrensen for serveren din er nå satt til {$a}.</p>
 <p>Dette kan skape minneproblemer for Moodle senere, spesielt hvis du har mange moduler tillatt og/eller mange brukere.</p>
 <p>Vi anbefaler at du konfigurer PHP med en høyere grense enn mulig, for eksepmel 40M. Det er flere måter å gjøre dette på.:</p>
index fdb59b1..a9dc958 100644 (file)
@@ -49,7 +49,7 @@ Verificarea completă a mediului se face înainte de fiecare instalare și upgra
 $string['errorsinenvironment'] = 'Verificarea mediului eșuată!';
 $string['installation'] = 'Instalare';
 $string['langdownloaderror'] = 'Din păcate, limba "{$a}" nu a putut fi descărcată. Procesul de instalare va continua în limba engleză.';
-$string['memorylimithelp'] = '<p> Limita de memorie PHP pentru serverul dvs. este setată în prezent la {$ a}. </p>
+$string['memorylimithelp'] = '<p> Limita de memorie PHP pentru serverul dvs. este setată în prezent la {$a}. </p>
 
 <p> Acest lucru poate determina Moodle să aibă probleme de memorie mai târziu, în special
    dacă aveți o mulțime de module activate și / sau o mulțime de utilizatori. </p>
@@ -90,7 +90,7 @@ $string['pathswrongadmindir'] = 'Directorul admin nu există';
 $string['phpextension'] = 'extensie PHP {$a}';
 $string['phpversion'] = 'Versiune PHP';
 $string['phpversionhelp'] = '<p> Moodle necesită o versiune PHP de cel puțin 5.6.5 sau 7.1 (7.0.x are unele limitări ale motorului). </p>
-<p> În prezent executați versiunea {$ a}. </p>
+<p> În prezent executați versiunea {$a}. </p>
 <p> Trebuie să faceți upgrade PHP sau să vă mutați la o gazdă cu o versiune mai nouă de PHP. </p>';
 $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'Vedeți această pagină deoarece ați instalat și lansat cu succes pachetul  <strong>{$a->packname} {$a->packversion}</strong> în computerul dumneavoastră. Felicitări!';
index 94ca6e0..8c7a79d 100644 (file)
@@ -29,5 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = '';
 $string['thisdirection'] = 'ltr';
 $string['thislanguage'] = 'Română';
index a4ab49e..1473fdc 100644 (file)
@@ -31,4 +31,4 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['parentlanguage'] = 'ro';
 $string['thisdirection'] = 'ltr';
-$string['thislanguage'] = 'Română';
+$string['thislanguage'] = 'Română Workplace';
index 7434b4e..aa6b691 100644 (file)
@@ -45,7 +45,7 @@ $string['dmlexceptiononinstall'] = '<p>Ett databasfel har inträffat [{$a->error
 $string['downloadedfilecheckfailed'] = 'Det gick inte att kontrollera den nedladdade filen';
 $string['invalidmd5'] = 'Kontrollvariabeln var felaktig - försök igen';
 $string['missingrequiredfield'] = 'Det saknas några obligatoriska fält';
-$string['remotedownloaderror'] = 'Nedladdningen av en komponent till din server misslyckades, var snäll och verifiera inställningar för proxy. <br /><br />Du måste ladda ner <a href="{$a->url}">{$a->url}</a> filen manuellt, kopiera den till "{$a->dest}" på din server och packa upp den där.';
+$string['remotedownloaderror'] = 'Nedladdningen av en komponent till din server misslyckades. Vänligen verifiera inställningar för proxy. <br /><br />Du måste ladda ner <a href="{$a->url}">{$a->url}</a> filen manuellt, kopiera den till "{$a->dest}" på din server och packa upp den där.';
 $string['wrongdestpath'] = 'Fel sökväg';
 $string['wrongsourcebase'] = 'Fel bas-URL till källan';
 $string['wrongzipfilename'] = 'Fel namn på ZIP-filen';
index 01e1eb1..cb25140 100644 (file)
@@ -32,8 +32,8 @@ defined('MOODLE_INTERNAL') || die();
 $string['admindirname'] = 'Katalog/mapp för administration';
 $string['availablelangs'] = 'Tillgängliga språkpaket';
 $string['chooselanguagehead'] = 'Välj ett språk';
-$string['chooselanguagesub'] = 'Var snäll och välj ett språk  för installationen. Du kommer att ha möjlighet att välja språk för webbplatsen och användarna på en senare skärm.';
-$string['clialreadyinstalled'] = 'Filen config.php finns redan, var snäll och använd admin/cli/upgrade.php om Du vill uppgradera Din webbplats.';
+$string['chooselanguagesub'] = 'Vänligen välj ett språk för installationen. Du kommer att ha möjlighet att välja språk för webbplatsen och användarna på en senare skärm.';
+$string['clialreadyinstalled'] = 'Filen config.php finns redan. Vänligen använd admin/cli/upgrade.php om Du vill uppgradera Din webbplats.';
 $string['cliinstallheader'] = 'Installationsprogram av typ kommandorad {$a} för Moodle ';
 $string['databasehost'] = 'Värd för databas';
 $string['databasename'] = 'Namn på databas';
index 4e1f6c3..92781b5 100644 (file)
@@ -348,6 +348,8 @@ $string['configsessioncookie'] = 'This setting customises the name of the cookie
 $string['configsessioncookiedomain'] = 'This allows you to change the domain that the Moodle cookies are available from. This is useful for Moodle customisations (e.g. authentication or enrolment plugins) that need to share Moodle session information with a web application on another subdomain. <strong>WARNING: it is strongly recommended to leave this setting at the default (empty) - an incorrect value will prevent all logins to the site.</strong>';
 $string['configsessioncookiepath'] = 'If you need to change where browsers send the Moodle cookies, you can change this setting to specify a subdirectory of your web site.  Otherwise the default \'/\' should be fine.';
 $string['configsessiontimeout'] = 'If people logged in to this site are idle for a long time (without loading pages) then they are automatically logged out (their session is ended).  This variable specifies how long this time should be.';
+$string['configsessiontimeoutwarning'] = 'If people logged in to this site are idle for a long time (without loading pages) then they are warned about their session is about to end.  This variable specifies how long this time should be.';
+$string['configsessiontimeoutwarningcheck'] = 'Session timeout warning must be less than session timeout';
 $string['configshowicalsource'] = 'Show source information for iCal events';
 $string['configshowcommentscount'] = 'Show comments count, it will cost one more query when display comments link';
 $string['configshowsiteparticipantslist'] = 'All of these site students and site teachers will be listed on the site participants list. Who shall be allowed to see this site participants list?';
@@ -1191,6 +1193,7 @@ $string['sessioncookiedomain'] = 'Cookie domain';
 $string['sessioncookiepath'] = 'Cookie path';
 $string['sessionhandling'] = 'Session handling';
 $string['sessiontimeout'] = 'Timeout';
+$string['sessiontimeoutwarning'] = 'Timeout Warning';
 $string['settingdependenton'] = 'This setting may be hidden, based on the value of <strong>{$a}</strong>.';
 $string['settingfileuploads'] = 'File uploading is required for normal operation, please enable it in PHP configuration.';
 $string['settingmemorylimit'] = 'Insufficient memory detected, please set higher memory limit in PHP settings.';
index 8fbcca4..95c040d 100644 (file)
@@ -1180,6 +1180,7 @@ $string['loginstepsnone'] = '<p>Hi!</p>
 <p>All you need to do is make up a username and password and use it in the form on this page!</p>
 <p>If someone else has already chosen your username then you\'ll have to try again using a different username.</p>';
 $string['loginto'] = 'Log in to {$a}';
+$string['loginagain'] = 'Log in again';
 $string['logout'] = 'Log out';
 $string['logoutconfirm'] = 'Do you really want to log out?';
 $string['logs'] = 'Logs';
index ff74c9a..1177b59 100644 (file)
@@ -3814,6 +3814,8 @@ class admin_setting_configduration extends admin_setting {
 
     /** @var int default duration unit */
     protected $defaultunit;
+    /** @var callable|null Validation function */
+    protected $validatefunction = null;
 
     /**
      * Constructor
@@ -3837,6 +3839,36 @@ class admin_setting_configduration extends admin_setting {
         parent::__construct($name, $visiblename, $description, $defaultsetting);
     }
 
+    /**
+     * Sets a validate function.
+     *
+     * The callback will be passed one parameter, the new setting value, and should return either
+     * an empty string '' if the value is OK, or an error message if not.
+     *
+     * @param callable|null $validatefunction Validate function or null to clear
+     * @since Moodle 3.10
+     */
+    public function set_validate_function(?callable $validatefunction = null) {
+        $this->validatefunction = $validatefunction;
+    }
+
+    /**
+     * Validate the setting. This uses the callback function if provided; subclasses could override
+     * to carry out validation directly in the class.
+     *
+     * @param int $data New value being set
+     * @return string Empty string if valid, or error message text
+     * @since Moodle 3.10
+     */
+    protected function validate_setting(int $data): string {
+        // If validation function is specified, call it now.
+        if ($this->validatefunction) {
+            return call_user_func($this->validatefunction, $data);
+        } else {
+            return '';
+        }
+    }
+
     /**
      * Returns selectable units.
      * @static
@@ -3922,6 +3954,12 @@ class admin_setting_configduration extends admin_setting {
             return get_string('errorsetting', 'admin');
         }
 
+        // Validate the new setting.
+        $error = $this->validate_setting($seconds);
+        if ($error) {
+            return $error;
+        }
+
         $result = $this->config_write($this->name, $seconds);
         return ($result ? '' : get_string('errorsetting', 'admin'));
     }
index 23e42cb..71d7b14 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 5c1fe60..89cf819 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
index 81e4444..680602f 100644 (file)
Binary files a/lib/amd/build/network.min.js and b/lib/amd/build/network.min.js differ
index b2204e1..df849d1 100644 (file)
Binary files a/lib/amd/build/network.min.js.map and b/lib/amd/build/network.min.js.map differ
index 349aade..6843e1b 100644 (file)
Binary files a/lib/amd/build/paged_content_factory.min.js and b/lib/amd/build/paged_content_factory.min.js differ
index 9c4ea67..e541e28 100644 (file)
Binary files a/lib/amd/build/paged_content_factory.min.js.map and b/lib/amd/build/paged_content_factory.min.js.map differ
index f7f0f21..ffe8b07 100644 (file)
@@ -1173,6 +1173,9 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
                 originalSelect.hide();
                 var container = originalSelect.parent();
 
+                // Ensure that the data-fieldtype is set for behat.
+                input.find('input').attr('data-fieldtype', 'autocomplete');
+
                 container.append(layout);
                 container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
                 container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
index 5e21a6a..fcf1a0b 100644 (file)
@@ -32,14 +32,66 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
     var sessionTimeout = false;
     // 1/10 of session timeout, max of 10 minutes.
     var checkFrequency = Math.min((Config.sessiontimeout / 10), 600) * 1000;
-    // 1/5 of sessiontimeout.
-    var warningLimit = checkFrequency * 2;
-
+    // Check if sessiontimeoutwarning is set or double the checkFrequency.
+    var warningLimit = (Config.sessiontimeoutwarning > 0) ? (Config.sessiontimeoutwarning * 1000) : (checkFrequency * 2);
+    // First wait is minimum of remaining time or half of the session timeout.
+    var firstWait = (Config.sessiontimeoutwarning > 0) ?
+        Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5;
     /**
      * The session time has expired - we can't extend it now.
+     * @param {Modal} modal
      */
-    var timeoutSessionExpired = function() {
+    var timeoutSessionExpired = function(modal) {
         sessionTimeout = true;
+        warningDisplayed = false;
+        closeModal(modal);
+        displaySessionExpired();
+    };
+
+    /**
+     * Close modal - this relies on modal object passed from Notification.confirm.
+     *
+     * @param {Modal} modal
+     */
+    var closeModal = function(modal) {
+        modal.destroy();
+    };
+
+    /**
+     * The session time has expired - we can't extend it now.
+     * @return {Promise}
+     */
+    var displaySessionExpired = function() {
+        // Check again if its already extended before displaying session expired popup in case multiple tabs are open.
+        var request = {
+            methodname: 'core_session_time_remaining',
+            args: { }
+        };
+
+        return Ajax.call([request], true, true, true)[0].then(function(args) {
+            if (args.timeremaining * 1000 > warningLimit) {
+                return false;
+            } else {
+                return Str.get_strings([
+                    {key: 'sessionexpired', component: 'error'},
+                    {key: 'sessionerroruser', component: 'error'},
+                    {key: 'loginagain', component: 'moodle'},
+                    {key: 'cancel', component: 'moodle'}
+                ]).then(function(strings) {
+                    Notification.confirm(
+                        strings[0], // Title.
+                        strings[1], // Message.
+                        strings[2], // Login Again.
+                        strings[3], // Cancel.
+                        function() {
+                            location.reload();
+                            return true;
+                        }
+                    );
+                    return true;
+                }).catch(Notification.exception);
+            }
+        });
     };
 
     /**
@@ -55,23 +107,14 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
 
         if (sessionTimeout) {
             // We timed out before we extended the session.
-            return Str.get_strings([
-                {key: 'sessionexpired', component: 'error'},
-                {key: 'sessionerroruser', component: 'error'}
-            ]).then(function(strings) {
-                Notification.alert(
-                    strings[0], // Title.
-                    strings[1] // Message.
-                );
-                return true;
-            }).fail(Notification.exception);
+            return displaySessionExpired();
         } else {
             return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() {
                 if (keepAliveFrequency > 0) {
                     setTimeout(touchSession, keepAliveFrequency);
                 }
                 return true;
-            }).fail(function() {
+            }).catch(function() {
                 Notification.alert('', keepAliveMessage);
             });
         }
@@ -88,27 +131,14 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
             methodname: 'core_session_time_remaining',
             args: { }
         };
-
         sessionTimeout = false;
         return Ajax.call([request], true, true, true)[0].then(function(args) {
             if (args.userid <= 0) {
                 return false;
             }
-            if (args.timeremaining < 0) {
-                Str.get_strings([
-                    {key: 'sessionexpired', component: 'error'},
-                    {key: 'sessionerroruser', component: 'error'}
-                ]).then(function(strings) {
-                    Notification.alert(
-                        strings[0], // Title.
-                        strings[1] // Message.
-                    );
-                    return true;
-                }).fail(Notification.exception);
-
-            } else if (args.timeremaining * 1000 < warningLimit && !warningDisplayed) {
-                // If we don't extend the session before the timeout - warn.
-                setTimeout(timeoutSessionExpired, args.timeremaining * 1000);
+            if (args.timeremaining <= 0) {
+                return displaySessionExpired();
+            } else if (args.timeremaining * 1000 <= warningLimit && !warningDisplayed) {
                 warningDisplayed = true;
                 Str.get_strings([
                     {key: 'norecentactivity', component: 'moodle'},
@@ -116,7 +146,7 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
                     {key: 'extendsession', component: 'moodle'},
                     {key: 'cancel', component: 'moodle'}
                 ]).then(function(strings) {
-                    Notification.confirm(
+                     return Notification.confirm(
                         strings[0], // Title.
                         strings[1], // Message.
                         strings[2], // Extend session.
@@ -124,17 +154,20 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
                         function() {
                             touchSession();
                             warningDisplayed = false;
-                            // First wait is half the session timeout.
-                            setTimeout(checkSession, checkFrequency * 5);
+                            // First wait is minimum of remaining time or half of the session timeout.
+                            setTimeout(checkSession, firstWait);
                             return true;
                         },
                         function() {
-                            warningDisplayed = false;
+                            // User has cancelled notification.
                             setTimeout(checkSession, checkFrequency);
                         }
                     );
-                    return true;
-                }).fail(Notification.exception);
+                }).then(modal => {
+                    // If we don't extend the session before the timeout - warn.
+                    setTimeout(timeoutSessionExpired, args.timeremaining * 1000, modal);
+                    return;
+                }).catch(Notification.exception);
             } else {
                 setTimeout(checkSession, checkFrequency);
             }
@@ -151,8 +184,8 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
         if (keepAliveFrequency > 0) {
             setTimeout(touchSession, keepAliveFrequency);
         } else {
-            // First wait is half the session timeout.
-            setTimeout(checkSession, checkFrequency * 5);
+            // First wait is minimum of remaining time or half of the session timeout.
+            setTimeout(checkSession, firstWait);
         }
     };
 
index 577ccec..359e16a 100644 (file)
@@ -166,9 +166,11 @@ function(
      * @return {int|array}
      */
     var buildItemsPerPagePagingBarContext = function(itemsPerPage) {
+        var context = [];
+
         if ($.isArray(itemsPerPage)) {
             // Convert the array into a format accepted by the template.
-            var context = itemsPerPage.map(function(num) {
+            context = itemsPerPage.map(function(num) {
                 if (typeof num === 'number') {
                     // If the item is just a plain number then convert it into
                     // an object with value and active keys.
@@ -190,11 +192,12 @@ function(
             if (!activeItems.length) {
                 context[0].active = true;
             }
-
-            return context;
         } else {
-            return itemsPerPage;
+            // Convert the integer into a format accepted by the template.
+            context = [{value: itemsPerPage, active: true}];
         }
+
+        return context;
     };
 
     /**
index 1a807a2..193d2af 100644 (file)
@@ -101,7 +101,6 @@ class behat_field_manager {
      * @return behat_form_field
      */
     public static function get_field_instance($type, NodeElement $fieldnode, Session $session) {
-
         global $CFG;
 
         // If the field is not part of a moodleform, we should still try to find out
@@ -152,6 +151,10 @@ class behat_field_manager {
             $type = $fieldnode->getAttribute('type');
             switch ($type) {
                 case 'text':
+                    if ($fieldtype = $fieldnode->getAttribute('data-fieldtype')) {
+                        return self::normalise_fieldtype($fieldtype);
+                    }
+                    return 'text';
                 case 'password':
                 case 'email':
                 case 'file':
index 238bbee..b8144f0 100644 (file)
@@ -48,43 +48,75 @@ class behat_form_autocomplete extends behat_form_text {
             throw new coding_exception('Setting the value of an autocomplete field requires javascript.');
         }
 
-        // Set the value of the autocomplete's input.
-        // If this autocomplete offers suggestions then these should be fetched by setting the value and waiting for the
-        // JS to finish fetching those suggestions.
+        // Clear all current selections.
+        $rootnode = $this->field->getParent()->getParent();
+        $selections = $rootnode->findAll('css', '.form-autocomplete-selection [role=option]');
+        foreach (array_reverse($selections) as $selection) {
+            $selection->click();
+            $this->wait_for_pending_js();
+        }
 
-        $istagelement = $this->field->hasAttribute('data-tags') && $this->field->getAttribute('data-tags');
+        $allowscreation = $this->field->hasAttribute('data-tags') && !empty($this->field->getAttribute('data-tags'));
+        $hasmultiple = $this->field->hasAttribute('data-multiple') && !empty($this->field->getAttribute('data-multiple'));
 
-        if ($istagelement && false !== strpos($value, ',')) {
-            // Commas have a special meaning as a value separator in 'tag' autocomplete elements.
+        if ($hasmultiple && false !== strpos($value, ',')) {
+            // Commas have a special meaning as a value separator in 'multiple' autocomplete elements.
             // To handle this we break the value up by comma, and enter it in chunks.
             $values = explode(',', $value);
 
             while ($value = array_shift($values)) {
-                $this->set_value($value);
+                $this->add_value(trim($value), $allowscreation);
             }
         } else {
-            $this->field->setValue($value);
-            $this->wait_for_pending_js();
+            $this->add_value(trim($value), $allowscreation);
+        }
+    }
 
-            // If the autocomplete found suggestions, then it will have:
-            // 1) marked itself as expanded; and
-            // 2) have an aria-selected suggestion in the list.
-            $expanded = $this->field->getAttribute('aria-expanded');
-            $suggestion = $this->field->getParent()->find('css', '.form-autocomplete-suggestions > [aria-selected="true"]');
-
-            if ($expanded && null !== $suggestion) {
-                // A suggestion was found.
-                // Click on the first item in the list.
-                $suggestion->click();
-            } else {
-                // Press the return key to create a new tag.
-                behat_base::type_keys($this->session, [behat_keys::ENTER]);
-            }
-            $this->wait_for_pending_js();
+    /**
+     * Add a value to the autocomplete.
+     *
+     * @param   string $value
+     * @param   bool $allowscreation
+     */
+    protected function add_value(string $value, bool $allowscreation): void {
+        $value = trim($value);
 
-            // Press the escape to close the autocomplete suggestions list.
-            behat_base::type_keys($this->session, [behat_keys::ESCAPE]);
-            $this->wait_for_pending_js();
+        // Click into the field.
+        $this->field->click();
+
+        // Remove any existing text.
+        do {
+            behat_base::type_keys($this->session, [behat_keys::BACKSPACE, behat_keys::DELETE]);
+        } while (strlen($this->field->getValue()) > 0);
+        $this->wait_for_pending_js();
+
+        // Type in the new value.
+        behat_base::type_keys($this->session, str_split($value));
+        $this->wait_for_pending_js();
+
+        // If the autocomplete found suggestions, then it will have:
+        // 1) marked itself as expanded; and
+        // 2) have an aria-selected suggestion in the list.
+        $expanded = $this->field->getAttribute('aria-expanded');
+        $suggestion = $this->field->getParent()->getParent()->find('css', '.form-autocomplete-suggestions > [aria-selected="true"]');
+
+        if ($expanded && null !== $suggestion) {
+            // A suggestion was found.
+            // Click on the first item in the list.
+            $suggestion->click();
+        } else if ($allowscreation) {
+            // Press the return key to create a new entry.
+            behat_base::type_keys($this->session, [behat_keys::ENTER]);
+        } else {
+            throw new \InvalidArgumentException(
+                "Unable to find '{$value}' in the list of options, and unable to create a new option"
+            );
         }
+
+        $this->wait_for_pending_js();
+
+        // Press the escape to close the autocomplete suggestions list.
+        behat_base::type_keys($this->session, [behat_keys::ESCAPE]);
+        $this->wait_for_pending_js();
     }
 }
index 574ea94..6c49aaa 100644 (file)
@@ -473,7 +473,7 @@ class client extends \oauth2_client {
 
         // Store the access token and, if provided by the server, the new refresh token.
         $this->store_token($receivedtokens['access_token']);
-        if (isset($receivedtokens['refreshtoken'])) {
+        if (isset($receivedtokens['refresh_token'])) {
             $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
             $systemaccount->update();
         }
index 1db678b..023193d 100644 (file)
@@ -84,9 +84,10 @@ class notification implements \renderable, \templatable {
      * Notification constructor.
      *
      * @param string $message the message to print out
-     * @param string $messagetype one of the NOTIFY_* constants..
+     * @param ?string $messagetype one of the NOTIFY_* constants..
+     * @param bool $closebutton Whether to show a close icon to remove the notification (default true).
      */
-    public function __construct($message, $messagetype = null) {
+    public function __construct($message, $messagetype = null, $closebutton = true) {
         $this->message = $message;
 
         if (empty($messagetype)) {
@@ -94,6 +95,8 @@ class notification implements \renderable, \templatable {
         }
 
         $this->messagetype = $messagetype;
+
+        $this->closebutton = $closebutton;
     }
 
     /**
index 559c68f..befe2af 100644 (file)
@@ -229,6 +229,7 @@ class manager {
         $record = self::record_from_scheduled_task($task);
         $record->id = $original->id;
         $record->nextruntime = $task->get_next_scheduled_time();
+        unset($record->lastruntime);
         $result = $DB->update_record('task_scheduled', $record);
 
         return $result;
@@ -679,7 +680,6 @@ class manager {
 
         $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
                   AND (nextruntime IS NULL OR nextruntime < :timestart2)
-                  AND disabled = 0
                   ORDER BY lastruntime, id ASC";
         $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
         $records = $DB->get_records_select('task_scheduled', $where, $params);
@@ -688,14 +688,15 @@ class manager {
 
         foreach ($records as $record) {
 
+            $task = self::scheduled_task_from_record($record);
+            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
+            // Also check to see if task is disabled or enabled after applying overrides.
+            if (!$task || $task->get_disabled()) {
+                continue;
+            }
+
             if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
                 $classname = '\\' . $record->classname;
-                $task = self::scheduled_task_from_record($record);
-                // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
-                if (!$task) {
-                    $lock->release();
-                    continue;
-                }
 
                 $task->set_lock($lock);
 
index 4190bf2..0cf405b 100644 (file)
@@ -182,7 +182,7 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
 
         try {
             $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits);
-        } catch (Exception $e) {
+        } catch (\Throwable $e) {
             if ($adhoclock) {
                 // Release the adhoc task runner lock.
                 $adhoclock->release();
@@ -260,7 +260,7 @@ function cron_run_inner_scheduled_task(\core\task\task_base $task) {
         }
         mtrace('Scheduled task complete: ' . $fullname);
         \core\task\manager::scheduled_task_complete($task);
-    } catch (Exception $e) {
+    } catch (\Throwable $e) {
         if ($DB && $DB->is_transaction_started()) {
             error_log('Database transaction aborted automatically in ' . get_class($task));
             $DB->force_transaction_rollback();
@@ -348,7 +348,7 @@ function cron_run_inner_adhoc_task(\core\task\adhoc_task $task) {
         }
         mtrace("Adhoc task complete: " . get_class($task));
         \core\task\manager::adhoc_task_complete($task);
-    } catch (Exception $e) {
+    } catch (\Throwable $e) {
         if ($DB && $DB->is_transaction_started()) {
             error_log('Database transaction aborted automatically in ' . get_class($task));
             $DB->force_transaction_rollback();
index 40a154d..1b50ada 100644 (file)
@@ -1795,7 +1795,7 @@ abstract class moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $dataobject A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @param bool $bulk Set to true is multiple inserts are expected
      * @return bool|int true or new id
index 4459502..13eceb3 100644 (file)
@@ -1374,7 +1374,7 @@ class mysqli_native_moodle_database extends moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $data A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @return bool|int true or new id
      * @throws dml_exception A DML specific exception is thrown for any errors.
index ce5ae22..3230f34 100644 (file)
@@ -1284,7 +1284,7 @@ class oci_native_moodle_database extends moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $data A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @return bool|int true or new id
      * @throws dml_exception A DML specific exception is thrown for any errors.
index 7f71905..d17ba24 100644 (file)
@@ -390,7 +390,7 @@ abstract class pdo_moodle_database extends moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $data A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @param bool $bulk true means repeated inserts expected
      * @return bool|int true or new id
index 848cee1..5cb4e74 100644 (file)
@@ -1044,7 +1044,7 @@ class pgsql_native_moodle_database extends moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $data A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @return bool|int true or new id
      * @throws dml_exception A DML specific exception is thrown for any errors.
index 8b1f156..799438b 100644 (file)
@@ -1118,7 +1118,7 @@ class sqlsrv_native_moodle_database extends moodle_database {
      * If the return ID isn't required, then this just reports success as true/false.
      * $data is an object containing needed data
      * @param string $table The database table to be inserted into
-     * @param object $data A data object with values for one or more fields in the record
+     * @param object|array $dataobject A data object with values for one or more fields in the record
      * @param bool $returnid Should the id of the newly created record entry be returned? If this option is not requested then true/false is returned.
      * @return bool|int true or new id
      * @throws dml_exception A DML specific exception is thrown for any errors.
index fcc4bfa..9d3fe04 100644 (file)
@@ -249,7 +249,7 @@ abstract class test_moodle_database extends moodle_database {
     /**
      * Default implementation, throws Exception
      * @param string $table
-     * @param StdObject $dataobject
+     * @param object|array $dataobject
      * @param bool $returnid
      * @param bool $bulk
      * @return bool|int true or new id
index d64ad69..7524c00 100644 (file)
                     {{{label}}}
                 {{/text}}
             </label>
-            <span class="ml-2 d-flex align-items-center align-self-start">
+            <div class="ml-2 d-flex align-items-center align-self-start">
                 {{#required}}
                     <div class="text-danger" title="{{#str}}required{{/str}}">
                     {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                     </div>
                 {{/required}}
                 {{{helpbutton}}}
-            </span>
+            </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index 4aacd4f..b3497a4 100644 (file)
                     {{{label}}}
                 {{/text}}
             </label>
-            <span class="ml-2 d-flex align-items-center align-self-start">
+            <div class="ml-2 d-flex align-items-center align-self-start">
                 {{#required}}
                     <div class="text-danger" title="{{#str}}required{{/str}}">
                     {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                     </div>
                 {{/required}}
                 {{{helpbutton}}}
-            </span>
+            </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index 17e557e..0efeff5 100644 (file)
                         {{{label}}}
                     {{/text}}
                 </label>
-                <span class="ml-2 d-flex align-items-center align-self-start">
+                <div class="ml-2 d-flex align-items-center align-self-start">
                     {{#required}}
                         <div class="text-danger" title="{{#str}}required{{/str}}">
                         {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                         </div>
                     {{/required}}
                     {{{helpbutton}}}
-                </span>
+                </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index afa6576..b77408b 100644 (file)
                 </span>
             {{/element.staticlabel}}
         {{/ label }}{{/ label}}
-        <span class="ml-1 ml-md-auto d-flex align-items-center align-self-start">
+        <div class="ml-1 ml-md-auto d-flex align-items-center align-self-start">
             {{#required}}
                 <div class="text-danger" title="{{#str}}required{{/str}}">
                 {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                 </div>
             {{/required}}
             {{{helpbutton}}}
-        </span>
+        </div>
     </div>
     <div class="col-md-9 form-inline align-items-start felement" data-fieldtype="{{element.type}}">
         {{$ element }}
index b0f5114..59fcd1c 100644 (file)
@@ -3906,13 +3906,19 @@ function get_enabled_auth_plugins($fix=false) {
 
     if ($fix) {
         $auths = array_unique($auths);
+        $oldauthconfig = implode(',', $auths);
         foreach ($auths as $k => $authname) {
-            if (!exists_auth_plugin($authname) or in_array($authname, $default)) {
+            $authplugindoesnotexist = !exists_auth_plugin($authname);
+            if ($authplugindoesnotexist || in_array($authname, $default)) {
+                if ($authplugindoesnotexist) {
+                    debugging(get_string('authpluginnotfound', 'debug', $authname));
+                }
                 unset($auths[$k]);
             }
         }
         $newconfig = implode(',', $auths);
         if (!isset($CFG->auth) or $newconfig != $CFG->auth) {
+            add_to_config_log('auth', $oldauthconfig, $newconfig, 'core');
             set_config('auth', $newconfig);
         }
     }
index b6277cb..ff5428b 100644 (file)
@@ -3407,6 +3407,15 @@ class block_contents {
     public function add_class($class) {
         $this->attributes['class'] .= ' '.$class;
     }
+
+    /**
+     * Check if the block is a fake block.
+     *
+     * @return boolean
+     */
+    public function is_fake() {
+        return isset($this->attributes['data-block']) && $this->attributes['data-block'] == '_fake';
+    }
 }
 
 
index 6a64c28..abfb603 100644 (file)
@@ -1817,9 +1817,10 @@ class core_renderer extends renderer_base {
      * Output all the blocks in a particular region.
      *
      * @param string $region the name of a region on this page.
+     * @param boolean $fakeblocksonly Output fake block only.
      * @return string the HTML to be output.
      */
-    public function blocks_for_region($region) {
+    public function blocks_for_region($region, $fakeblocksonly = false) {
         $blockcontents = $this->page->blocks->get_content_for_region($region, $this);
         $lastblock = null;
         $zones = array();
@@ -1832,10 +1833,16 @@ class core_renderer extends renderer_base {
 
         foreach ($blockcontents as $bc) {
             if ($bc instanceof block_contents) {
+                if ($fakeblocksonly && !$bc->is_fake()) {
+                    // Skip rendering real blocks if we only want to show fake blocks.
+                    continue;
+                }
                 $output .= $this->block($bc, $region);
                 $lastblock = $bc->title;
             } else if ($bc instanceof block_move_target) {
-                $output .= $this->block_move_target($bc, $zones, $lastblock, $region);
+                if (!$fakeblocksonly) {
+                    $output .= $this->block_move_target($bc, $zones, $lastblock, $region);
+                }
             } else {
                 throw new coding_exception('Unexpected type of thing (' . get_class($bc) . ') found in list of block contents.');
             }
@@ -2875,10 +2882,11 @@ EOD;
      * Note: \core\notification::add() may be more suitable for your usage.
      *
      * @param string $message The message to print out.
-     * @param string $type    The type of notification. See constants on \core\output\notification.
+     * @param ?string $type   The type of notification. See constants on \core\output\notification.
+     * @param bool $closebutton Whether to show a close icon to remove the notification (default true).
      * @return string the HTML to output.
      */
-    public function notification($message, $type = null) {
+    public function notification($message, $type = null, $closebutton = true) {
         $typemappings = [
             // Valid types.
             'success'           => \core\output\notification::NOTIFY_SUCCESS,
@@ -2922,7 +2930,7 @@ EOD;
             }
         }
 
-        $notification = new \core\output\notification($message, $type);
+        $notification = new \core\output\notification($message, $type, $closebutton);
         if (count($extraclasses)) {
             $notification->set_extra_classes($extraclasses);
         }
@@ -3938,9 +3946,12 @@ EOD;
      *
      * @since Moodle 2.5.1 2.6
      * @param string $region The region to get HTML for.
+     * @param array $classes Wrapping tag classes.
+     * @param string $tag Wrapping tag.
+     * @param boolean $fakeblocksonly Include fake blocks only.
      * @return string HTML.
      */
-    public function blocks($region, $classes = array(), $tag = 'aside') {
+    public function blocks($region, $classes = array(), $tag = 'aside', $fakeblocksonly = false) {
         $displayregion = $this->page->apply_theme_region_manipulations($region);
         $classes = (array)$classes;
         $classes[] = 'block-region';
@@ -3951,7 +3962,7 @@ EOD;
             'data-droptarget' => '1'
         );
         if ($this->page->blocks->region_has_content($displayregion, $this)) {
-            $content = $this->blocks_for_region($displayregion);
+            $content = $this->blocks_for_region($displayregion, $fakeblocksonly);
         } else {
             $content = '';
         }
@@ -4886,9 +4897,10 @@ class core_renderer_cli extends core_renderer {
      *
      * @param string $message The message to print out.
      * @param string $type    The type of notification. See constants on \core\output\notification.
+     * @param bool $closebutton Whether to show a close icon to remove the notification (default true).
      * @return string A template fragment for a notification
      */
-    public function notification($message, $type = null) {
+    public function notification($message, $type = null, $closebutton = true) {
         $message = clean_text($message);
         if ($type === 'notifysuccess' || $type === 'success') {
             return "++ $message ++\n";
@@ -4972,8 +4984,10 @@ class core_renderer_ajax extends core_renderer {
      *
      * @param string $message The message to print out.
      * @param string $type    The type of notification. See constants on \core\output\notification.
+     * @param bool $closebutton Whether to show a close icon to remove the notification (default true).
      */
-    public function notification($message, $type = null) {}
+    public function notification($message, $type = null, $closebutton = true) {
+    }
 
     /**
      * Used to display a redirection message.
@@ -5080,9 +5094,10 @@ class core_renderer_maintenance extends core_renderer {
      * @param string $region
      * @param array $classes
      * @param string $tag
+     * @param boolean $fakeblocksonly
      * @return string
      */
-    public function blocks($region, $classes = array(), $tag = 'aside') {
+    public function blocks($region, $classes = array(), $tag = 'aside', $fakeblocksonly = false) {
         return '';
     }
 
@@ -5090,9 +5105,10 @@ class core_renderer_maintenance extends core_renderer {
      * Does nothing. The maintenance renderer cannot produce blocks.
      *
      * @param string $region
+     * @param boolean $fakeblocksonly Output fake block only.
      * @return string
      */
-    public function blocks_for_region($region) {
+    public function blocks_for_region($region, $fakeblocksonly = false) {
         return '';
     }
 
index 7bca2a7..d18b9f5 100644 (file)
@@ -319,20 +319,21 @@ class page_requirements_manager {
             }
 
             $this->M_cfg = array(
-                'wwwroot'             => $CFG->wwwroot,
-                'sesskey'             => sesskey(),
-                'sessiontimeout'      => $CFG->sessiontimeout,
-                'themerev'            => theme_get_revision(),
-                'slasharguments'      => (int)(!empty($CFG->slasharguments)),
-                'theme'               => $page->theme->name,
-                'iconsystemmodule'    => $iconsystem->get_amd_name(),
-                'jsrev'               => $this->get_jsrev(),
-                'admin'               => $CFG->admin,
-                'svgicons'            => $page->theme->use_svg_icons(),
-                'usertimezone'        => usertimezone(),
-                'contextid'           => $contextid,
-                'langrev'             => get_string_manager()->get_revision(),
-                'templaterev'         => $this->get_templaterev()
+                'wwwroot'               => $CFG->wwwroot,
+                'sesskey'               => sesskey(),
+                'sessiontimeout'        => $CFG->sessiontimeout,
+                'sessiontimeoutwarning' => $CFG->sessiontimeoutwarning,
+                'themerev'              => theme_get_revision(),
+                'slasharguments'        => (int)(!empty($CFG->slasharguments)),
+                'theme'                 => $page->theme->name,
+                'iconsystemmodule'      => $iconsystem->get_amd_name(),
+                'jsrev'                 => $this->get_jsrev(),
+                'admin'                 => $CFG->admin,
+                'svgicons'              => $page->theme->use_svg_icons(),
+                'usertimezone'          => usertimezone(),
+                'contextid'             => $contextid,
+                'langrev'               => get_string_manager()->get_revision(),
+                'templaterev'           => $this->get_templaterev()
             );
             if ($CFG->debugdeveloper) {
                 $this->M_cfg['developerdebug'] = true;
index 1326f13..210bdee 100644 (file)
@@ -801,6 +801,10 @@ if (CLI_SCRIPT) {
 if (empty($CFG->sessiontimeout)) {
     $CFG->sessiontimeout = 8 * 60 * 60;
 }
+// Set sessiontimeoutwarning 20 minutes.
+if (empty($CFG->sessiontimeoutwarning)) {
+    $CFG->sessiontimeoutwarning = 20 * 60;
+}
 \core\session\manager::start();
 
 // Set default content type and encoding, developers are still required to use
index ac76ed5..ae7d194 100644 (file)
 }}
 {{#showSuggestions}}
 <div class="d-md-inline-block mr-md-2 position-relative">
-    <input type="text" id="{{inputId}}" class="form-control" list="{{suggestionsId}}" placeholder="{{placeholder}}" role="combobox" aria-expanded="false" autocomplete="off" autocorrect="off" autocapitalize="off" aria-autocomplete="list" aria-owns="{{suggestionsId}} {{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+    <input type="text"{{!
+    }} id="{{inputId}}"{{!
+    }} class="form-control"{{!
+    }} list="{{suggestionsId}}"{{!
+    }} placeholder="{{placeholder}}"{{!
+    }} role="combobox"{{!
+    }} aria-expanded="false"{{!
+    }} autocomplete="off"{{!
+    }} autocorrect="off"{{!
+    }} autocapitalize="off"{{!
+    }} aria-autocomplete="list"{{!
+    }} aria-owns="{{suggestionsId}} {{selectionId}}"{{!
+    }} {{#tags}}data-tags="1"{{/tags}}{{!
+    }} {{#multiple}}data-multiple="multiple"{{/multiple}}{{!
+    }}>
     <span class="form-autocomplete-downarrow position-absolute p-1" id="{{downArrowId}}">&#x25BC;</span>
 </div>
 {{/showSuggestions}}
 {{^showSuggestions}}
 <div class="d-md-inline-block mr-md-2">
-    <input type="text" id="{{inputId}}" class="form-control" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+    <input type="text"{{!
+    }} id="{{inputId}}"{{!
+    }} class="form-control"{{!
+    }} placeholder="{{placeholder}}"{{!
+    }} role="textbox"{{!
+    }} aria-owns="{{selectionId}}"{{!
+    }} {{#tags}}data-tags="1"{{/tags}}{{!
+    }} {{#multiple}}data-multiple="multiple"{{/multiple}}{{!
+    }}>
 </div>
 {{/showSuggestions}}
 
index f1b3e79..365dec7 100644 (file)
         {{^activepagenumber}}
             data-active-page-number="1"
         {{/activepagenumber}}
-        {{#showitemsperpageselector}}
-            {{#itemsperpage}}
-                {{#active}}
-                    data-items-per-page="{{value}}"
-                {{/active}}
-            {{/itemsperpage}}
-        {{/showitemsperpageselector}}
-        {{^showitemsperpageselector}}
-            data-items-per-page="{{itemsperpage}}"
-        {{/showitemsperpageselector}}
+        {{#itemsperpage}}
+            {{#active}}
+                data-items-per-page="{{value}}"
+            {{/active}}
+        {{/itemsperpage}}
         {{#arialabels.paginationnav}}
             aria-label="{{.}}"
         {{/arialabels.paginationnav}}
index 7e89b09..d2cdc48 100644 (file)
@@ -49,6 +49,7 @@
            class="form-control withclear"
            placeholder="{{$placeholder}}{{#str}} search, core {{/str}}{{/placeholder}}"
            name="search"
+           value="{{$value}}{{/value}}"
            autocomplete="off"
         >
         <button
index 4f07bc4..73953d8 100644 (file)
@@ -78,7 +78,7 @@
         aria-expanded="false"
         aria-controls="searchform-navbar"
     >
-        <i class="icon fa fa-search fa-fw " aria-hidden="true"></i>
+        {{#pix}} a/search, core {{/pix}}
         <span class="sr-only">{{#str}} togglesearch {{/str}}</span>
     </a>
 </div>
@@ -95,7 +95,7 @@ function(
     var container = $('#searchinput-navbar-' + uniqid);
     var opensearch = container.find('[data-action="opensearch"]');
     var input = container.find('[data-region="input"]');
-    var submit = container.find('[data-action="submit"');
+    var submit = container.find('[data-action="submit"]');
 
     submit.on('click', function(e) {
         if (input.val() === '') {
@@ -113,4 +113,4 @@ function(
         input.focus();
     });
 });
-{{/js}}
\ No newline at end of file
+{{/js}}
index 2c61603..be60ee6 100644 (file)
@@ -27,7 +27,6 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once(__DIR__ . '/../../analytics/tests/fixtures/test_target_course_level_shortname.php');
 require_once(__DIR__ . '/../../analytics/tests/fixtures/test_target_shortname.php');
-require_once(__DIR__ . '/fixtures/deprecated_analyser.php');
 require_once(__DIR__ . '/../../lib/enrollib.php');
 
 /**
@@ -197,20 +196,6 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context]));
     }
 
-    /**
-     * test_deprecated_analyser
-     *
-     * @return void
-     */
-    public function test_deprecated_analyser() {
-
-        $target = new test_target_shortname();
-        $analyser = new deprecated_analyser(1, $target, [], [], []);
-
-        $analysables = $analyser->get_analysables_iterator();
-        $this->assertDebuggingCalled();
-    }
-
     /**
      * test_get_analysables_iterator description
      *
index 2532f94..2b92271 100644 (file)
@@ -693,18 +693,21 @@ class behat_forms extends behat_base {
         $xpathtarget = "//ul[@class='form-autocomplete-suggestions']//*[contains(concat('|', string(.), '|'),'|" . $item . "|')]";
 
         $this->execute('behat_general::i_click_on', [$xpathtarget, 'xpath_element']);
-
-        $this->execute('behat_general::i_press_key_in_element', ['13', 'body', 'xpath_element']);
     }
 
     /**
      * Open the auto-complete suggestions list (Assuming there is only one on the page.).
      *
-     * @Given /^I open the autocomplete suggestions list$/
+     * @Given I open the autocomplete suggestions list
+     * @Given I open the autocomplete suggestions list in the :container :containertype
      */
-    public function i_open_the_autocomplete_suggestions_list() {
+    public function i_open_the_autocomplete_suggestions_list($container = null, $containertype = null) {
         $csstarget = ".form-autocomplete-downarrow";
-        $this->execute('behat_general::i_click_on', [$csstarget, 'css_element']);
+        if ($container && $containertype) {
+            $this->execute('behat_general::i_click_on', [$csstarget, 'css_element', $container, $containertype]);
+        } else {
+            $this->execute('behat_general::i_click_on', [$csstarget, 'css_element']);
+        }
     }
 
     /**
index 1e8e1e8..17b8a0e 100644 (file)
@@ -328,7 +328,6 @@ EOF;
             // The `before_subsequent_scenario_start_session` function will restart the session instead.
             return;
         }
-        self::$firstjavascriptscenarioseen = true;
 
         $docsurl = behat_command::DOCS_URL;
         $driverexceptionmsg = <<<EOF
@@ -475,6 +474,16 @@ EOF;
         $this->resize_window('medium');
     }
 
+    /**
+     * Mark the first Javascript Scenario as have been seen.
+     *
+     * @BeforeScenario
+     * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
+     */
+    public function mark_first_js_scenario_as_seen(BeforeScenarioScope $scope) {
+        self::$firstjavascriptscenarioseen = true;
+    }
+
     /**
      * Hook to open the site root before the first step in the suite.
      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
diff --git a/lib/tests/fixtures/deprecated_analyser.php b/lib/tests/fixtures/deprecated_analyser.php
deleted file mode 100644 (file)
index a4a48ef..0000000
+++ /dev/null
@@ -1,158 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Deprecated analyser for testing purposes.
- *
- * @package   core_analytics
- * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Deprecated analyser for testing purposes.
- *
- * @package   core_analytics
- * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class deprecated_analyser extends \core_analytics\local\analyser\base {
-
-    /**
-     * Implementation of a deprecated method.
-     *
-     * It should be called by get_analysables_iterator, which triggers a debugging message.
-     * @return \core_analytics\analysable[]
-     */
-    public function get_analysables() {
-        $analysable = new \core_analytics\site();
-        return [SYSCONTEXTID => $analysable];
-    }
-
-    /**
-     * Samples origin is course table.
-     *
-     * @return string
-     */
-    public function get_samples_origin() {
-        return 'user';
-    }
-
-    /**
-     * Returns the sample analysable
-     *
-     * @param int $sampleid
-     * @return \core_analytics\analysable
-     */
-    public function get_sample_analysable($sampleid) {
-        return new \core_analytics\site();
-    }
-
-    /**
-     * Data this analyer samples provide.
-     *
-     * @return string[]
-     */
-    protected function provided_sample_data() {
-        return array('user');
-    }
-
-    /**
-     * Returns the sample context.
-     *
-     * @param int $sampleid
-     * @return \context
-     */
-    public function sample_access_context($sampleid) {
-        return \context_system::instance();
-    }
-
-    /**
-     * Returns all site courses.
-     *
-     * @param \core_analytics\analysable $site
-     * @return array
-     */
-    public function get_all_samples(\core_analytics\analysable $site) {
-        global $DB;
-
-        $users = $DB->get_records('user');
-        $userids = array_keys($users);
-        $sampleids = array_combine($userids, $userids);
-
-        $users = array_map(function($user) {
-            return array('user' => $user);
-        }, $users);
-
-        return array($sampleids, $users);
-    }
-
-    /**
-     * Return all complete samples data from sample ids.
-     *
-     * @param int[] $sampleids
-     * @return array
-     */
-    public function get_samples($sampleids) {
-        global $DB;
-
-        list($userssql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED);
-        $users = $DB->get_records_select('user', "id {$userssql}", $params);
-        $userids = array_keys($users);
-        $sampleids = array_combine($userids, $userids);
-
-        $users = array_map(function($user) {
-            return array('user' => $user);
-        }, $users);
-
-        return array($sampleids, $users);
-    }
-
-    /**
-     * Returns the description of a sample.
-     *
-     * @param int $sampleid
-     * @param int $contextid
-     * @param array $sampledata
-     * @return array array(string, \renderable)
-     */
-    public function sample_description($sampleid, $contextid, $sampledata) {
-        $description = fullname($sampledata['user']);
-        $userimage = new \pix_icon('i/user', get_string('user'));
-        return array($description, $userimage);
-    }
-
-    /**
-     * We need to delete associated data if a user requests his data to be deleted.
-     *
-     * @return bool
-     */
-    public function processes_user_data() {
-        return true;
-    }
-
-    /**
-     * Join the samples origin table with the user id table.
-     *
-     * @param string $sampletablealias
-     * @return string
-     */
-    public function join_sample_user($sampletablealias) {
-        return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid";
-    }
-}
\ No newline at end of file
index c46383b..0ff1c55 100644 (file)
@@ -650,4 +650,33 @@ EOF;
         $this->assertTrue(in_array(['name' => 'class', 'value' => $labelclass], $data->labelattributes));
         $this->assertTrue(in_array(['name' => 'style', 'value' => $labelstyle], $data->labelattributes));
     }
+
+    /**
+     * Data provider for test_block_contents_is_fake().
+     *
+     * @return array
+     */
+    public function block_contents_is_fake_provider() {
+        return [
+            'Null' => [null, false],
+            'Not set' => [false, false],
+            'Fake' => ['_fake', true],
+            'Real block' => ['activity_modules', false],
+        ];
+    }
+
+    /**
+     * Test block_contents is_fake() method.
+     *
+     * @dataProvider block_contents_is_fake_provider
+     * @param mixed $value Value for the data-block attribute
+     * @param boolean $expected The expected result
+     */
+    public function test_block_contents_is_fake($value, $expected) {
+        $bc = new block_contents(array());
+        if ($value !== false) {
+            $bc->attributes['data-block'] = $value;
+        }
+        $this->assertEquals($expected, $bc->is_fake());
+    }
 }
index a16e8e0..d57c352 100644 (file)
@@ -640,6 +640,47 @@ class core_scheduled_task_testcase extends advanced_testcase {
         }
     }
 
+    /**
+     * Check that an overridden task is sent to be processed.
+     */
+    public function test_scheduled_task_overridden_task_can_run(): void {
+        global $CFG, $DB;
+
+        $this->resetAfterTest();
+
+        // Delete all existing scheduled tasks.
+        $DB->delete_records('task_scheduled');
+
+        // Add overrides to the config.
+        $CFG->scheduled_tasks = [
+            '\core\task\scheduled_test_task' => [
+                'disabled' => 1
+            ],
+            '\core\task\scheduled_test2_task' => [
+                'disabled' => 0
+            ],
+        ];
+
+        // A task that runs once per hour.
+        $record = new stdClass();
+        $record->component = 'test_scheduled_task';
+        $record->classname = '\core\task\scheduled_test_task';
+        $record->disabled = 0;
+        $DB->insert_record('task_scheduled', $record);
+
+        // And disabled test.
+        $record->classname = '\core\task\scheduled_test2_task';
+        $record->disabled = 1;
+        $DB->insert_record('task_scheduled', $record);
+
+        $now = time();
+
+        $scheduledtask = \core\task\manager::get_next_scheduled_task($now);
+        $this->assertInstanceOf('\core\task\scheduled_test2_task', $scheduledtask);
+        $scheduledtask->execute();
+        \core\task\manager::scheduled_task_complete($scheduledtask);
+    }
+
     /**
      * Assert that the specified tasks are equal.
      *
@@ -691,4 +732,24 @@ class core_scheduled_task_testcase extends advanced_testcase {
 
         call_user_func_array([$this, 'assertNotEquals'], $args);
     }
+
+    /**
+     * Assert that the lastruntime column holds an original value after a scheduled task is reset.
+     */
+    public function test_reset_scheduled_tasks_for_component_keeps_original_lastruntime(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Set lastruntime for the scheduled task.
+        $DB->set_field('task_scheduled', 'lastruntime', 123456789, ['classname' => '\core\task\session_cleanup_task']);
+
+        // Reset the task.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Confirm, that lastruntime is still in place.
+        $this->assertEquals(123456789, $taskafterreset->get_last_run_time());
+    }
 }
index 825b708..0697d54 100644 (file)
@@ -2505,27 +2505,6 @@ class assign {
         return $useridlist;
     }
 
-    /**
-     * Generate zip file from array of given files.
-     *
-     * @param array $filesforzipping - array of files to pass into archive_to_pathname.
-     *                                 This array is indexed by the final file name and each
-     *                                 element in the array is an instance of a stored_file object.
-     * @return path of temp file - note this returned file does
-     *         not have a .zip extension - it is a temp file.
-     */
-    protected function pack_files($filesforzipping) {
-        global $CFG;
-        // Create path for new zip file.
-        $tempzip = tempnam($CFG->tempdir . '/', 'assignment_');
-        // Zip files.
-        $zipper = new zip_packer();
-        if ($zipper->archive_to_pathname($filesforzipping, $tempzip)) {
-            return $tempzip;
-        }
-        return false;
-    }
-
     /**
      * Finds all assignment notifications that have yet to be mailed out, and mails them.
      *
@@ -2539,7 +2518,8 @@ class assign {
         // Only ever send a max of one days worth of updates.
         $yesterday = time() - (24 * 3600);
         $timenow   = time();
-        $lastruntime = $DB->get_field('task_scheduled', 'lastruntime', array('component' => 'mod_assign'));
+        $task = \core\task\manager::get_scheduled_task(mod_assign\task\cron_task::class);
+        $lastruntime = $task->get_last_run_time();
 
         // Collect all submissions that require mailing.
         // Submissions are included if all are true:
@@ -3660,13 +3640,26 @@ class assign {
                                                                     'action'=>'grading'));
             $result .= $this->get_renderer()->continue_button($url);
             $result .= $this->view_footer();
-        } else if ($zipfile = $this->pack_files($filesforzipping)) {
-            \mod_assign\event\all_submissions_downloaded::create_from_assign($this)->trigger();
-            // Send file and delete after sending.
-            send_temp_file($zipfile, $filename);
-            // We will not get here - send_temp_file calls exit.
+
+            return $result;
         }
-        return $result;
+
+        // Log zip as downloaded.
+        \mod_assign\event\all_submissions_downloaded::create_from_assign($this)->trigger();
+
+        // Close the session.
+        \core\session\manager::write_close();
+
+        $zipwriter = \core_files\archive_writer::get_stream_writer($filename, \core_files\archive_writer::ZIP_WRITER);
+
+        // Stream the files into the zip.
+        foreach ($filesforzipping as $pathinzip => $storedfile) {
+            $zipwriter->add_file_from_stored_file($pathinzip, $storedfile);
+        }
+
+        // Finish the archive.
+        $zipwriter->finish();
+        exit();
     }
 
     /**
index 8c51acb..0d5af67 100644 (file)
@@ -4206,4 +4206,54 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
         $output3 .= $assign->get_renderer()->render($summary);
         $this->assertStringContainsStringIgnoringCase('Friday, 7 June 2019, 5:37 PM', $output3);
     }
+
+    /**
+     * Test that cron task uses task API to get its last run time.
+     */
+    public function test_cron_use_task_api_to_get_lastruntime() {
+        global $DB;
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create an assignment which allows submissions from 3 days ago.
+        $assign1 = $this->create_instance($course, [
+            'duedate' => time() + DAYSECS,
+            'alwaysshowdescription' => 0,
+            'allowsubmissionsfromdate' => time() - 3 * DAYSECS,
+            'intro' => 'This one should not be re-created',
+        ]);
+
+        // Create an assignment which allows submissions from 1 day ago.
+        $assign2 = $this->create_instance($course, [
+            'duedate' => time() + DAYSECS,
+            'alwaysshowdescription' => 0,
+            'allowsubmissionsfromdate' => time() - DAYSECS,
+            'intro' => 'This one should be re-created',
+        ]);
+
+        // Set last run time 2 days ago.
+        $DB->set_field('task_scheduled', 'lastruntime', time() - 2 * DAYSECS, ['classname' => '\mod_assign\task\cron_task']);
+
+        // Remove events to make sure cron will update calendar and re-create one of them.
+        $params = array('modulename' => 'assign', 'instance' => $assign1->get_instance()->id);
+        $DB->delete_records('event', $params);
+        $params = array('modulename' => 'assign', 'instance' => $assign2->get_instance()->id);
+        $DB->delete_records('event', $params);
+
+        // Run cron.
+        assign::cron();
+
+        // Assert that calendar hasn't been updated for the first assignment as it's supposed to be
+        // updated as part of previous cron runs (allowsubmissionsfromdate is less than lastruntime).
+        $params = array('modulename' => 'assign', 'instance' => $assign1->get_instance()->id);
+        $event1 = $DB->get_record('event', $params);
+        $this->assertEmpty($event1);
+
+        // Assert that calendar has been updated for the second assignment
+        // because its allowsubmissionsfromdate is greater than lastruntime.
+        $params = array('modulename' => 'assign', 'instance' => $assign2->get_instance()->id);
+        $event2 = $DB->get_record('event', $params);
+        $this->assertNotEmpty($event2);
+        $this->assertSame('This one should be re-created', $event2->description);
+    }
 }
index 726a14b..29e42ef 100644 (file)
@@ -17,7 +17,7 @@
 define('NO_MOODLE_COOKIES', true); // Session not used here.
 
 require('../../../config.php');
-require('../lib.php');
+require_once('../lib.php');
 
 $chatsid      = required_param('chat_sid', PARAM_ALPHANUM);
 $chatlasttime = optional_param('chat_lasttime', 0, PARAM_INT);
index 3e564c6..6842678 100644 (file)
@@ -34,7 +34,7 @@ define('NO_MOODLE_COOKIES', true); // Session not used here.
 define('NO_OUTPUT_BUFFERING', true);
 
 require('../../../config.php');
-require('../lib.php');
+require_once('../lib.php');
 
 // We are going to run for a long time.
 // Avoid being terminated by php.
index 0bfd9c0..799731e 100644 (file)
@@ -17,7 +17,7 @@
 define('NO_MOODLE_COOKIES', true); // Session not used here.
 
 require('../../../config.php');
-require('../lib.php');
+require_once('../lib.php');
 
 $chatsid = required_param('chat_sid', PARAM_ALPHANUM);
 
index edf9986..75f27f4 100644 (file)
@@ -31,25 +31,31 @@ class mod_data_export_form extends moodleform {
     }
 
     function definition() {
-        global $CFG;
         $mform =& $this->_form;
-        $mform->addElement('header', 'notice', get_string('chooseexportformat', 'data'));
-        $choices = csv_import_reader::get_delimiter_list();
-        $key = array_search(';', $choices);
-        if (! $key === FALSE) {
-            // array $choices contains the semicolon -> drop it (because its encrypted form also contains a semicolon):
-            unset($choices[$key]);
-        }
+        $mform->addElement('header', 'exportformat', get_string('chooseexportformat', 'data'));
+
+        $optionattrs = ['class' => 'mt-1 mb-1'];
+
+        // Export format type radio group.
         $typesarray = array();
-        $str = get_string('csvwithselecteddelimiter', 'data');
-        $typesarray[] = $mform->createElement('radio', 'exporttype', null, $str . '&nbsp;', 'csv');
-        $typesarray[] = $mform->createElement('select', 'delimiter_name', null, $choices);
-        //temporarily commenting out Excel export option. See MDL-19864
+        $typesarray[] = $mform->createElement('radio', 'exporttype', null, get_string('csvwithselecteddelimiter', 'data'), 'csv',
+            $optionattrs);
+        // Temporarily commenting out Excel export option. See MDL-19864.
         //$typesarray[] = $mform->createElement('radio', 'exporttype', null, get_string('excel', 'data'), 'xls');
-        $typesarray[] = $mform->createElement('radio', 'exporttype', null, get_string('ods', 'data'), 'ods');
-        $mform->addGroup($typesarray, 'exportar', '', array(''), false);
+        $typesarray[] = $mform->createElement('radio', 'exporttype', null, get_string('ods', 'data'), 'ods', $optionattrs);
+        $mform->addGroup($typesarray, 'exportar', get_string('exportformat', 'data'), null, false);
         $mform->addRule('exportar', null, 'required');
         $mform->setDefault('exporttype', 'csv');
+
+        // CSV delimiter list.
+        $choices = csv_import_reader::get_delimiter_list();
+        $key = array_search(';', $choices);
+        if ($key !== false) {
+            // Array $choices contains the semicolon -> drop it (because its encrypted form also contains a semicolon):
+            unset($choices[$key]);
+        }
+        $mform->addElement('select', 'delimiter_name', get_string('fielddelimiter', 'data'), $choices);
+        $mform->hideIf('delimiter_name', 'exporttype', 'neq', 'csv');
         if (array_key_exists('cfg', $choices)) {
             $mform->setDefault('delimiter_name', 'cfg');
         } else if (get_string('listsep', 'langconfig') == ';') {
@@ -57,38 +63,56 @@ class mod_data_export_form extends moodleform {
         } else {
             $mform->setDefault('delimiter_name', 'comma');
         }
-        $mform->addElement('header', 'notice', get_string('chooseexportfields', 'data'));
+
+        // Fields to be exported.
+        $mform->addElement('header', 'exportfieldsheader', get_string('chooseexportfields', 'data'));
+        $mform->setExpanded('exportfieldsheader');
         $numfieldsthatcanbeselected = 0;
-        foreach($this->_datafields as $field) {
-            if($field->text_export_supported()) {
+        $exportfields = [];
+        $unsupportedfields = [];
+        foreach ($this->_datafields as $field) {
+            $label = get_string('fieldnametype', 'data', (object)['name' => $field->field->name, 'type' => $field->name()]);
+            if ($field->text_export_supported()) {
                 $numfieldsthatcanbeselected++;
-                $html = '<div title="' . s($field->field->description) . '" ' .
-                        'class="d-inline-block">' . $field->field->name . '</div>';
-                $name = ' (' . $field->name() . ')';
-                $mform->addElement('advcheckbox', 'field_' . $field->field->id, $html, $name, array('group' => 1));
+                $exportfields[] = $mform->createElement('advcheckbox', 'field_' . $field->field->id, '', $label,
+                    array_merge(['group' => 1], $optionattrs));
                 $mform->setDefault('field_' . $field->field->id, 1);
             } else {
-                $a = new stdClass();
-                $a->fieldtype = $field->name();
-                $str = get_string('unsupportedexport', 'data', $a);
-                $mform->addElement('static', 'unsupported' . $field->field->id, $field->field->name, $str);
+                $unsupportedfields[] = $label;
             }
         }
+        $mform->addGroup($exportfields, 'exportfields', get_string('selectfields', 'data'), ['<br>'], false);
+
         if ($numfieldsthatcanbeselected > 1) {
             $this->add_checkbox_controller(1, null, null, 1);
         }
+
+        // List fields that cannot be exported.
+        if (!empty($unsupportedfields)) {
+            $unsupportedfieldslist = html_writer::tag('p', get_string('unsupportedfieldslist', 'data'), ['class' => 'mt-1']);
+            $unsupportedfieldslist .= html_writer::alist($unsupportedfields);
+            $mform->addElement('static', 'unsupportedfields', get_string('unsupportedfields', 'data'), $unsupportedfieldslist);
+        }
+
+        // Export options.
+        $mform->addElement('header', 'exportoptionsheader', get_string('exportoptions', 'data'));
+        $mform->setExpanded('exportoptionsheader');
+        $exportoptions = [];
         if (core_tag_tag::is_enabled('mod_data', 'data_records')) {
-            $mform->addElement('checkbox', 'exporttags', get_string('includetags', 'data'));
+            $exportoptions[] = $mform->createElement('checkbox', 'exporttags', get_string('includetags', 'data'), '', $optionattrs);
             $mform->setDefault('exporttags', 1);
         }
         $context = context_module::instance($this->_cm->id);
         if (has_capability('mod/data:exportuserinfo', $context)) {
-            $mform->addElement('checkbox', 'exportuser', get_string('includeuserdetails', 'data'));
+            $exportoptions[] = $mform->createElement('checkbox', 'exportuser', get_string('includeuserdetails', 'data'), '',
+                $optionattrs);
         }
-        $mform->addElement('checkbox', 'exporttime', get_string('includetime', 'data'));
+        $exportoptions[] = $mform->createElement('checkbox', 'exporttime', get_string('includetime', 'data'), '', $optionattrs);
         if ($this->_data->approval) {
-            $mform->addElement('checkbox', 'exportapproval', get_string('includeapproval', 'data'));
+            $exportoptions[] = $mform->createElement('checkbox', 'exportapproval', get_string('includeapproval', 'data'), '',
+                $optionattrs);
         }
+        $mform->addGroup($exportoptions, 'exportoptions', get_string('selectexportoptions', 'data'), ['<br>'], false);
 
         $this->add_action_buttons(true, get_string('exportentries', 'data'));
     }
index d96f7f5..1c5be54 100644 (file)
@@ -83,7 +83,7 @@ $string['csvfailed'] = 'Unable to read the raw data from the CSV file';
 $string['csvfile'] = 'CSV file';
 $string['csvimport'] = 'CSV file import';
 $string['csvimport_help'] = 'Entries may be imported via a plain text file with a list of field names as the first line, then the data, with one record per line.';
-$string['csvwithselecteddelimiter'] = '<acronym title="Comma Separated Values">CSV</acronym> text with selected delimiter:';
+$string['csvwithselecteddelimiter'] = '<abbr title="Comma Separated Values">CSV</abbr>';
 $string['data:addinstance'] = 'Add a new database';
 $string['data:approve'] = 'Approve and undo approved entries';
 $string['data:comment'] = 'Write comments';
@@ -153,6 +153,8 @@ $string['exportaszip'] = 'Export as zip';
 $string['exportaszip_help'] = 'The export as zip feature allows you to save the templates and fields as a preset zip for download. The zip may then be imported to another course.';
 $string['exportedtozip'] = 'Exported to temporary zip...';
 $string['exportentries'] = 'Export entries';
+$string['exportformat'] = 'Export format';
+$string['exportoptions'] = 'Export options';
 $string['exportownentries'] = 'Export your own entries only? ({$a->mine}/{$a->all})';
 $string['failedpresetdelete'] = 'Error deleting a preset!';
 $string['fieldadded'] = 'Field added';
@@ -169,6 +171,7 @@ $string['fieldmappings'] = 'Field mappings';
 $string['fieldmappings_help'] = 'This menu allows you to keep the data from the existing database. To preserve the data in a field, you must map it to a new field, where the data will appear. Any field can also be left blank, with no information copied into it. Any old field not mapped to a new one will be lost and all its data removed.
 You can only map fields of the same type, so each drop-down menu will have different fields in it. Also, you must be careful not to try and map one old field to more than one new field.';
 $string['fieldname'] = 'Field name';
+$string['fieldnametype'] = '{$a->name} ({$a->type})';
 $string['fieldnotmatched'] = 'The following fields in your file are not known in this database: {$a}';
 $string['fieldoptions'] = 'Options (one per line)';
 $string['fields'] = 'Fields';
@@ -195,8 +198,8 @@ $string['headerlisttemplate'] = 'Defines browsing interface for multiple entries
 $string['headerrsstemplate'] = 'Defines appearance of entries in RSS feeds';
 $string['headersingletemplate'] = 'Defines browsing interface for a single entry';
 $string['checkbox'] = 'Checkbox';
-$string['chooseexportfields'] = 'Choose the fields you wish to export:';
-$string['chooseexportformat'] = 'Choose the format you wish to export to:';
+$string['chooseexportfields'] = 'Choose the fields you wish to export';
+$string['chooseexportformat'] = 'Choose the format you wish to export to';
 $string['chooseorupload'] = 'Choose file';
 $string['expired'] = 'Sorry, this activity closed on {$a} and is no longer available';
 $string['importentries'] = 'Import entries';
@@ -290,7 +293,7 @@ $string['number'] = 'Number';
 $string['numberrssarticles'] = 'Entries in the RSS feed';
 $string['numnotapproved'] = 'Pending';
 $string['numrecords'] = '{$a} entries';
-$string['ods'] = '<acronym title="OpenDocument Spreadsheet">ODS</acronym> (OpenOffice)';
+$string['ods'] = '<abbr title="OpenDocument Spreadsheet">ODS</abbr>&nbsp;(OpenOffice)';
 $string['openafterclose'] = 'You have specified an open date after the close date';
 $string['optionaldescription'] = 'Short description (optional)';
 $string['optionalfilename'] = 'Filename (optional)';
@@ -366,6 +369,8 @@ $string['search'] = 'Search';
 $string['search:activity'] = 'Database - activity information';
 $string['search:entry'] = 'Database - entries';
 $string['selectedrequired'] = 'All selected required';
+$string['selectfields'] = 'Select fields';
+$string['selectexportoptions'] = 'Select export options';
 $string['showall'] = 'Show all entries';
 $string['single'] = 'View single';
 $string['singletemplate'] = 'Single template';
@@ -386,7 +391,8 @@ $string['timemodified'] = 'Time modified';
 $string['todatabase'] = 'to this database.';
 $string['type'] = 'Field type';
 $string['undefinedprocessactionmethod'] = 'No action method defined in Data_Preset to handle action "{$a}".';
-$string['unsupportedexport'] = '({$a->fieldtype}) cannot be exported.';
+$string['unsupportedfields'] = 'Unsupported fields';
+$string['unsupportedfieldslist'] = 'The following fields cannot be exported:';
 $string['updatefield'] = 'Update an existing field';
 $string['uploadfile'] = 'Upload file';
 $string['uploadrecords'] = 'Upload entries from a file';
@@ -406,3 +412,6 @@ $string['viewfromdate'] = 'Read only from';
 $string['viewtodate'] = 'Read only to';
 $string['viewtodatevalidation'] = 'The read only to date cannot be before the read only from date.';
 $string['wrongdataid'] = 'Wrong data id provided';
+
+// Deprecated since Moodle 3.11.
+$string['unsupportedexport'] = '({$a->fieldtype}) cannot be exported.';
index 61bdfee..95b4a93 100644 (file)
@@ -94,7 +94,6 @@ Feature: Users can view and search database entries
       | Test field name   | Student original entry tagged   |
       | Test field 2 name | Student original entry tagged 2 |
     And I set the field with xpath "//div[@class='datatagcontrol']//input[@type='text']" to "Tag1"
-    And I click on "[data-value='Tag1']" "css_element"
     And I press "Save and view"
     And I should see "Student original entry"
     And I should see "Tag1" in the "div.tag_list" "css_element"
index 112ead2..cb66f94 100644 (file)
@@ -136,8 +136,7 @@ Feature: Mapping courses in a feedback
     And I am on site homepage
     And I follow "Course feedback"
     And I follow "Map feedback to courses"
-    And I set the field "Courses" to "Course 2"
-    And I set the field "Courses" to "Course 3"
+    And I set the field "Courses" to "Course 2, Course 3"
     And I press "Save changes"
     And I should see "Course mapping has been changed"
     And I log out
@@ -224,7 +223,6 @@ Feature: Mapping courses in a feedback
     And I should see "1 (33.33 %)" in the "option d" "table_row"
     And I should see "2 (66.67 %)" in the "option e" "table_row"
     And I should see "0" in the "option f" "table_row"
-    And I log out
 
   Scenario: Site feedback deletion hides feedback block completely
     When I log in as "manager"
@@ -239,4 +237,3 @@ Feature: Mapping courses in a feedback
     Then "Feedback" "block" should not exist
     And I am on "Course 1" course homepage
     And "Feedback" "block" should not exist
-    And I log out
index 53d0005..b3ed5b7 100644 (file)
@@ -40,20 +40,29 @@ if (!$downloadable) {
     print_error('cannotdownloaddir', 'repository');
 }
 
-folder_downloaded($folder, $course, $cm, $context);
-
 $fs = get_file_storage();
-$file = $fs->get_file($context->id, 'mod_folder', 'content', 0, '/', '.');
-if (!$file) {
+$files = $fs->get_area_files($context->id, 'mod_folder', 'content');
+if (empty($files)) {
     print_error('cannotdownloaddir', 'repository');
 }
 
-$zipper   = get_file_packer('application/zip');
+// Log zip as downloaded.
+folder_downloaded($folder, $course, $cm, $context);
+
+// Close the session.
+\core\session\manager::write_close();
+
 $filename = shorten_filename(clean_filename($folder->name . "-" . date("Ymd")) . ".zip");
-$temppath = make_request_directory() . $filename;
+$zipwriter = \core_files\archive_writer::get_stream_writer($filename, \core_files\archive_writer::ZIP_WRITER);
 
-if ($zipper->archive_to_pathname(array('/' => $file), $temppath)) {
-    send_temp_file($temppath, $filename);
-} else {
-    print_error('cannotdownloaddir', 'repository');
+foreach ($files as $file) {
+    if ($file->is_directory()) {
+        continue;
+    }
+    $pathinzip = $file->get_filepath() . $file->get_filename();
+    $zipwriter->add_file_from_stored_file($pathinzip, $file);
 }
+
+// Finish the archive.
+$zipwriter->finish();
+exit();
index 74732f4..91364b4 100644 (file)
@@ -72,7 +72,8 @@ class mod_folder_renderer extends plugin_renderer_base {
         if ($downloadable) {
             $downloadbutton = $this->output->single_button(
                 new moodle_url('/mod/folder/download_folder.php', array('id' => $cm->id)),
-                get_string('downloadfolder', 'folder')
+                get_string('downloadfolder', 'folder'),
+                'get'
             );
 
             $buttons .= $downloadbutton;
index 2956639..1a6e1a3 100644 (file)
@@ -504,6 +504,15 @@ class mod_forum_external extends external_api {
         return true;
     }
 
+    /**
+     * Mark the get_forum_discussions_paginated web service as deprecated.
+     *
+     * @return  bool
+     */
+    public static function get_forum_discussions_paginated_is_deprecated() {
+        return true;
+    }
+
     /**
      * Describes the parameters for get_forum_discussions_paginated.
      *
index dbcca3b..b26e61a 100644 (file)
@@ -130,7 +130,6 @@ Feature: The forum search allows users to perform advanced searches for forum po
     And I press "Search forums"
     And I should see "Advanced search"
     And I set the field "Is tagged with" to "SearchedTag"
-    And I press the enter key
     When I press "Search forums"
     Then I should see "My subject"
     And I should not see "Your subjective"
index d990e9c..563346d 100644 (file)
@@ -3,6 +3,7 @@ information provided here is intended especially for developers.
 
 === 3.11 ===
 
+* The get_forum_discussions_paginated web service has been deprecated in favour of get_forum_discussions.
 * The forum post exporter now includes a "timemodified" field for each post, which is included in several WS methods:
     * mod_forum_get_discussion_posts
     * get_discussion_posts_by_userid
@@ -35,7 +36,6 @@ information provided here is intended especially for developers.
   * External function get_forums_by_courses now returns two additional fields "duedate" and "cutoffdate" containing the due date and the cutoff date for posting to the forums respectively.
   * External function get_forum_discussion_posts now returns an additional field "tags" returning the post tags.
   * New external function mod_forum_external::get_forum_discussions returns a list of forum discussions optionally sorted and paginated.
-  * External function mod_forum_external::get_forum_discussions_paginated has been deprecated.
     Use mod_forum_external::get_forum_discussions instead.
   * External function mod_forum_external::add_discussion_post() has a new 'messageformat' param to be able to handle other formats different from FORMAT_HTML (that continues being the default one). Also a new 'topreferredformat' option enables the automatic conversion between any format and the preferred format (given the editor preference) before contents are saved.
 
index 7d681e8..843061e 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js and b/mod/lti/amd/build/contentitem.min.js differ
index c9ddf82..198ab86 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js.map and b/mod/lti/amd/build/contentitem.min.js.map differ
index 3d10cb4..74fb54e 100644 (file)
@@ -163,7 +163,8 @@ define(
          */
         var configToVariant = (config) => {
             const variant = {};
-            ['name', 'toolurl', 'securetoolurl', 'instructorcustomparameters', 'icon', 'secureicon', 'launchcontainer'].forEach(
+            ['name', 'toolurl', 'securetoolurl', 'instructorcustomparameters', 'icon', 'secureicon',
+                'launchcontainer', 'lineitemresourceid', 'lineitemtag'].forEach(
                 function(name) {
                     variant[name] = config[name] || '';
                 }
index 448fc01..ffedbbf 100644 (file)
@@ -101,14 +101,23 @@ class registration_helper {
         $ltitoolconfiguration = self::get_parameter($registrationpayload,
             'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
 
-        $domain = self::get_parameter($ltitoolconfiguration, 'domain', true);
-        $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', true);
+        $domain = self::get_parameter($ltitoolconfiguration, 'domain', false);
+        $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', false);
         $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
         $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
         $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
         $messages = $ltitoolconfiguration['messages'] ?? [];
         $description = self::get_parameter($ltitoolconfiguration, 'description', false);
 
+        // Validate domain and target link.
+        if (empty($domain)) {
+            throw new registration_exception('missing_domain', 400);
+        }
+        $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
+        if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
+            throw new registration_exception('domain_targetlinkuri_mismatch', 400);
+        }
+
         // Validate response type.
         // According to specification, for this scenario, id_token must be explicitly set.
         if (!in_array('id_token', $responsetypes)) {
@@ -245,7 +254,7 @@ class registration_helper {
         $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
         $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
         $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
-        $registrationresponse['application_type'] = ['web'];
+        $registrationresponse['application_type'] = 'web';
         $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
         $registrationresponse['client_name'] = $config->lti_typename;
         $registrationresponse['logo_uri'] = $config->lti_icon ?? '';
@@ -288,12 +297,12 @@ class registration_helper {
         $registrationresponse['scope'] = implode(' ', $scopesresponse);
 
         $claimsresponse = ['sub', 'iss'];
-        if ($config->lti_sendname = LTI_SETTING_ALWAYS) {
+        if ($config->lti_sendname == LTI_SETTING_ALWAYS) {
             $claimsresponse[] = 'name';
             $claimsresponse[] = 'family_name';
-            $claimsresponse[] = 'middle_name';
+            $claimsresponse[] = 'given_name';
         }
-        if ($config->lti_sendemailaddr = LTI_SETTING_ALWAYS) {
+        if ($config->lti_sendemailaddr == LTI_SETTING_ALWAYS) {
             $claimsresponse[] = 'email';
         }
         $lticonfigurationresponse['claims'] = $claimsresponse;
index e586766..12969d1 100644 (file)
@@ -1525,8 +1525,6 @@ function content_item_to_form(object $tool, object $typeconfig, object $item) :
         }
         $config->instructorcustomparameters = implode("\n", $customparameters);
     }
-    // Including a JSON version of the form data to support adding many items in one submit.
-    $config->contentitemjson = json_encode($item);
     return $config;
 }
 
@@ -2323,7 +2321,8 @@ function lti_get_lti_types_by_course($courseid, $coursevisible = null) {
                 FROM {lti_types}
                WHERE coursevisible $coursevisiblesql
                  AND ($coursecond)
-                 AND state = :active";
+                 AND state = :active
+            ORDER BY name ASC";
 
     return $DB->get_records_sql($query,
         array('siteid' => $SITE->id, 'courseid' => $courseid, 'active' => LTI_TOOL_STATE_CONFIGURED) + $coursevisparams);
index 6de875d..7d616c3 100644 (file)
@@ -51,7 +51,7 @@ $conf = [
     'https://purl.imsglobal.org/spec/lti-platform-configuration' => [
         'product_family_code' => 'moodle',
         'version' => $CFG->release,
-        'messages_supported' => ['LtiResourceLink', 'LtiDeepLinkingRequest'],
+        'messages_supported' => ['LtiResourceLinkRequest', 'LtiDeepLinkingRequest'],
         'placements' => ['AddContentMenu'],
         'variables' => array_keys(lti_get_capabilities())
     ]
index af67829..4f789f5 100644 (file)
@@ -162,6 +162,8 @@ EOD;
         $this->assertEquals(LTI_SETTING_DELEGATE, $config->lti_acceptgrades);
         $this->assertEquals(1, $config->ltiservice_memberships);
         $this->assertEquals(0, $config->ltiservice_toolsettings);
+        $this->assertEquals('client.example.org', $config->lti_tooldomain);
+        $this->assertEquals('https://client.example.org/lti', $config->lti_toolurl);
         $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendname);
         $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendemailaddr);
         $this->assertEquals(1, $config->lti_contentitem);
@@ -236,6 +238,51 @@ EOD;
         registration_helper::registration_to_config($registration, 'TheClientId');
     }
 
+    /**
+     * Validation Test: no domain nor targetlinkuri is rejected.
+     */
+    public function test_validation_missing_domain_targetlinkuri() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain']);
+        unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['target_link_uri']);
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: mismatch between domain and targetlinkuri is rejected.
+     */
+    public function test_validation_domain_targetlinkuri_match() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        $registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain'] = 'not.the.right.domain';
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: domain is required.
+     */
+    public function test_validation_domain_targetlinkuri_onlylink() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain']);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: base url (targetlinkuri) is built from domain if not present.
+     */
+    public function test_validation_domain_targetlinkuri_onlydomain() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['target_link_uri']);
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $this->assertEquals('client.example.org', $config->lti_tooldomain);
+        $this->assertEquals('https://client.example.org', $config->lti_toolurl);
+    }
+
     /**
      * Test the transformation from lti config to OpenId LTI Client Registration response.
      */
@@ -265,5 +312,34 @@ EOD;
         $dlmsg = $lti['messages'][0];
         $this->assertEquals($dlmsgorig['type'], $dlmsg['type']);
         $this->assertEquals($dlmsgorig['target_link_uri'], $dlmsg['target_link_uri']);
+        $this->assertTrue(in_array('iss', $lti['claims']));
+        $this->assertTrue(in_array('sub', $lti['claims']));
+        $this->assertTrue(in_array('email', $lti['claims']));
+        $this->assertTrue(in_array('family_name', $lti['claims']));
+        $this->assertTrue(in_array('given_name', $lti['claims']));
+        $this->assertTrue(in_array('name', $lti['claims']));
     }
+
+    /**
+     * Test the transformation from lti config to OpenId LTI Client Registration response for the minimal version.
+     */
+    public function test_config_to_registration_minimal() {
+        $orig = json_decode($this->registrationminimaljson, true);
+        $reg = registration_helper::config_to_registration(registration_helper::registration_to_config($orig, 'clid'), 12);
+        $this->assertEquals('clid', $reg['client_id']);
+        $this->assertEquals($orig['response_types'], $reg['response_types']);
+        $this->assertEquals($orig['initiate_login_uri'], $reg['initiate_login_uri']);
+        $this->assertEquals($orig['redirect_uris'], $reg['redirect_uris']);
+        $this->assertEquals($orig['jwks_uri'], $reg['jwks_uri']);
+        $this->assertEquals('', $reg['scope']);
+        $ltiorig = $orig['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $lti = $reg['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $this->assertTrue(in_array('iss', $lti['claims']));
+        $this->assertTrue(in_array('sub', $lti['claims']));
+        $this->assertFalse(in_array('email', $lti['claims']));
+        $this->assertFalse(in_array('family_name', $lti['claims']));
+        $this->assertFalse(in_array('given_name', $lti['claims']));
+        $this->assertFalse(in_array('name', $lti['claims']));
+    }
+
 }
index 7a58644..51fe4c0 100644 (file)
@@ -2002,7 +2002,7 @@ class quiz_attempt {
         // Transition to the appropriate state.
         switch ($this->quizobj->get_quiz()->overduehandling) {
             case 'autosubmit':
-                $this->process_finish($timestamp, false);
+                $this->process_finish($timestamp, false, $studentisonline ? $timestamp : $timeclose);
                 return;
 
             case 'graceperiod':
@@ -2160,7 +2160,20 @@ class quiz_attempt {
         $transaction->allow_commit();
     }
 
-    public function process_finish($timestamp, $processsubmitted) {
+    /**
+     * Submit the attempt.
+     *
+     * The separate $timefinish argument should be used when the quiz attempt
+     * is being processed asynchronously (for example when cron is submitting
+     * attempts where the time has expired).
+     *
+     * @param int $timestamp the time to record as last modified time.
+     * @param bool $processsubmitted if true, and question responses in the current
+     *      POST request are stored to be graded, before the attempt is finished.
+     * @param ?int $timefinish if set, use this as the finish time for the attempt.
+     *      (otherwise use $timestamp as the finish time as well).
+     */
+    public function process_finish($timestamp, $processsubmitted, $timefinish = null) {
         global $DB;
 
         $transaction = $DB->start_delegated_transaction();
@@ -2173,7 +2186,7 @@ class quiz_attempt {
         question_engine::save_questions_usage_by_activity($this->quba);
 
         $this->attempt->timemodified = $timestamp;
-        $this->attempt->timefinish = $timestamp;
+        $this->attempt->timefinish = $timefinish ?? $timestamp;
         $this->attempt->sumgrades = $this->quba->get_total_mark();
         $this->attempt->state = self::FINISHED;
         $this->attempt->timecheckstate = null;
@@ -2440,7 +2453,7 @@ class quiz_attempt {
             if ($becomingabandoned) {
                 $this->process_abandon($timenow, true);
             } else {
-                $this->process_finish($timenow, !$toolate);
+                $this->process_finish($timenow, !$toolate, $toolate ? $timeclose : $timenow);
             }
 
         } catch (question_out_of_sequence_exception $e) {
index ae31ddb..246641f 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-$capabilities = array(
+$capabilities = [
 
     // Ability to see that the quiz exists, and the basic information
     // about it, for example the start date and time limit.
-    'mod/quiz:view' => array(
+    'mod/quiz:view' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'guest' => CAP_ALLOW,
             'student' => CAP_ALLOW,
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Ability to add a new quiz to the course.
-    'mod/quiz:addinstance' => array(
+    'mod/quiz:addinstance' => [
         'riskbitmask' => RISK_XSS,
 
         'captype' => 'write',
         'contextlevel' => CONTEXT_COURSE,
-        'archetypes' => array(
+        'archetypes' => [
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        ),
+        ],
         'clonepermissionsfrom' => 'moodle/course:manageactivities'
-    ),
+    ],
 
     // Ability to do the quiz as a 'student'.
-    'mod/quiz:attempt' => array(
+    'mod/quiz:attempt' => [
         'riskbitmask' => RISK_SPAM,
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'student' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Ability for a 'Student' to review their previous attempts. Review by
     // 'Teachers' is controlled by mod/quiz:viewreports.
-    'mod/quiz:reviewmyattempts' => array(
+    'mod/quiz:reviewmyattempts' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'student' => CAP_ALLOW
-        ),
+        ],
         'clonepermissionsfrom' => 'moodle/quiz:attempt'
-    ),
+    ],
 
     // Edit the quiz settings, add and remove questions.
-    'mod/quiz:manage' => array(
+    'mod/quiz:manage' => [
         'riskbitmask' => RISK_SPAM,
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Edit the quiz overrides.
-    'mod/quiz:manageoverrides' => array(
+    'mod/quiz:manageoverrides' => [
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
+
+    // View the quiz overrides (only checked for users who don't have mod/quiz:manageoverrides.
+    'mod/quiz:viewoverrides' => [
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => [
+            'teacher' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ]
+    ],
 
     // Preview the quiz.
-    'mod/quiz:preview' => array(
+    'mod/quiz:preview' => [
         'captype' => 'write', // Only just a write.
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Manually grade and comment on student attempts at a question.
-    'mod/quiz:grade' => array(
+    'mod/quiz:grade' => [
         'riskbitmask' => RISK_SPAM | RISK_XSS,
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Regrade quizzes.
-    'mod/quiz:regrade' => array(
+    'mod/quiz:regrade' => [
         'riskbitmask' => RISK_SPAM,
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        ),
+        ],
         'clonepermissionsfrom' =>  'mod/quiz:grade'
-    ),
+    ],
 
     // View the quiz reports.
-    'mod/quiz:viewreports' => array(
+    'mod/quiz:viewreports' => [
         'riskbitmask' => RISK_PERSONAL,
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Delete attempts using the overview report.
-    'mod/quiz:deleteattempts' => array(
+    'mod/quiz:deleteattempts' => [
         'riskbitmask' => RISK_DATALOSS,
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array(
+        'archetypes' => [
             'editingteacher' => CAP_ALLOW,
             'manager' => CAP_ALLOW
-        )
-    ),
+        ]
+    ],
 
     // Do not have the time limit imposed. Used for accessibility legislation compliance.
-    'mod/quiz:ignoretimelimits' => array(
+    'mod/quiz:ignoretimelimits' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array()
-    ),
+        'archetypes' => []
+    ],
 
     // Receive a confirmation message of own quiz submission.
-    'mod/quiz:emailconfirmsubmission' => array(
+    'mod/quiz:emailconfirmsubmission' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array()
-    ),
+        'archetypes' => []
+    ],
 
     // Receive a notification message of other peoples' quiz submissions.
-    'mod/quiz:emailnotifysubmission' => array(
+    'mod/quiz:emailnotifysubmission' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array()
-    ),
+        'archetypes' => []
+    ],
 
     // Receive a notification message when a quiz attempt becomes overdue.
-    'mod/quiz:emailwarnoverdue' => array(
+    'mod/quiz:emailwarnoverdue' => [
         'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
-        'archetypes' => array()
-    ),
-);
+        'archetypes' => []
+    ],
+];
 
index 29a19bc..ecadc82 100644 (file)
@@ -1,3 +1,4 @@
 numattemptsmade,mod_quiz
 reviewofattempt,mod_quiz
-reviewofpreview,mod_quiz
\ No newline at end of file
+reviewofpreview,mod_quiz
+settingsoverrides,mod_quiz
index b268444..714926d 100644 (file)
@@ -613,6 +613,14 @@ $string['overridedeleteusersure'] = 'Are you sure you want to delete the overrid
 $string['overridegroup'] = 'Override group';
 $string['overridegroupeventname'] = '{$a->quiz} - {$a->group}';
 $string['overrides'] = 'Overrides';
+$string['overridesforquiz'] = 'Settings overrides: {$a}';
+$string['overridesnoneforgroups'] = 'No group settings overrides have been created for this quiz.';
+$string['overridesnoneforusers'] = 'No user settings overrides have been created for this quiz.';
+$string['overridessummary'] = 'Settings overrides exist ({$a})';
+$string['overridessummarythisgroup'] = 'Settings overrides exist ({$a}) for this group';
+$string['overridessummaryyourgroups'] = 'Settings overrides exist ({$a}) for your groups';
+$string['overridessummarygroup'] = 'Groups: {$a}';
+$string['overridessummaryuser'] = 'Users: {$a}';
 $string['overrideuser'] = 'Override user';
 $string['overrideusereventname'] = '{$a->quiz} - Override';
 $string['pageshort'] = 'P';
@@ -724,7 +732,8 @@ $string['quizisopen'] = 'This quiz is open';
 $string['quizisclosedwillopen'] = 'Quiz closed (opens {$a})';
 $string['quizisopenwillclose'] = 'Quiz open (closes {$a})';
 $string['quiz:manage'] = 'Manage quizzes';
-$string['quiz:manageoverrides'] = 'Manage quiz overrides';
+$string['quiz:manageoverrides'] = 'Manage quiz settings overrides';
+$string['quiz:viewoverrides'] = 'View quiz settings overrides';
 $string['quiznavigation'] = 'Quiz navigation';
 $string['quizopen'] = 'Open the quiz';
 $string['quizeventopens'] = '{$a} opens';
@@ -886,7 +895,6 @@ $string['serveridentifier'] = 'Identifier';
 $string['serverinfo'] = 'Server information';
 $string['servers'] = 'Servers';
 $string['serverurl'] = 'Server URL';
-$string['settingsoverrides'] = 'Settings overrides';
 $string['shortanswer'] = 'Short answer';
 $string['show'] = 'Show';
 $string['showall'] = 'Show all questions on one page';
@@ -1008,3 +1016,4 @@ $string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.';
 $string['numattemptsmade'] = '{$a} attempts made on this quiz';
 $string['reviewofattempt'] = 'Review of attempt {$a}';
 $string['reviewofpreview'] = 'Review of preview';
+$string['settingsoverrides'] = 'Settings overrides';
index eb9b090..37873c6 100644 (file)
@@ -1645,16 +1645,10 @@ function quiz_num_attempt_summary($quiz, $cm, $returnzero = false, $currentgroup
  */
 function quiz_attempt_summary_link_to_reports($quiz, $cm, $context, $returnzero = false,
         $currentgroup = 0) {
-    global $CFG;
-    $summary = quiz_num_attempt_summary($quiz, $cm, $returnzero, $currentgroup);
-    if (!$summary) {
-        return '';
-    }
+    global $PAGE;
 
-    require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
-    $url = new moodle_url('/mod/quiz/report.php', array(
-            'id' => $cm->id, 'mode' => quiz_report_default_report($context)));
-    return html_writer::link($url, $summary);
+    return $PAGE->get_renderer('mod_quiz')->quiz_attempt_summary_link_to_reports(
+            $quiz, $cm, $context, $returnzero, $currentgroup);
 }
 
 /**
@@ -1716,7 +1710,7 @@ function quiz_extend_settings_navigation($settings, $quiznode) {
         $beforekey = $keys[$i + 1];
     }
 
-    if (has_capability('mod/quiz:manageoverrides', $PAGE->cm->context)) {
+    if (has_any_capability(['mod/quiz:manageoverrides', 'mod/quiz:viewoverrides'], $PAGE->cm->context)) {
         $url = new moodle_url('/mod/quiz/overrides.php', array('cmid'=>$PAGE->cm->id));
         $node = navigation_node::create(get_string('groupoverrides', 'quiz'),
                 new moodle_url($url, array('mode'=>'group')),
index 7768db8..ac30401 100644 (file)
@@ -968,6 +968,72 @@ function quiz_update_all_final_grades($quiz) {
     }
 }
 
+/**
+ * Return summary of the number of settings override that exist.
+ *
+ * To get a nice display of this, see the quiz_override_summary_links()
+ * quiz renderer method.
+ *
+ * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment.
+ * @param stdClass|cm_info $cm the cm object. Only $cm->course, $cm->groupmode and
+ *      $cm->groupingid fields are used at the moment.
+ * @param int $currentgroup if there is a concept of current group where this method is being called
+ *      (e.g. a report) pass it in here. Default 0 which means no current group.
+ * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides,
+ *      and 12 is the number of user ones.
+ */
+function quiz_override_summary(stdClass $quiz, stdClass $cm, int $currentgroup = 0): array {
+    global $DB;
+
+    if ($currentgroup) {
+        // Currently only interested in one group.
+        $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]);
+        $usercount = $DB->count_records_sql("
+                SELECT COUNT(1)
+                  FROM {quiz_overrides} o
+                  JOIN {groups_members} gm ON o.userid = gm.userid
+                 WHERE o.quiz = ?
+                   AND gm.groupid = ?
+                    ", [$quiz->id, $currentgroup]);
+        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup'];
+    }
+
+    $quizgroupmode = groups_get_activity_groupmode($cm);
+    $accessallgroups = ($quizgroupmode == NOGROUPS) ||
+            has_capability('moodle/site:accessallgroups', context_module::instance($cm->id));
+
+    if ($accessallgroups) {
+        // User can see all groups.
+        $groupcount = $DB->count_records_select('quiz_overrides',
+                'quiz = ? AND groupid IS NOT NULL', [$quiz->id]);
+        $usercount = $DB->count_records_select('quiz_overrides',
+                'quiz = ? AND userid IS NOT NULL', [$quiz->id]);
+        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups'];
+
+    } else {
+        // User can only see groups they are in.
+        $groups = groups_get_activity_allowed_groups($cm);
+        if (!$groups) {
+            return ['group' => 0, 'user' => 0, 'mode' => 'somegroups'];
+        }
+
+        list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups));
+        $params[] = $quiz->id;
+
+        $groupcount = $DB->count_records_select('quiz_overrides',
+                "groupid $groupidtest AND quiz = ?", $params);
+        $usercount = $DB->count_records_sql("
+                SELECT COUNT(1)
+                  FROM {quiz_overrides} o
+                  JOIN {groups_members} gm ON o.userid = gm.userid
+                 WHERE gm.groupid $groupidtest
+                   AND o.quiz = ?
+               ", $params);
+
+        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups'];
+    }
+}
+
 /**
  * Efficiently update check state time on all open attempts
  *
index a42c3d6..30b5fee 100644 (file)
@@ -22,7 +22,6 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-
 require_once(__DIR__ . '/../../config.php');
 require_once($CFG->dirroot.'/mod/quiz/lib.php');
 require_once($CFG->dirroot.'/mod/quiz/locallib.php');
@@ -33,14 +32,17 @@ $cmid = required_param('cmid', PARAM_INT);
 $mode = optional_param('mode', '', PARAM_ALPHA); // One of 'user' or 'group', default is 'group'.
 
 list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz');
-$quiz = $DB->get_record('quiz', array('id' => $cm->instance), '*', MUST_EXIST);
+$quiz = $DB->get_record('quiz', ['id' => $cm->instance], '*', MUST_EXIST);
 
 require_login($course, false, $cm);
 
 $context = context_module::instance($cm->id);
 
 // Check the user has the required capabilities to list overrides.
-require_capability('mod/quiz:manageoverrides', $context);
+$canedit = has_capability('mod/quiz:manageoverrides', $context);
+if (!$canedit) {
+    require_capability('mod/quiz:viewoverrides', $context);
+}
 
 $quizgroupmode = groups_get_activity_groupmode($cm);
 $accessallgroups = ($quizgroupmode == NOGROUPS) || has_capability('moodle/site:accessallgroups', $context);
@@ -58,16 +60,14 @@ if ($mode != "user" and $mode != "group") {
 }
 $groupmode = ($mode == "group");
 
-$url = new moodle_url('/mod/quiz/overrides.php', array('cmid'=>$cm->id, 'mode'=>$mode));
+$url = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id, 'mode' => $mode]);
 
+$title = get_string('overridesforquiz', 'quiz',
+        format_string($quiz->name, true, ['context' => $context]));
 $PAGE->set_url($url);
-
-// Display a list of overrides.
 $PAGE->set_pagelayout('admin');
-$PAGE->set_title(get_string('overrides', 'quiz'));
+$PAGE->set_title($title);
 $PAGE->set_heading($course->fullname);
-echo $OUTPUT->header();
-echo $OUTPUT->heading(format_string($quiz->name, true, array('context' => $context)));
 
 // Delete orphaned group overrides.
 $sql = 'SELECT o.id
@@ -76,7 +76,7 @@ $sql = 'SELECT o.id
          WHERE o.groupid IS NOT NULL
                AND g.id IS NULL
                AND o.quiz = ?';
-$params = array($quiz->id);
+$params = [$quiz->id];
 $orphaned = $DB->get_records_sql($sql, $params);
 if (!empty($orphaned)) {
     $DB->delete_records_list('quiz_overrides', 'id', array_keys($orphaned));
@@ -102,6 +102,7 @@ if ($groupmode) {
         $overrides = $DB->get_records_sql($sql, $params);
     }
 } else {
+    // User overrides.
     $colname = get_string('user');
     list($sort, $params) = users_order_by_sql('u');
     $params['quizid'] = $quiz->id;
@@ -131,16 +132,18 @@ if ($groupmode) {
 
 // Initialise table.
 $table = new html_table();
-$table->headspan = array(1, 2, 1);
-$table->colclasses = array('colname', 'colsetting', 'colvalue', 'colaction');
-$table->head = array(
-        $colname,
-        get_string('overrides', 'quiz'),
-        get_string('action'),
-);
+$table->headspan = [1, 2, 1];
+$table->colclasses = ['colname', 'colsetting', 'colvalue', 'colaction'];
+$table->head = [
+    $colname,
+    get_string('overrides', 'quiz'),
+];
+if ($canedit) {
+    $table->head[] = get_string('action');
+}
 
-$userurl = new moodle_url('/user/view.php', array());
-$groupurl = new moodle_url('/group/overview.php', array('id' => $cm->course));
+$userurl = new moodle_url('/user/view.php', []);
+$groupurl = new moodle_url('/group/overview.php', ['id' => $cm->course]);
 
 $overridedeleteurl = new moodle_url('/mod/quiz/overridedelete.php');
 $overrideediturl = new moodle_url('/mod/quiz/overrideedit.php');
@@ -149,11 +152,8 @@ $hasinactive = false; // Whether there are any inactive overrides.
 
 foreach ($overrides as $override) {
 
-    $fields = array();
-    $values = array();
+    // Check if this override is active.
     $active = true;
-
-    // Check for inactive overrides.
     if (!$groupmode) {
         if (!has_capability('mod/quiz:attempt', $context, $override->userid)) {
             // User not allowed to take the quiz.
@@ -163,6 +163,13 @@ foreach ($overrides as $override) {
             $active = false;
         }
     }
+    if (!$active) {
+        $hasinactive = true;
+    }
+
+    // Prepare the information about which settings are overridden.
+    $fields = [];
+    $values = [];
 
     // Format timeopen.
     if (isset($override->timeopen)) {
@@ -170,28 +177,24 @@ foreach ($overrides as $override) {
         $values[] = $override->timeopen > 0 ?
                 userdate($override->timeopen) : get_string('noopen', 'quiz');
     }
-
     // Format timeclose.
     if (isset($override->timeclose)) {
         $fields[] = get_string('quizcloses', 'quiz');
         $values[] = $override->timeclose > 0 ?
                 userdate($override->timeclose) : get_string('noclose', 'quiz');
     }
-
     // Format timelimit.
     if (isset($override->timelimit)) {
         $fields[] = get_string('timelimit', 'quiz');
         $values[] = $override->timelimit > 0 ?
                 format_time($override->timelimit) : get_string('none', 'quiz');
     }
-
     // Format number of attempts.
     if (isset($override->attempts)) {
         $fields[] = get_string('attempts', 'quiz');
         $values[] = $override->attempts > 0 ?
                 $override->attempts : get_string('unlimited');
     }
-
     // Format password.
     if (isset($override->password)) {
         $fields[] = get_string('requirepassword', 'quiz');
@@ -199,110 +202,130 @@ foreach ($overrides as $override) {
                 get_string('enabled', 'quiz') : get_string('none', 'quiz');
     }
 
-    // Icons.
-    $iconstr = '';
-
-    // Edit.
-    $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
-    $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
-            $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
-    // Duplicate.
-    $copyurlstr = $overrideediturl->out(true,
-            array('id' => $override->id, 'action' => 'duplicate'));
-    $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
-            $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
-    // Delete.
-    $deleteurlstr = $overridedeleteurl->out(true,
-            array('id' => $override->id, 'sesskey' => sesskey()));
-    $iconstr .= '<a title="' . get_string('delete') . '" href="' . $deleteurlstr . '">' .
-                $OUTPUT->pix_icon('t/delete', get_string('delete')) . '</a> ';
-
+    // Prepare the information about who this override applies to.
     if ($groupmode) {
         $usergroupstr = '<a href="' . $groupurl->out(true,
-                array('group' => $override->groupid)) . '" >' . $override->name . '</a>';
+                        ['group' => $override->groupid]) . '" >' . $override->name . '</a>';
     } else {
         $usergroupstr = '<a href="' . $userurl->out(true,
-                array('id' => $override->userid)) . '" >' . fullname($override) . '</a>';
+                        ['id' => $override->userid]) . '" >' . fullname($override) . '</a>';
     }
-
-    $class = '';
     if (!$active) {
-        $class = "dimmed_text";
         $usergroupstr .= '*';
-        $hasinactive = true;
     }
-
     $usergroupcell = new html_table_cell();
     $usergroupcell->rowspan = count($fields);
     $usergroupcell->text = $usergroupstr;
-    $actioncell = new html_table_cell();
-    $actioncell->rowspan = count($fields);
-    $actioncell->text = $iconstr;
 
+    // Prepare the actions.
+    if ($canedit) {
+        // Icons.
+        $iconstr = '';
+
+        // Edit.
+        $editurlstr = $overrideediturl->out(true, ['id' => $override->id]);
+        $iconstr = '<a title="' . get_string('edit') . '" href="' . $editurlstr . '">' .
+                $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
+        // Duplicate.
+        $copyurlstr = $overrideediturl->out(true,
+                ['id' => $override->id, 'action' => 'duplicate']);
+        $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
+                $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
+        // Delete.
+        $deleteurlstr = $overridedeleteurl->out(true,
+                ['id' => $override->id, 'sesskey' => sesskey()]);
+        $iconstr .= '<a title="' . get_string('delete') . '" href="' . $deleteurlstr . '">' .
+                $OUTPUT->pix_icon('t/delete', get_string('delete')) . '</a> ';
+
+        $actioncell = new html_table_cell();
+        $actioncell->rowspan = count($fields);
+        $actioncell->text = $iconstr;
+    }
+
+    // Add the data to the table.
     for ($i = 0; $i < count($fields); ++$i) {
         $row = new html_table_row();
-        $row->attributes['class'] = $class;
+        if (!$active) {
+            $row->attributes['class'] = 'dimmed_text';
+        }
+
         if ($i == 0) {
             $row->cells[] = $usergroupcell;
         }
-        $cell1 = new html_table_cell();
-        $cell1->text = $fields[$i];
-        $row->cells[] = $cell1;
-        $cell2 = new html_table_cell();
-        $cell2->text = $values[$i];
-        $row->cells[] = $cell2;
-        if ($i == 0) {
+
+        $labelcell = new html_table_cell();
+        $labelcell->text = $fields[$i];
+        $row->cells[] = $labelcell;
+        $valuecell = new html_table_cell();
+        $valuecell->text = $values[$i];
+        $row->cells[] = $valuecell;
+
+        if ($canedit && $i == 0) {
             $row->cells[] = $actioncell;
         }
+
         $table->data[] = $row;
     }
 }
 
+// Display a list of overrides.
+echo $OUTPUT->header();
+echo $OUTPUT->heading($title);
+
 // Output the table and button.
-echo html_writer::start_tag('div', array('id' => 'quizoverrides'));
+echo html_writer::start_tag('div', ['id' => 'quizoverrides']);
 if (count($table->data)) {
     echo html_writer::table($table);
+} else {
+    if ($groupmode) {
+        echo $OUTPUT->notification(get_string('overridesnoneforgroups', 'quiz'), 'info', false);
+    } else {
+        echo $OUTPUT->notification(get_string('overridesnoneforusers', 'quiz'), 'info', false);
+    }
 }
 if ($hasinactive) {
-    echo $OUTPUT->notification(get_string('inactiveoverridehelp', 'quiz'), 'dimmed_text');
+    echo $OUTPUT->notification(get_string('inactiveoverridehelp', 'quiz'), 'info', false);
 }
 
-echo html_writer::start_tag('div', array('class' => 'buttons'));
-$options = array();
-if ($groupmode) {
-    if (empty($groups)) {
-        // There are no groups.
-        echo $OUTPUT->notification(get_string('groupsnone', 'quiz'), 'error');
-        $options['disabled'] = true;
-    }
-    echo $OUTPUT->single_button($overrideediturl->out(true,
-            array('action' => 'addgroup', 'cmid' => $cm->id)),
-            get_string('addnewgroupoverride', 'quiz'), 'post', $options);
-} else {
-    $users = array();
-    // See if there are any students in the quiz.
-    if ($accessallgroups) {
-        $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id');
-        $nousermessage = get_string('usersnone', 'quiz');
-    } else if ($groups) {
-        $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id', '', '', '', array_keys($groups));
-        $nousermessage = get_string('usersnone', 'quiz');
+if ($canedit) {
+    echo html_writer::start_tag('div', ['class' => 'buttons']);
+    $options = [];
+    if ($groupmode) {
+        if (empty($groups)) {
+            // There are no groups.
+            echo $OUTPUT->notification(get_string('groupsnone', 'quiz'), 'error');
+            $options['disabled'] = true;
+        }
+        echo $OUTPUT->single_button($overrideediturl->out(true,
+                ['action' => 'addgroup', 'cmid' => $cm->id]),
+                get_string('addnewgroupoverride', 'quiz'), 'post', $options);
     } else {
-        $nousermessage = get_string('groupsnone', 'quiz');
-    }
-    $info = new \core_availability\info_module($cm);
-    $users = $info->filter_user_list($users);
+        $users = [];
+        // See if there are any students in the quiz.
+        if ($accessallgroups) {
+            $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id');
+            $nousermessage = get_string('usersnone', 'quiz');
+        } else if ($groups) {
+            $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id', '', '', '', array_keys($groups));
+            $nousermessage = get_string('usersnone', 'quiz');
+        } else {
+            $nousermessage = get_string('groupsnone', 'quiz');
+        }
+        $info = new \core_availability\info_module($cm);
+        $users = $info->filter_user_list($users);
 
-    if (empty($users)) {
-        // There are no students.
-        echo $OUTPUT->notification($nousermessage, 'error');
-        $options['disabled'] = true;
+        if (empty($users)) {
+            // There are no students.
+            echo $OUTPUT->notification($nousermessage, 'error');
+            $options['disabled'] = true;
+        }
+        echo $OUTPUT->single_button($overrideediturl->out(true,
+                ['action' => 'adduser', 'cmid' => $cm->id]),
+                get_string('addnewuseroverride', 'quiz'), 'get', $options);
     }
-    echo $OUTPUT->single_button($overrideediturl->out(true,
-            array('action' => 'adduser', 'cmid' => $cm->id)),
-            get_string('addnewuseroverride', 'quiz'), 'get', $options);
+    echo html_writer::end_tag('div');
 }
-echo html_writer::end_tag('div');
+
 echo html_writer::end_tag('div');
 
 // Finish the page.
index 3631596..33a3164 100644 (file)
@@ -927,7 +927,7 @@ class mod_quiz_renderer extends plugin_renderer_base {
      *
      * @param object $quiz the quiz settings.
      * @param object $cm the course_module object.
-     * @param object $context the quiz context.
+     * @param context $context the quiz context.
      * @param array $messages any access messages that should be described.
      * @return string HTML to output.
      */
@@ -953,6 +953,13 @@ class mod_quiz_renderer extends plugin_renderer_base {
                         array('class' => 'quizattemptcounts'));
             }
         }
+
+        if (has_any_capability(['mod/quiz:manageoverrides', 'mod/quiz:viewoverrides'], $context)) {
+            if ($overrideinfo = $this->quiz_override_summary_links($quiz, $cm)) {
+                $output .= html_writer::tag('div', $overrideinfo, ['class' => 'quizattemptcounts']);
+            }
+        }
+
         return $output;
     }
 
@@ -1216,15 +1223,14 @@ class mod_quiz_renderer extends plugin_renderer_base {
      * Returns the same as {@link quiz_num_attempt_summary()} but wrapped in a link
      * to the quiz reports.
      *
-     * @param object $quiz the quiz object. Only $quiz->id is used at the moment.
-     * @param object $cm the cm object. Only $cm->course, $cm->groupmode and $cm->groupingid
+     * @param stdClass $quiz the quiz object. Only $quiz->id is used at the moment.
+     * @param stdClass $cm the cm object. Only $cm->course, $cm->groupmode and $cm->groupingid
      * fields are used at the moment.
-     * @param object $context the quiz context.
+     * @param context $context the quiz context.
      * @param bool $returnzero if false (default), when no attempts have been made '' is returned
-     * instead of 'Attempts: 0'.
+     *      instead of 'Attempts: 0'.
      * @param int $currentgroup if there is a concept of current group where this method is being
-     * called
-     *         (e.g. a report) pass it in here. Default 0 which means no current group.
+     *      called (e.g. a report) pass it in here. Default 0 which means no current group.
      * @return string HTML fragment for the link.
      */
     public function quiz_attempt_summary_link_to_reports($quiz, $cm, $context,
@@ -1241,6 +1247,49 @@ class mod_quiz_renderer extends plugin_renderer_base {
         return html_writer::link($url, $summary);
     }
 
+    /**
+     * Render a summary of the number of group and user overrides, with corresponding links.
+     *
+     * @param stdClass $quiz the quiz settings.
+     * @param stdClass|cm_info $cm the cm object.
+     * @param int $currentgroup currently selected group, if there is one.
+     * @return string HTML fragment for the link.
+     */
+    public function quiz_override_summary_links(stdClass $quiz, stdClass $cm, $currentgroup = 0): string {
+
+        $baseurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]);
+        $counts = quiz_override_summary($quiz, $cm, $currentgroup);
+
+        $links = [];
+        if ($counts['group']) {
+            $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'group']),
+                    get_string('overridessummarygroup', 'quiz', $counts['group']));
+        }
+        if ($counts['user']) {
+            $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'user']),
+                    get_string('overridessummaryuser', 'quiz', $counts['user']));
+        }
+
+        if (!$links) {
+            return '';
+        }
+
+        $links = implode(', ', $links);
+        switch ($counts['mode']) {
+            case 'onegroup':
+                return get_string('overridessummarythisgroup', 'quiz', $links);
+
+            case 'somegroups':
+                return get_string('overridessummaryyourgroups', 'quiz', $links);
+
+            case 'allgroups':
+                return get_string('overridessummary', 'quiz', $links);
+
+            default:
+                throw new coding_exception('Unexpected mode ' . $counts['mode']);
+        }
+    }
+
     /**
      * Outputs a chart.
      *
index 0580070..81c707a 100644 (file)
@@ -81,9 +81,6 @@ class mod_quiz_attempt_testcase extends advanced_testcase {
         return quiz_attempt::create($attempt->id);
     }
 
-    /**
-     * Test the functions quiz_update_open_attempts() and get_list_of_overdue_attempts()
-     */
     public function test_attempt_url() {
         $attempt = $this->create_quiz_and_attempt_with_layout('1,2,0,3,4,0,5,6,0');
 
index 00a638d..161be62 100644 (file)
@@ -37,8 +37,10 @@ require_once($CFG->dirroot.'/group/lib.php');
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class mod_quiz_attempt_overdue_testcase extends advanced_testcase {
+
     /**
-     * Test the functions quiz_update_open_attempts() and get_list_of_overdue_attempts()
+     * Test the functions quiz_update_open_attempts(), get_list_of_overdue_attempts() and
+     * update_overdue_attempts().
      */
     public function test_bulk_update_functions() {
         global $DB,$CFG;
@@ -53,245 +55,342 @@ class mod_quiz_attempt_overdue_testcase extends advanced_testcase {
 
         $course = $this->getDataGenerator()->create_course();
         $user1 = $this->getDataGenerator()->create_user();
-        $studentrole = $DB->get_record('role', array('shortname'=>'student'));
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
         $this->assertNotEmpty($studentrole);
         $this->assertTrue(enrol_try_internal_enrol($course->id, $user1->id, $studentrole->id));
-        $group1 = $this->getDataGenerator()->create_group(array('courseid'=>$course->id));
-        $group2 = $this->getDataGenerator()->create_group(array('courseid'=>$course->id));
-        $group3 = $this->getDataGenerator()->create_group(array('courseid'=>$course->id));
+        $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $group3 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
         $this->assertTrue(groups_add_member($group1, $user1));
         $this->assertTrue(groups_add_member($group2, $user1));
 
-        $uniqueid = 0;
-        $usertimes = array();
+        $usertimes = [];
 
-        $quiz_generator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 
         // Basic quiz settings
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>600, 'message'=>'Test1A');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test1A', 'time1000state' => 'finished'];
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>1800));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>1800, 'message'=>'Test1B');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 1800]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 1800, 'message' => 'Test1B', 'time1000state' => 'inprogress'];
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>0, 'message'=>'Test1C');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 0, 'message' => 'Test1C', 'time1000state' => 'inprogress'];
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>0, 'timelimit'=>600));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>600, 'message'=>'Test1D');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 600, 'message' => 'Test1D', 'time1000state' => 'finished'];
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>0, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>0, 'message'=>'Test1E');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 0, 'message' => 'Test1E', 'time1000state' => 'inprogress'];
 
         // Group overrides
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>null));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>0, 'message'=>'Test2A');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>0));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1100, 'timelimit'=>null));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1100, 'timelimit'=>0, 'message'=>'Test2B');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>0, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>null, 'timelimit'=>700));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>700, 'message'=>'Test2C');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>0, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>null, 'timelimit'=>500));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>500, 'message'=>'Test2D');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>0, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>null, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>0, '', 'message'=>'Test2E');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>500));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>500, '', 'message'=>'Test2F');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>500, '', 'message'=>'Test2G');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group3->id, 'timeclose'=>1300, 'timelimit'=>500)); // user not in group
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>600, '', 'message'=>'Test2H');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>600, '', 'message'=>'Test2I');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => null]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 0, 'message' => 'Test2A', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1100, 'timelimit' => null]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1100, 'timelimit' => 0, 'message' => 'Test2B', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(
+                ['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600, 'overduehandling' => 'autoabandon']);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 700]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 700, 'message' => 'Test2C', 'time1000state' => 'abandoned'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 500]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 500, 'message' => 'Test2D', 'time1000state' => 'finished'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 0, 'message' => 'Test2E', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 500, 'message' => 'Test2F', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 500, 'message' => 'Test2G', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group3->id, 'timeclose' => 1300, 'timelimit' => 500]); // User not in group.
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test2H', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test2I', 'time1000state' => 'inprogress'];
 
         // Multiple group overrides
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>501));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>1301, 'timelimit'=>500));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3A');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3B');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1301, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>1300, 'timelimit'=>501));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3C');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3D');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1301, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>1300, 'timelimit'=>501));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group3->id, 'timeclose'=>1500, 'timelimit'=>1000)); // user not in group
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3E');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>501, '', 'message'=>'Test3F');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>null, 'timelimit'=>501));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>501, '', 'message'=>'Test3G');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>501, '', 'message'=>'Test3H');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>1301, 'timelimit'=>null));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>500, '', 'message'=>'Test3I');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>500, '', 'message'=>'Test3J');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>1301, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>0, '', 'message'=>'Test3K');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1301, 'timelimit'=>0, '', 'message'=>'Test3L');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>500));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group2->id, 'timeclose'=>0, 'timelimit'=>501));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>501, '', 'message'=>'Test3M');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>501, '', 'message'=>'Test3N');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 501]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => 500]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3A', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3B', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1301, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1300, 'timelimit' => 501]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3C', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3D', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600,
+                'overduehandling' => 'autoabandon']);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1301, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1300, 'timelimit' => 501]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group3->id, 'timeclose' => 1500, 'timelimit' => 1000]); // User not in group.
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3E', 'time1000state' => 'abandoned'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3F', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => null, 'timelimit' => 501]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 501, 'message' => 'Test3G', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 501, 'message' => 'Test3H', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => null]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 500, 'message' => 'Test3I', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 500, 'message' => 'Test3J', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 0, 'message' => 'Test3K', 'time1000state' => 'inprogress'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 0, 'message' => 'Test3L', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 0, 'timelimit' => 501]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 501, 'message' => 'Test3M', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 501, 'message' => 'Test3N', 'time1000state' => 'inprogress'];
 
         // User overrides
 
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>1201, 'timelimit'=>601));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>601, '', 'message'=>'Test4A');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>601, '', 'message'=>'Test4B');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>0, 'timelimit'=>601));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>601, '', 'message'=>'Test4C');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>0, 'timelimit'=>601, '', 'message'=>'Test4D');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>1201, 'timelimit'=>0));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>0, '', 'message'=>'Test4E');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>0, '', 'message'=>'Test4F');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>null, 'timelimit'=>601));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>601, '', 'message'=>'Test4G');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>601, '', 'message'=>'Test4H');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>null, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>null, 'timelimit'=>601));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>601, '', 'message'=>'Test4I');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1200, 'timelimit'=>601, '', 'message'=>'Test4J');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>1201, 'timelimit'=>null));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>700, '', 'message'=>'Test4K');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>700, '', 'message'=>'Test4L');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>null));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'timeclose'=>1201, 'timelimit'=>null));
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>600, '', 'message'=>'Test4M');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1201, 'timelimit'=>600, '', 'message'=>'Test4N');
-
-        $quiz = $quiz_generator->create_instance(array('course'=>$course->id, 'timeclose'=>1200, 'timelimit'=>600));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'groupid'=>$group1->id, 'timeclose'=>1300, 'timelimit'=>700));
-        $DB->insert_record('quiz_overrides', array('quiz'=>$quiz->id, 'userid'=>0, 'timeclose'=>1201, 'timelimit'=>601)); // not user
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>100, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>700, '', 'message'=>'Test4O');
-        $attemptid = $DB->insert_record('quiz_attempts', array('quiz'=>$quiz->id, 'userid'=>$user1->id, 'state'=>'inprogress', 'timestart'=>1000, 'timecheckstate'=>0, 'layout'=>'', 'uniqueid'=>$uniqueid++, 'attempt'=>1));
-        $usertimes[$attemptid] = array('timeclose'=>1300, 'timelimit'=>700, '', 'message'=>'Test4P');
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => 601]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 601, 'message' => 'Test4A', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 601, 'message' => 'Test4B', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 0, 'timelimit' => 601]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 601, 'message' => 'Test4C', 'time1000state' => 'finished'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 601, 'message' => 'Test4D', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => 0]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 0, 'message' => 'Test4E', 'time1000state' => 'inprogress'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 0, 'message' => 'Test4F', 'time1000state' => 'inprogress'];
+
+        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600,
+                'overduehandling' => 'autoabandon']);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]);
+        $DB->insert_record('quiz_overrides',
+                ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => null, 'timelimit' => 601]);
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 601, 'message' => 'Test4G', 'time1000state' => 'abandoned'];
+        $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress',
+                'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]);
+        $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 601, 'message' => 'Test4H', 'time1000state' => 'inprogress'];
+