Merge branch 'wip-MDL-64729-master' of https://github.com/Beedell/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Thu, 21 Feb 2019 11:20:48 +0000 (12:20 +0100)
committerAdrian Greeve <abgreeve@gmail.com>
Thu, 21 Feb 2019 11:20:48 +0000 (12:20 +0100)
118 files changed:
.gitignore
admin/settings/subsystems.php
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/templates/invalid_analysables.mustache
admin/tool/langimport/classes/locale.php [new file with mode: 0644]
admin/tool/langimport/index.php
admin/tool/langimport/lang/en/tool_langimport.php
admin/tool/langimport/tests/locale_test.php [new file with mode: 0644]
admin/tool/lp/classes/external.php
admin/tool/lp/classes/external/user_competency_summary_in_course_exporter.php
admin/tool/lp/classes/output/course_competencies_page.php
admin/tool/lp/templates/course_competencies_page.mustache
admin/tool/lp/templates/user_competency_summary_in_course.mustache
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/mobile/classes/api.php
admin/tool/mobile/db/upgrade.php [new file with mode: 0644]
admin/tool/mobile/version.php
admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php [new file with mode: 0644]
admin/tool/xmldb/actions/edit_table/edit_table.class.php
admin/tool/xmldb/lang/en/tool_xmldb.php
blocks/login/block_login.php
cache/classes/loaders.php
cache/tests/cache_test.php
cache/tests/fixtures/lib.php
competency/classes/api.php
competency/tests/api_test.php
course/modlib.php
enrol/locallib.php
enrol/renderer.php
enrol/tests/course_enrolment_manager_test.php
enrol/upgrade.txt
enrol/yui/otherusersmanager/otherusersmanager.js
lang/en/admin.php
lang/en/competency.php
lang/en/enrol.php
lib/amd/build/checkbox-toggleall.min.js [new file with mode: 0644]
lib/amd/src/checkbox-toggleall.js [new file with mode: 0644]
lib/behat/form_field/behat_form_field.php
lib/classes/message/manager.php
lib/db/messages.php
lib/db/upgrade.php
lib/editor/atto/plugins/media/tests/behat/media.feature
lib/form/amd/build/showadvanced.min.js [new file with mode: 0644]
lib/form/amd/src/showadvanced.js [new file with mode: 0644]
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js [deleted file]
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js [deleted file]
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js [deleted file]
lib/form/yui/src/showadvanced/build.json [deleted file]
lib/form/yui/src/showadvanced/js/showadvanced.js [deleted file]
lib/form/yui/src/showadvanced/meta/showadvanced.json [deleted file]
lib/formslib.php
lib/moodlelib.php
lib/navigationlib.php
lib/templates/loginform.mustache
lib/upgrade.txt
login/change_password_form.php
login/forgot_password_form.php
message/amd/build/notification_processor_settings.min.js
message/amd/build/preferences_notifications_list_controller.min.js
message/amd/build/preferences_processor_form.min.js
message/amd/src/notification_processor_settings.js
message/amd/src/preferences_notifications_list_controller.js
message/amd/src/preferences_processor_form.js
message/classes/api.php
message/externallib.php
message/lib.php
message/templates/preferences_processor.mustache
message/tests/privacy_provider_test.php
mod/assign/classes/event/base.php
mod/assign/locallib.php
mod/assign/submission/file/locallib.php
mod/assign/submission/onlinetext/locallib.php
mod/assign/tests/events_test.php
mod/chat/lib.php
mod/chat/tests/lib_test.php
mod/glossary/view.php
question/amd/build/qbankmanager.min.js [new file with mode: 0644]
question/amd/src/qbankmanager.js [new file with mode: 0644]
question/classes/bank/checkbox_column.php
question/classes/bank/view.php
question/tests/behat/select_questions.feature [new file with mode: 0644]
question/type/essay/backup/moodle2/restore_qtype_essay_plugin.class.php
question/type/essay/tests/restore_test.php [new file with mode: 0644]
question/type/gapselect/renderer.php
question/type/gapselect/tests/walkthrough_test.php
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-debug.js [deleted file]
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-min.js [deleted file]
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager.js [deleted file]
question/yui/src/qbankmanager/build.json [deleted file]
question/yui/src/qbankmanager/js/qbankmanager.js [deleted file]
question/yui/src/qbankmanager/meta/qbankmanager.json [deleted file]
theme/boost/classes/output/core_renderer.php
theme/boost/layout/columns2.php
theme/boost/scss/moodle/forms.scss
theme/boost/style/moodle.css
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/boost/templates/core/help_icon.mustache
theme/boost/templates/core/loginform.mustache
theme/boost/templates/core/navbar.mustache
theme/boost/templates/core_form/element-password.mustache
theme/boost/templates/flat_navigation.mustache
theme/boost/templates/footer.mustache
theme/boost/templates/login.mustache
theme/boost/templates/maintenance.mustache
theme/boost/templates/navbar-secure.mustache
theme/boost/templates/secure.mustache
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/style/moodle.css
user/editadvanced_form.php
user/editlib.php
user/language_form.php
user/lib.php
user/tests/behat/behat_user.php
user/tests/behat/input-purpose.feature [new file with mode: 0644]
version.php

index 1dab0f7..b96359f 100644 (file)
@@ -9,10 +9,18 @@
 #
 # See gitignore(5) man page for more details
 #
+
+# Swap files (vim)
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+# Temporary files including undo
+*~
+#
 /config.php
 /lib/editor/tinymce/extra/tools/temp/
-*~
-*.swp
 /tags
 /TAGS
 /cscope.*
index a108b42..b559de3 100644 (file)
@@ -21,6 +21,12 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
         0)
     );
 
+    $optionalsubsystems->add(new admin_setting_configcheckbox('messagingdefaultpressenter',
+        new lang_string('messagingdefaultpressenter', 'admin'),
+        new lang_string('configmessagingdefaultpressenter', 'admin'),
+        1)
+    );
+
     $options = array(
         DAYSECS => new lang_string('secondstotime86400'),
         WEEKSECS => new lang_string('secondstotime604800'),
index a5bda09..0bb3902 100644 (file)
@@ -153,6 +153,12 @@ class invalid_analysables implements \renderable, \templatable {
             $data->analysables[] = $obj;
         }
 
+        if (empty($data->analysables)) {
+            $data->noanalysables = [
+                'message' => get_string('noinvalidanalysables', 'tool_analytics'),
+                'announce' => true,
+            ];
+        }
         return $data;
     }
 }
index cbd0a60..83e2755 100644 (file)
@@ -247,7 +247,7 @@ class models_list implements \renderable, \templatable {
             }
 
             // Clear model.
-            if (!empty($predictioncontexts)) {
+            if (!empty($predictioncontexts) || $model->is_trained()) {
                 $actionid = 'clear-' . $model->get_id();
                 $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'clear']);
                 $urlparams['action'] = 'clear';
index 8df2cc3..1c3bba2 100644 (file)
@@ -75,6 +75,7 @@ $string['nextpage'] = 'Next page';
 $string['nodatatoevaluate'] = 'There is no data to evaluate the model';
 $string['nodatatopredict'] = 'No new elements to get predictions for';
 $string['nodatatotrain'] = 'There is no new data that can be used for training';
+$string['noinvalidanalysables'] = 'This site does not contain any invalid analysable element.';
 $string['notdefined'] = 'Not yet defined';
 $string['pluginname'] = 'Analytic models';
 $string['predictionresults'] = 'Prediction results';
index c97dd6b..2b3ddd6 100644 (file)
 <div class="box">
     <h3>{{#str}}modelinvalidanalysables, tool_analytics, {{modelname}}{{/str}}</h3>
     <div>{{#str}}invalidanalysablesinfo, tool_analytics{{/str}}</div>
-    <div class="m-t-2 m-b-1">
-        <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
-        <span>{{#next}}{{> core/single_button}}{{/next}}</span>
-    </div>
-    <table class="generaltable fullwidth">
-        <caption class="accesshide">{{#str}}invalidanalysablestable, tool_analytics{{/str}}</caption>
-        <thead>
-            <tr>
-                <th scope="col">{{#str}}name{{/str}}</th>
-                <th scope="col">{{#str}}invalidtraining, tool_analytics{{/str}}</th>
-                <th scope="col">{{#str}}invalidprediction, tool_analytics{{/str}}</th>
-            </tr>
-        </thead>
-        <tbody>
-        {{#analysables}}
-            <tr>
-                <td>{{{url}}}</td>
-                <td>{{validtraining}}</td>
-                <td>{{validprediction}}</td>
-            </tr>
-        {{/analysables}}
-        </tbody>
-    </table>
-    <div class="m-t-1 m-b-2">
-        <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
-        <span>{{#next}}{{> core/single_button}}{{/next}}</span>
-    </div>
+    {{#noanalysables}}
+        <div class="m-t-2 m-b-1">
+            {{> core/notification_info}}
+        </div>
+    {{/noanalysables}}
+    {{^noanalysables}}
+        <div class="m-t-2 m-b-1">
+            <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
+            <span>{{#next}}{{> core/single_button}}{{/next}}</span>
+        </div>
+        <table class="generaltable fullwidth">
+            <caption class="accesshide">{{#str}}invalidanalysablestable, tool_analytics{{/str}}</caption>
+            <thead>
+                <tr>
+                    <th scope="col">{{#str}}name{{/str}}</th>
+                    <th scope="col">{{#str}}invalidtraining, tool_analytics{{/str}}</th>
+                    <th scope="col">{{#str}}invalidprediction, tool_analytics{{/str}}</th>
+                </tr>
+            </thead>
+            <tbody>
+            {{#analysables}}
+                <tr>
+                    <td>{{{url}}}</td>
+                    <td>{{validtraining}}</td>
+                    <td>{{validprediction}}</td>
+                </tr>
+            {{/analysables}}
+            </tbody>
+        </table>
+        <div class="m-t-1 m-b-2">
+            <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
+            <span>{{#next}}{{> core/single_button}}{{/next}}</span>
+        </div>
+    {{/noanalysables}}
 </div>
diff --git a/admin/tool/langimport/classes/locale.php b/admin/tool/langimport/classes/locale.php
new file mode 100644 (file)
index 0000000..69c43b6
--- /dev/null
@@ -0,0 +1,84 @@
+<?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/>.
+
+/**
+ * Helper class for the language import tool.
+ *
+ * @package    tool_langimport
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_langimport;
+
+use coding_exception;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Helper class for the language import tool.
+ *
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class locale {
+    /**
+     * Checks availability of locale on current operating system.
+     *
+     * @param string $langpackcode E.g.: en, es, fr, de.
+     * @return bool TRUE if the locale is available on OS.
+     * @throws coding_exception when $langpackcode parameter is a non-empty string.
+     */
+    public function check_locale_availability(string $langpackcode) : bool {
+        global $CFG;
+
+        if (empty($langpackcode)) {
+            throw new coding_exception('Invalid language pack code in \\'.__METHOD__.'() call, only non-empty string is allowed');
+        }
+
+        // Fetch the correct locale based on ostype.
+        if ($CFG->ostype === 'WINDOWS') {
+            $stringtofetch = 'localewin';
+        } else {
+            $stringtofetch = 'locale';
+        }
+
+        // Store current locale.
+        $currentlocale = $this->set_locale(LC_ALL, 0);
+
+        $locale = get_string_manager()->get_string($stringtofetch, 'langconfig', $a = null, $langpackcode);
+
+        // Try to set new locale.
+        $return = $this->set_locale(LC_ALL, $locale);
+
+        // Restore current locale.
+        $this->set_locale(LC_ALL, $currentlocale);
+
+        // If $return is not equal to false, it means that setlocale() succeed to change locale.
+        return $return !== false;
+    }
+
+    /**
+     * Wrap for the native PHP function setlocale().
+     *
+     * @param int $category Specifying the category of the functions affected by the locale setting.
+     * @param string $locale E.g.: en_AU.utf8, en_GB.utf8, es_ES.utf8, fr_FR.utf8, de_DE.utf8.
+     * @return string|false Returns the new current locale, or FALSE on error.
+     */
+    protected function set_locale(int $category = LC_ALL, string $locale = '0') {
+        return setlocale($category, $locale);
+    }
+}
index 92a09da..1180abc 100644 (file)
@@ -109,9 +109,16 @@ echo $OUTPUT->header();
 echo $OUTPUT->heading(get_string('langimport', 'tool_langimport'));
 
 $installedlangs = get_string_manager()->get_list_of_translations(true);
+$locale = new \tool_langimport\locale();
 
+$missinglocales = '';
 $missingparents = array();
-foreach ($installedlangs as $installedlang => $unused) {
+foreach ($installedlangs as $installedlang => $langpackname) {
+    // Check locale availability.
+    if (!$locale->check_locale_availability($installedlang)) {
+        $missinglocales .= '<li>'.$langpackname.'</li>';
+    }
+
     $parent = get_parent_language($installedlang);
     if (empty($parent)) {
         continue;
@@ -121,6 +128,14 @@ foreach ($installedlangs as $installedlang => $unused) {
     }
 }
 
+if (!empty($missinglocales)) {
+    // There is at least one missing locale.
+    $a = new stdClass();
+    $a->globallocale = moodle_getlocale();
+    $a->missinglocales = $missinglocales;
+    $controller->errors[] = get_string('langunsupported', 'tool_langimport', $a);
+}
+
 if ($availablelangs = $controller->availablelangs) {
     $remote = true;
 } else {
index faeb02c..27739ed 100644 (file)
@@ -37,6 +37,7 @@ $string['langpackupdateskipped'] = 'Update of \'{$a}\' language pack skipped';
 $string['langpackuptodate'] = 'Language pack \'{$a}\' is up-to-date';
 $string['langpackupdated'] = 'Language pack \'{$a}\' was successfully updated';
 $string['langpackupdatedevent'] = 'Language pack updated';
+$string['langunsupported'] = '<p>Your server does not seem to fully support the following languages:</p><ul>{$a->missinglocales}</ul><p>Instead, the global locale ({$a->globallocale}) will be used to format certain strings such as dates or numbers.</p>';
 $string['langupdatecomplete'] = 'Language pack update completed';
 $string['missingcfglangotherroot'] = 'Missing configuration value $CFG->langotherroot';
 $string['missinglangparent'] = 'Missing parent language <em>{$a->parent}</em> of <em>{$a->lang}</em>.';
diff --git a/admin/tool/langimport/tests/locale_test.php b/admin/tool/langimport/tests/locale_test.php
new file mode 100644 (file)
index 0000000..4d1ffef
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Tests for \tool_langimport\locale class.
+ *
+ * @package    tool_langimport
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests for \tool_langimport\locale class.
+ *
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class locale_testcase extends \advanced_testcase {
+    /**
+     * Test that \tool_langimport\locale::check_locale_availability() works as expected.
+     *
+     * @return void
+     */
+    public function test_check_locale_availability() {
+        // Create a mock of set_locale() method to simulate :
+        // - first setlocale() call which backup current locale
+        // - second setlocale() call which try to set new 'es' locale
+        // - third setlocale() call which restore locale.
+        $mock = $this->getMockBuilder(\tool_langimport\locale::class)
+            ->setMethods(['set_locale'])
+            ->getMock();
+        $mock->method('set_locale')->will($this->onConsecutiveCalls('en', 'es', 'en'));
+
+        // Test what happen when locale is available on system.
+        $result = $mock->check_locale_availability('en');
+        $this->assertTrue($result);
+
+        // Create a mock of set_locale() method to simulate :
+        // - first setlocale() call which backup current locale
+        // - second setlocale() call which fail to set new locale
+        // - third setlocale() call which restore locale.
+        $mock = $this->getMockBuilder(\tool_langimport\locale::class)
+            ->setMethods(['set_locale'])
+            ->getMock();
+        $mock->method('set_locale')->will($this->onConsecutiveCalls('en', false, 'en'));
+
+        // Test what happen when locale is not available on system.
+        $result = $mock->check_locale_availability('en');
+        $this->assertFalse($result);
+
+        // Test an invalid parameter.
+        $locale = new \tool_langimport\locale();
+        $this->expectException(coding_exception::class);
+        $locale->check_locale_availability('');
+    }
+}
index 77cf893..f798e7d 100644 (file)
@@ -420,6 +420,9 @@ class external extends external_api {
                     ))
                 ),
                 'comppath' => competency_path_exporter::get_read_structure(),
+                'plans' => new external_multiple_structure(
+                    plan_exporter::get_read_structure()
+                ),
             ))),
             'manageurl' => new external_value(PARAM_LOCALURL, 'Url to the manage competencies page.'),
         ));
index d1b7b7f..78ea24a 100644 (file)
@@ -26,11 +26,13 @@ defined('MOODLE_INTERNAL') || die();
 
 use core_competency\api;
 use core_competency\user_competency;
+use core_competency\external\plan_exporter;
 use core_course\external\course_module_summary_exporter;
 use core_course\external\course_summary_exporter;
 use context_course;
 use renderer_base;
 use stdClass;
+use moodle_url;
 
 /**
  * Class for exporting user competency data with additional related data in a plan.
@@ -62,7 +64,14 @@ class user_competency_summary_in_course_exporter extends \core\external\exporter
             'coursemodules' => array(
                 'type' => course_module_summary_exporter::read_properties_definition(),
                 'multiple' => true
-            )
+            ),
+            'plans' => array(
+                'type' => plan_exporter::read_properties_definition(),
+                'multiple' => true
+            ),
+            'pluginbaseurl' => [
+                'type' => PARAM_URL
+            ],
         );
     }
 
@@ -95,6 +104,16 @@ class user_competency_summary_in_course_exporter extends \core\external\exporter
         }
         $result->coursemodules = $exportedmodules;
 
+        // User learning plans.
+        $plans = api::list_plans_with_competency($this->related['user']->id, $this->related['competency']);
+        $exportedplans = array();
+        foreach ($plans as $plan) {
+            $planexporter = new plan_exporter($plan, array('template' => $plan->get_template()));
+            $exportedplans[] = $planexporter->export($output);
+        }
+        $result->plans = $exportedplans;
+        $result->pluginbaseurl = (new moodle_url('/admin/tool/lp'))->out(true);
+
         return (array) $result;
     }
 }
index eaf7d34..218d8a5 100644 (file)
@@ -41,6 +41,7 @@ use core_competency\external\course_competency_exporter;
 use core_competency\external\course_competency_settings_exporter;
 use core_competency\external\user_competency_course_exporter;
 use core_competency\external\user_competency_exporter;
+use core_competency\external\plan_exporter;
 use tool_lp\external\competency_path_exporter;
 use tool_lp\external\course_competency_statistics_exporter;
 use core_course\external\course_module_summary_exporter;
@@ -113,6 +114,7 @@ class course_competencies_page implements renderable, templatable {
         $data->courseid = $this->courseid;
         $data->pagecontextid = $this->context->id;
         $data->competencies = array();
+        $data->pluginbaseurl = (new moodle_url('/admin/tool/lp'))->out(true);
 
         $gradable = is_enrolled($this->context, $USER, 'moodle/competency:coursecompetencygradable');
         if ($gradable) {
@@ -154,12 +156,21 @@ class course_competencies_page implements renderable, templatable {
                 'context' => $context
             ]);
 
+            // User learning plans.
+            $plans = api::list_plans_with_competency($USER->id, $competency);
+            $exportedplans = array();
+            foreach ($plans as $plan) {
+                $planexporter = new plan_exporter($plan, array('template' => $plan->get_template()));
+                $exportedplans[] = $planexporter->export($output);
+            }
+
             $onerow = array(
                 'competency' => $compexporter->export($output),
                 'coursecompetency' => $ccexporter->export($output),
                 'ruleoutcomeoptions' => $ccoutcomeoptions,
                 'coursemodules' => $exportedmodules,
-                'comppath' => $pathexporter->export($output)
+                'comppath' => $pathexporter->export($output),
+                'plans' => $exportedplans
             );
             if ($gradable) {
                 $foundusercompetencycourse = false;
index eaa0f43..6d89b20 100644 (file)
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
+    @template tool_lp/course_competencies_page
+
     Course competencies template.
 
     For a full list of the context for this template see the course_competencies_page renderable.
+
+    This template includes ajax functionality, so it cannot be shown in the template library.
 }}
 <div data-region="coursecompetenciespage">
     <div data-region="actions" class="clearfix">
@@ -66,7 +70,7 @@
         <div class="clearfix"></div>
         {{/canmanagecoursecompetencies}}
         {{#competency}}
-            <a href="{{pluginbaseurl}}user_competency_in_course.php?courseid={{courseid}}&competencyid={{competency.id}}&userid={{gradableuserid}}"
+            <a href="{{pluginbaseurl}}/user_competency_in_course.php?courseid={{courseid}}&competencyid={{competency.id}}&userid={{gradableuserid}}"
                    id="competency-info-link-{{competency.id}}"
                    title="{{#str}}viewdetails, tool_lp{{/str}}">
                 <p><strong>{{{competency.shortname}}} <em>{{competency.idnumber}}</em></strong></p>
         {{/canmanagecoursecompetencies}}
         <div data-region="coursecompetencyactivities">
         <p>
-        <ul class="inline list-inline">
+        <strong>{{#str}}activities{{/str}}</strong>
+        <ul class="inline list-inline p-2">
         {{#coursemodules}}
             <li class="list-inline-item"><a href="{{url}}"><img src="{{iconurl}}"> {{name}} </a></li>
         {{/coursemodules}}
         {{^coursemodules}}
-            <li class="list-inline-item"><span class="alert">{{#str}}noactivities, tool_lp{{/str}}</span></li>
+            <li class="list-inline-item">{{#str}}noactivities, tool_lp{{/str}}</li>
         {{/coursemodules}}
         </ul>
         </p>
         </div>
+        <div data-region="learningplans">
+        <p>
+        <strong>{{#str}}userplans, core_competency{{/str}}</strong>
+        <ul class="inline list-inline p-2">
+        {{#plans}}
+            <li class="list-inline-item"><a href="{{pluginbaseurl}}/plan.php?id={{id}}">{{{name}}}</a></li>
+        {{/plans}}
+        {{^plans}}
+            <li class="list-inline-item">{{#str}}nouserplanswithcompetency, core_competency{{/str}}</li>
+        {{/plans}}
+        </ul>
+        </p>
+        </div>
     </td>
     </tr>
 {{/competencies}}
index 22cf96d..ce80bbb 100644 (file)
         </dd>
         {{/user}}
         {{/displayuser}}
+        <dt>{{#str}}userplans, competency{{/str}}</dt>
+        <dd>
+        <p>
+        <ul class="inline list-inline">
+        {{#plans}}
+            <li class="list-inline-item"><a href="{{pluginbaseurl}}/plan.php?id={{id}}">{{{name}}}</a></li>
+        {{/plans}}
+        {{^plans}}
+            <li>{{#str}}nouserplanswithcompetency, competency{{/str}}</li>
+        {{/plans}}
+        </ul>
+        </p>
+        </dd>
         {{#usercompetencycourse}}
         <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
         <dd>
index d2ca869..0fbbfd1 100644 (file)
@@ -130,3 +130,35 @@ Feature: Manage plearning plan
     When I click on "Delete" "button" in the "Confirm" "dialogue"
     And I wait until the page is ready
     Then I should not see "Science plan Year-4"
+
+  Scenario: See a learning plan from a course
+    Given the following lp "plans" exist:
+      | name | user | description |
+      | Science plan Year-manage | admin | science plan description |
+    And the following lp "frameworks" exist:
+      | shortname | idnumber |
+      | Framework 1 | sc-y-2 |
+    And the following lp "competencies" exist:
+      | shortname | framework |
+      | comp1 | sc-y-2 |
+      | comp2 | sc-y-2 |
+    And I follow "Learning plans"
+    And I should see "Science plan Year-manage"
+    And I follow "Science plan Year-manage"
+    And I should see "Add competency"
+    And I press "Add competency"
+    And "Competency picker" "dialogue" should be visible
+    And I select "comp1" of the competency tree
+    When I click on "Add" "button" in the "Competency picker" "dialogue"
+    Then "comp1" "table_row" should exist
+    And I create a course with:
+      | Course full name | New course fullname |
+      | Course short name | New course shortname |
+    And I follow "New course fullname"
+    And I follow "Competencies"
+    And I press "Add competencies to course"
+    And "Competency picker" "dialogue" should be visible
+    And I select "comp1" of the competency tree
+    And I click on "Add" "button" in the "Competency picker" "dialogue"
+    And I should see "Learning plans"
+    And I should see "Science plan Year-manage"
index 71cf2bb..bd49699 100644 (file)
@@ -356,7 +356,7 @@ class api {
         $mobileplugins = self::get_plugins_supporting_mobile();
         foreach ($mobileplugins as $plugin) {
             $displayname = core_plugin_manager::instance()->plugin_name($plugin['component']) . " - " . $plugin['addon'];
-            $remoteaddonslist['remoteAddOn_' . $plugin['component'] . '_' . $plugin['addon']] = $displayname;
+            $remoteaddonslist['sitePlugin_' . $plugin['component'] . '_' . $plugin['addon']] = $displayname;
 
         }
 
diff --git a/admin/tool/mobile/db/upgrade.php b/admin/tool/mobile/db/upgrade.php
new file mode 100644 (file)
index 0000000..73748aa
--- /dev/null
@@ -0,0 +1,46 @@
+<?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/>.
+
+/**
+ * Mobile app support.
+ *
+ * @package    tool_mobile
+ * @copyright  2019 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/lib/upgradelib.php');
+
+/**
+ * Upgrade the plugin.
+ *
+ * @param int $oldversion
+ * @return bool always true
+ */
+function xmldb_tool_mobile_upgrade($oldversion) {
+    global $CFG;
+
+    if ($oldversion < 2019021100) {
+        $disabledfeatures = get_config('tool_mobile', 'disabledfeatures');
+        $disabledfeatures = str_replace('remoteAddOn_', 'sitePlugin_', $disabledfeatures);
+        set_config('disabledfeatures', $disabledfeatures, 'tool_mobile');
+        upgrade_plugin_savepoint(true, 2019021100, 'tool', 'mobile');
+    }
+
+    return true;
+}
index 4584e2d..bc7dc20 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 defined('MOODLE_INTERNAL') || die();
-$plugin->version   = 2018120300; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019021100; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2018112800; // Requires this Moodle version.
 $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics).
 $plugin->dependencies = array(
diff --git a/admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php b/admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php
new file mode 100644 (file)
index 0000000..a323c1e
--- /dev/null
@@ -0,0 +1,154 @@
+<?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/>.
+
+/**
+ * @package    tool_xmldb
+ * @copyright  2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Add the mandatory fields for persistent to the table.
+ *
+ * @package    tool_xmldb
+ * @copyright  2019 Michael Aherne
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class add_persistent_mandatory extends XMLDBAction {
+
+    function init() {
+
+        parent::init();
+
+        // Get needed strings.
+        $this->loadStrings(array(
+            'addpersistent' => 'tool_xmldb',
+            'persistentfieldsconfirm' => 'tool_xmldb',
+            'persistentfieldscomplete' => 'tool_xmldb',
+            'persistentfieldsexist' => 'tool_xmldb',
+            'back' => 'core'
+        ));
+
+    }
+
+    function getTitle() {
+        return $this->str['addpersistent'];
+    }
+
+    function invoke() {
+
+        parent::invoke();
+
+        $this->does_generate = ACTION_GENERATE_HTML;
+
+        global $CFG, $XMLDB, $OUTPUT;
+
+        $dir = required_param('dir', PARAM_PATH);
+        $dirpath = $CFG->dirroot . $dir;
+
+        if (empty($XMLDB->dbdirs)) {
+            return false;
+        }
+
+        if (!empty($XMLDB->editeddirs)) {
+            $editeddir = $XMLDB->editeddirs[$dirpath];
+            $structure = $editeddir->xml_file->getStructure();
+        }
+
+        $tableparam = required_param('table', PARAM_ALPHANUMEXT);
+
+        /** @var xmldb_table $table */
+        $table = $structure->getTable($tableparam);
+
+        $result = true;
+        // Launch postaction if exists (leave this here!)
+        if ($this->getPostAction() && $result) {
+            return $this->launch($this->getPostAction());
+        }
+
+        $confirm = optional_param('confirm', false, PARAM_BOOL);
+
+        $fields = ['usermodified', 'timecreated', 'timemodified'];
+        $existing = [];
+        foreach ($fields as $field) {
+            if ($table->getField($field)) {
+                $existing[] = $field;
+            }
+        }
+
+        $returnurl = new \moodle_url('/admin/tool/xmldb/index.php', [
+            'table' => $tableparam,
+            'dir' => $dir,
+            'action' => 'edit_table'
+        ]);
+
+        $backbutton = html_writer::link($returnurl, '[' . $this->str['back'] . ']');
+        $actionbuttons = html_writer::tag('p', $backbutton, ['class' => 'centerpara buttons']);
+
+        if (!$confirm) {
+
+            if (!empty($existing)) {
+
+                $message = html_writer::span($this->str['persistentfieldsexist']);
+                $message .= html_writer::alist($existing);
+                $this->output .= $OUTPUT->notification($message);
+
+                if (count($existing) == count($fields)) {
+                    $this->output .= $actionbuttons;
+                    return true;
+                }
+            }
+
+            $confirmurl = new \moodle_url('/admin/tool/xmldb/index.php', [
+                'table' => $tableparam,
+                'dir' => $dir,
+                'action' => 'add_persistent_mandatory',
+                'sesskey' => sesskey(),
+                'confirm' => '1'
+            ]);
+
+            $message = html_writer::span($this->str['persistentfieldsconfirm']);
+            $message .= html_writer::alist(array_diff($fields, $existing));
+            $this->output .= $OUTPUT->confirm($message, $confirmurl, $returnurl);
+
+        } else {
+
+            $fieldsadded = [];
+            foreach ($fields as $field) {
+                if (!in_array($field, $existing)) {
+                    $fieldsadded[] = $field;
+                    $table->add_field($field, XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, null, 0);
+                }
+            }
+
+            if (!$table->getKey('usermodified')) {
+                $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']);
+            }
+
+            $structure->setVersion(userdate(time(), '%Y%m%d', 99, false));
+            $structure->setChanged(true);
+
+            $message = html_writer::span($this->str['persistentfieldscomplete']);
+            $message .= html_writer::alist(array_diff($fields, $existing));
+            $this->output .= $OUTPUT->notification($message, 'success');
+
+            $this->output .= $actionbuttons;
+        }
+
+        return $result;
+    }
+
+}
index 5cd4bcd..aa82f99 100644 (file)
@@ -44,6 +44,7 @@ class edit_table extends XMLDBAction {
 
         // Get needed strings
         $this->loadStrings(array(
+            'addpersistent' => 'tool_xmldb',
             'change' => 'tool_xmldb',
             'vieworiginal' => 'tool_xmldb',
             'viewedited' => 'tool_xmldb',
@@ -177,6 +178,15 @@ class edit_table extends XMLDBAction {
         $b .= '<a href="index.php?action=view_table_sql&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' .$this->str['viewsqlcode'] . ']</a>';
         // The view php code button
         $b .= '&nbsp;<a href="index.php?action=view_table_php&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['viewphpcode'] . ']</a>';
+        // The add persistent fields button.
+        $url = new \moodle_url('/admin/tool/xmldb/index.php', [
+            'action' => 'add_persistent_mandatory',
+            'sesskey' => sesskey(),
+            'table' => $tableparam,
+            'dir'=> str_replace($CFG->dirroot, '', $dirpath)
+        ]);
+        $b .= '&nbsp;' . \html_writer::link($url, '[' . $this->str['addpersistent'] . ']');
+
         // The save button (if possible)
         if ($cansavenow) {
             $b .= '&nbsp;<a href="index.php?action=save_xml_file&amp;sesskey=' . sesskey() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '&amp;time=' . time() . '&amp;unload=false&amp;postaction=edit_table&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['save'] . ']</a>';
index d531a86..d2efeab 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 $string['actual'] = 'Actual';
+$string['addpersistent'] = 'Add mandatory persistent fields';
 $string['aftertable'] = 'After table:';
 $string['back'] = 'Back';
 $string['backtomainview'] = 'Back to main';
@@ -169,6 +170,9 @@ $string['numberincorrectwholepart'] = 'Too big whole number part for number fiel
 $string['pendingchanges'] = 'Note: You have performed changes to this file. They can be saved at any moment.';
 $string['pendingchangescannotbesaved'] = 'There are changes in this file but they cannot be saved! Please verify that both the directory and the "install.xml" within it have write permissions for the web server.';
 $string['pendingchangescannotbesavedreload'] = 'There are changes in this file but they cannot be saved! Please verify that both the directory and the "install.xml" within it have write permissions for the web server. Then reload this page and you should be able to save those changes.';
+$string['persistentfieldsconfirm'] = 'Do you want to add the following fields: ';
+$string['persistentfieldscomplete'] = 'The following fields have been added: ';
+$string['persistentfieldsexist'] = 'The following fields already exist: ';
 $string['pluginname'] = 'XMLDB editor';
 $string['primarykeyonlyallownotnullfields'] = 'Primary keys cannot be null';
 $string['reserved'] = 'Reserved';
index 67d0d03..8247355 100644 (file)
@@ -66,12 +66,16 @@ class block_login extends block_base {
 
             $this->content->text .= "\n".'<form class="loginform" id="login" method="post" action="'.get_login_url().'">';
 
-            $this->content->text .= '<div class="form-group"><label for="login_username">'.$strusername.'</label>';
-            $this->content->text .= '<input type="text" name="username" id="login_username" class="form-control" value="'.s($username).'" /></div>';
+            $this->content->text .= '<div class="form-group">';
+            $this->content->text .= '<label for="login_username">'.$strusername.'</label>';
+            $this->content->text .= '<input type="text" name="username" id="login_username" ';
+            $this->content->text .= ' class="form-control" value="'.s($username).'" autocomplete="username"/></div>';
 
             $this->content->text .= '<div class="form-group"><label for="login_password">'.get_string('password').'</label>';
 
-            $this->content->text .= '<input type="password" name="password" id="login_password" class="form-control" value="" /></div>';
+            $this->content->text .= '<input type="password" name="password" id="login_password" ';
+            $this->content->text .= ' class="form-control" value="" autocomplete="current-password"/>';
+            $this->content->text .= '</div>';
 
             if (isset($CFG->rememberusername) and $CFG->rememberusername == 2) {
                 $checked = $username ? 'checked="checked"' : '';
index 691d2fb..6b1337f 100644 (file)
@@ -1712,6 +1712,7 @@ class cache_session extends cache {
     public function __construct(cache_definition $definition, cache_store $store, $loader = null) {
         // First up copy the loadeduserid to the current user id.
         $this->currentuserid = self::$loadeduserid;
+        $this->set_session_id();
         parent::__construct($definition, $store, $loader);
 
         // This will trigger check tracked user. If this gets removed a call to that will need to be added here in its place.
@@ -1771,8 +1772,6 @@ class cache_session extends cache {
                 // Purge the data we have for the old user.
                 // This way we don't bloat the session.
                 $this->purge();
-                // Update the session id just in case!
-                $this->set_session_id();
             }
             self::$loadeduserid = $new;
             $this->currentuserid = $new;
@@ -1780,8 +1779,6 @@ class cache_session extends cache {
             // The current user matches the loaded user but not the user last used by this cache.
             $this->purge_current_user();
             $this->currentuserid = $new;
-            // Update the session id just in case!
-            $this->set_session_id();
         }
     }
 
index 6aa268f..9c0f1d9 100644 (file)
@@ -2327,4 +2327,51 @@ class core_cache_testcase extends advanced_testcase {
         $this->assertEquals('test data 2', $cache->get('testkey1'));
     }
 
+    /**
+     * Test that values set in different sessions are stored with different key prefixes.
+     */
+    public function test_session_distinct_storage_key() {
+        $this->resetAfterTest();
+
+        // Prepare a dummy session cache configuration.
+        $config = cache_config_testing::instance();
+        $config->phpunit_add_definition('phpunit/test_session_distinct_storage_key', array(
+            'mode' => cache_store::MODE_SESSION,
+            'component' => 'phpunit',
+            'area' => 'test_session_distinct_storage_key'
+        ));
+
+        // First anonymous user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('foo');
+        $this->setUser(0);
+        $cache1 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        // Reset cache instances to emulate a new request.
+        cache_factory::instance()->reset_cache_instances();
+
+        // Another anonymous user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('bar');
+        $this->setUser(0);
+        $cache2 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        cache_factory::instance()->reset_cache_instances();
+
+        // Guest user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('baz');
+        $this->setGuestUser();
+        $cache3 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        cache_factory::instance()->reset_cache_instances();
+
+        // Same guest user's session cache but in another browser window.
+        cache_phpunit_session::phpunit_mockup_session_id('baz');
+        $this->setGuestUser();
+        $cache4 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        // Assert that different PHP session implies different key prefix for storing values.
+        $this->assertNotEquals($cache1->phpunit_get_key_prefix(), $cache2->phpunit_get_key_prefix());
+
+        // Assert that same PHP session implies same key prefix for storing values.
+        $this->assertEquals($cache3->phpunit_get_key_prefix(), $cache4->phpunit_get_key_prefix());
+    }
 }
index 6c42c39..bee6609 100644 (file)
@@ -465,6 +465,9 @@ class cache_phpunit_application extends cache_application {
  */
 class cache_phpunit_session extends cache_session {
 
+    /** @var Static member used for emulating the behaviour of session_id() during the tests. */
+    protected static $sessionidmockup = 'phpunitmockupsessionid';
+
     /**
      * Returns the class of the store immediately associated with this cache.
      * @return string
@@ -480,6 +483,31 @@ class cache_phpunit_session extends cache_session {
     public function phpunit_get_store_implements() {
         return class_implements($this->get_store());
     }
+
+    /**
+     * Provide access to the {@link cache_session::get_key_prefix()} method.
+     *
+     * @return string
+     */
+    public function phpunit_get_key_prefix() {
+        return $this->get_key_prefix();
+    }
+
+    /**
+     * Allows to inject the session identifier.
+     *
+     * @param string $sessionid
+     */
+    public static function phpunit_mockup_session_id($sessionid) {
+        static::$sessionidmockup = $sessionid;
+    }
+
+    /**
+     * Override the parent behaviour so that it does not need the actual session_id() call.
+     */
+    protected function set_session_id() {
+        $this->sessionid = static::$sessionidmockup;
+    }
 }
 
 /**
index 9d04083..97edf63 100644 (file)
@@ -3191,6 +3191,34 @@ class api {
         return $plancompetency;
     }
 
+    /**
+     * List the plans with a competency.
+     *
+     * @param  int $userid The user id we want the plans for.
+     * @param  int $competencyorid The competency, or its ID.
+     * @return array[plan] Array of learning plans.
+     */
+    public static function list_plans_with_competency($userid, $competencyorid) {
+        global $USER;
+
+        static::require_enabled();
+        $competencyid = $competencyorid;
+        $competency = null;
+        if (is_object($competencyid)) {
+            $competency = $competencyid;
+            $competencyid = $competency->get('id');
+        }
+
+        $plans = plan::get_by_user_and_competency($userid, $competencyid);
+        foreach ($plans as $index => $plan) {
+            // Filter plans we cannot read.
+            if (!$plan->can_read()) {
+                unset($plans[$index]);
+            }
+        }
+        return $plans;
+    }
+
     /**
      * List the competencies in a user plan.
      *
index 705a32a..3ecf3e9 100644 (file)
@@ -4631,4 +4631,38 @@ class core_competency_api_testcase extends advanced_testcase {
         $this->assertEquals($uc1b->get('id'), $result['competencies'][0]->usercompetency->get('id'));
         $this->assertEquals($uc1c->get('id'), $result['competencies'][1]->usercompetency->get('id'));
     }
+
+    /**
+     * Test we can get all of a users plans with a competency.
+     */
+    public function test_list_plans_with_competency() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $lpg = $this->getDataGenerator()->get_plugin_generator('core_competency');
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $tpl = $this->getDataGenerator()->get_plugin_generator('core_competency')->create_template();
+
+        // Create a framework and assign competencies.
+        $framework = $lpg->create_framework();
+        $c1 = $lpg->create_competency(array('competencyframeworkid' => $framework->get('id')));
+
+        // Create two plans and assign the competency to each.
+        $plan1 = $lpg->create_plan(array('userid' => $u1->id));
+        $plan2 = $lpg->create_plan(array('userid' => $u1->id));
+
+        $lpg->create_plan_competency(array('planid' => $plan1->get('id'), 'competencyid' => $c1->get('id')));
+        $lpg->create_plan_competency(array('planid' => $plan2->get('id'), 'competencyid' => $c1->get('id')));
+
+        // Create one more plan without the competency.
+        $plan3 = $lpg->create_plan(array('userid' => $u1->id));
+
+        $plans = api::list_plans_with_competency($u1->id, $c1);
+
+        $this->assertEquals(2, count($plans));
+
+        $this->assertEquals(reset($plans)->get('id'), $plan1->get('id'));
+        $this->assertEquals(end($plans)->get('id'), $plan2->get('id'));
+    }
+
 }
index 4d2348e..caf3868 100644 (file)
@@ -61,9 +61,7 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     $newcm->instance         = 0; // Not known yet, will be updated later (this is similar to restore code).
     $newcm->visible          = $moduleinfo->visible;
     $newcm->visibleold       = $moduleinfo->visible;
-    if (isset($moduleinfo->visibleoncoursepage)) {
-        $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
-    }
+    $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
     if (isset($moduleinfo->cmidnumber)) {
         $newcm->idnumber         = $moduleinfo->cmidnumber;
     }
@@ -410,6 +408,9 @@ function set_moduleinfo_defaults($moduleinfo) {
     if (!isset($moduleinfo->conditionfieldgroup)) {
         $moduleinfo->conditionfieldgroup = array();
     }
+    if (!isset($moduleinfo->visibleoncoursepage)) {
+        $moduleinfo->visibleoncoursepage = 1;
+    }
 
     return $moduleinfo;
 }
index 35e61ea..c77e151 100644 (file)
@@ -419,21 +419,50 @@ class course_enrolment_manager {
      * @param int $page which page number of the results to show.
      * @param int $perpage number of users per page.
      * @param int $addedenrollment number of users added to enrollment.
-     * @return array with two elememts:
-     *      int total number of users matching the search.
-     *      array of user objects returned by the query.
-     */
-    protected function execute_search_queries($search, $fields, $countfields, $sql, array $params, $page, $perpage, $addedenrollment=0) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    protected function execute_search_queries($search, $fields, $countfields, $sql, array $params, $page, $perpage,
+            $addedenrollment = 0, $returnexactcount = false) {
         global $DB, $CFG;
 
         list($sort, $sortparams) = users_order_by_sql('u', $search, $this->get_context());
         $order = ' ORDER BY ' . $sort;
 
-        $totalusers = $DB->count_records_sql($countfields . $sql, $params);
+        $totalusers = 0;
+        $moreusers = false;
+        $results = [];
+
         $availableusers = $DB->get_records_sql($fields . $sql . $order,
-                array_merge($params, $sortparams), ($page*$perpage) - $addedenrollment, $perpage);
+                array_merge($params, $sortparams), ($page * $perpage) - $addedenrollment, $perpage + 1);
+        if ($availableusers) {
+            $totalusers = count($availableusers);
+            $moreusers = $totalusers > $perpage;
+
+            if ($moreusers) {
+                // We need to discard the last record.
+                array_pop($availableusers);
+            }
+
+            if ($returnexactcount && $moreusers) {
+                // There is more data. We need to do the exact count.
+                $totalusers = $DB->count_records_sql($countfields . $sql, $params);
+            }
+        }
 
-        return array('totalusers' => $totalusers, 'users' => $availableusers);
+        $results['users'] = $availableusers;
+        $results['moreusers'] = $moreusers;
+
+        if ($returnexactcount) {
+            // Include totalusers in result if $returnexactcount flag is true.
+            $results['totalusers'] = $totalusers;
+        }
+
+        return $results;
     }
 
     /**
@@ -446,9 +475,15 @@ class course_enrolment_manager {
      * @param int $page Defaults to 0
      * @param int $perpage Defaults to 25
      * @param int $addedenrollment Defaults to 0
-     * @return array Array(totalusers => int, users => array)
-     */
-    public function get_potential_users($enrolid, $search='', $searchanywhere=false, $page=0, $perpage=25, $addedenrollment=0) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    public function get_potential_users($enrolid, $search = '', $searchanywhere = false, $page = 0, $perpage = 25,
+            $addedenrollment = 0, $returnexactcount = false) {
         global $DB;
 
         list($ufields, $params, $wherecondition) = $this->get_basic_search_conditions($search, $searchanywhere);
@@ -461,7 +496,8 @@ class course_enrolment_manager {
                       AND ue.id IS NULL";
         $params['enrolid'] = $enrolid;
 
-        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, $addedenrollment);
+        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, $addedenrollment,
+                $returnexactcount);
     }
 
     /**
@@ -472,9 +508,14 @@ class course_enrolment_manager {
      * @param bool $searchanywhere
      * @param int $page Starting at 0
      * @param int $perpage
-     * @return array
-     */
-    public function search_other_users($search='', $searchanywhere=false, $page=0, $perpage=25) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    public function search_other_users($search = '', $searchanywhere = false, $page = 0, $perpage = 25, $returnexactcount = false) {
         global $DB, $CFG;
 
         list($ufields, $params, $wherecondition) = $this->get_basic_search_conditions($search, $searchanywhere);
@@ -487,7 +528,7 @@ class course_enrolment_manager {
                     AND ra.id IS NULL";
         $params['contextid'] = $this->context->id;
 
-        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage);
+        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, 0, $returnexactcount);
     }
 
     /**
index a1bc241..32c5c10 100644 (file)
@@ -680,6 +680,7 @@ class course_enrolment_other_users_table extends course_enrolment_table {
             $this->manager->get_moodlepage()->requires->strings_for_js(array(
                     'ajaxoneuserfound',
                     'ajaxxusersfound',
+                    'ajaxxmoreusersfound',
                     'ajaxnext25',
                     'enrol',
                     'enrolmentoptions',
index 8f56a44..9e9f0ed 100644 (file)
@@ -104,6 +104,20 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
         $this->course = $course;
         $this->users = $users;
         $this->groups = $groups;
+
+        // Make sample users and not enroll to any course.
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser1',
+                'firstname' => 'testapiuser 1'
+        ]);
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser2',
+                'firstname' => 'testapiuser 2'
+        ]);
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser3',
+                'firstname' => 'testapiuser 3'
+        ]);
     }
 
     /**
@@ -239,4 +253,88 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
         $this->assertCount(1, $users, 'Only suspended users must be returned when suspended users filtering is applied.');
         $this->assertArrayHasKey($this->users['user22']->id, $users);
     }
+
+    /**
+     * Test get_potential_users without returnexactcount param.
+     *
+     * @dataProvider search_users_provider
+     *
+     * @param int $perpage Number of users per page.
+     * @param bool $returnexactcount Return the exact count or not.
+     * @param int $expectedusers Expected number of users return.
+     * @param int $expectedtotalusers Expected total of users in database.
+     * @param bool $expectedmoreusers Expected for more users return or not.
+     */
+    public function test_get_potential_users($perpage, $returnexactcount, $expectedusers, $expectedtotalusers, $expectedmoreusers) {
+        global $DB, $PAGE;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $enrol = $DB->get_record('enrol', array('courseid' => $this->course->id, 'enrol' => 'manual'));
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->get_potential_users($enrol->id,
+                'testapiuser',
+                true,
+                0,
+                $perpage,
+                0,
+                $returnexactcount);
+
+        $this->assertCount($expectedusers, $users['users']);
+        $this->assertEquals($expectedmoreusers, $users['moreusers']);
+        if ($returnexactcount) {
+            $this->assertArrayHasKey('totalusers', $users);
+            $this->assertEquals($expectedtotalusers, $users['totalusers']);
+        } else {
+            $this->assertArrayNotHasKey('totalusers', $users);
+        }
+    }
+
+    /**
+     * Test search_other_users with returnexactcount param.
+     *
+     * @dataProvider search_users_provider
+     *
+     * @param int $perpage Number of users per page.
+     * @param bool $returnexactcount Return the exact count or not.
+     * @param int $expectedusers Expected number of users return.
+     * @param int $expectedtotalusers Expected total of users in database.
+     * @param bool $expectedmoreusers Expected for more users return or not.
+     */
+    public function test_search_other_users($perpage, $returnexactcount, $expectedusers, $expectedtotalusers, $expectedmoreusers) {
+        global $PAGE;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->search_other_users(
+                'testapiuser',
+                true,
+                0,
+                $perpage,
+                $returnexactcount);
+
+        $this->assertCount($expectedusers, $users['users']);
+        $this->assertEquals($expectedmoreusers, $users['moreusers']);
+        if ($returnexactcount) {
+            $this->assertArrayHasKey('totalusers', $users);
+            $this->assertEquals($expectedtotalusers, $users['totalusers']);
+        } else {
+            $this->assertArrayNotHasKey('totalusers', $users);
+        }
+    }
+
+    /**
+     * Test case for test_get_potential_users and test_search_other_users tests.
+     *
+     * @return array Dataset
+     */
+    public function search_users_provider() {
+        return [
+                [2, false, 2, 3, true],
+                [5, false, 3, 3, false],
+                [2, true, 2, 3, true],
+                [5, true, 3, 3, false]
+        ];
+    }
 }
index 3202160..df24f1f 100644 (file)
@@ -1,6 +1,13 @@
 This files describes API changes in /enrol/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+
+* Functions get_potential_users() and search_other_users() now return more information to avoid extra count query:
+  - users: List of user objects returned by the query.
+  - moreusers: True if there are still more users, otherwise is False.
+  - totalusers: Number users matching the search. (This element only exists if the function is called with $returnexactcount param set to true).
+
 === 3.6 ===
 
 * External function core_enrol_external::get_users_courses now return more information to avoid multiple queries to build the
index 23d8ba9..77daf94 100644 (file)
@@ -160,8 +160,9 @@ YUI.add('moodle-enrol-otherusersmanager', function(Y) {
             this._loadingNode.addClass(CSS.HIDDEN);
         },
         processSearchResults : function(tid, outcome, args) {
+            var result;
             try {
-                var result = Y.JSON.parse(outcome.responseText);
+                result = Y.JSON.parse(outcome.responseText);
                 if (result.error) {
                     return new M.core.ajaxException(result);
                 }
@@ -186,18 +187,26 @@ YUI.add('moodle-enrol-otherusersmanager', function(Y) {
             }
             this.set(USERCOUNT, count);
             if (!args.append) {
-                var usersstr = (result.response.totalusers == '1')?M.util.get_string('ajaxoneuserfound', 'enrol'):M.util.get_string('ajaxxusersfound','enrol', result.response.totalusers);
+                var usersstr = '';
+                if (this.get(USERCOUNT) === 1) {
+                    usersstr = M.util.get_string('ajaxoneuserfound', 'enrol');
+                } else if (result.response.moreusers) {
+                    usersstr = M.util.get_string('ajaxxmoreusersfound', 'enrol', this.get(USERCOUNT));
+                } else {
+                    usersstr = M.util.get_string('ajaxxusersfound', 'enrol', this.get(USERCOUNT));
+                }
+
                 var content = Y.Node.create('<div class="'+CSS.SEARCHRESULTS+'"></div>')
                     .append(Y.Node.create('<div class="'+CSS.TOTALUSERS+'">'+usersstr+'</div>'))
                     .append(usersnode);
-                if (result.response.totalusers > (this.get(PAGE)+1)*25) {
+                if (result.response.moreusers) {
                     var fetchmore = Y.Node.create('<div class="'+CSS.MORERESULTS+'"><a href="#">'+M.util.get_string('ajaxnext25', 'enrol')+'</a></div>');
                     fetchmore.on('click', this.getUsers, this, true);
                     content.append(fetchmore)
                 }
                 this.setContent(content);
             } else {
-                if (result.response.totalusers <= (this.get(PAGE)+1)*25) {
+                if (!result.response.moreusers) {
                     this.get(BASE).one('.'+CSS.MORERESULTS).remove();
                 }
             }
index 707818c..0cedfb0 100644 (file)
@@ -279,6 +279,7 @@ $string['configmaxeditingtime'] = 'This specifies the amount of time people have
 $string['configmaxevents'] = 'Events to Lookahead';
 $string['configmessaging'] = 'If enabled, users can send messages to other users on the site.';
 $string['configmessagingallowemailoverride'] = 'Allow users to have email message notifications sent to an email address other than the email address in their profile';
+$string['configmessagingdefaultpressenter'] = 'Whether \'Use enter to send\' is enabled by default in users\' messaging settings.';
 $string['configmessagingdeletereadnotificationsdelay'] = 'Read notifications can be deleted to save space. How long after a notification is read can it be deleted?';
 $string['configmessagingdeleteallnotificationsdelay'] = 'Read and unread notifications can be deleted to save space. How long after a notification is created can it be deleted?';
 $string['configmessagingallusers'] = 'If enabled, users can view the list of all users on the site when selecting someone to message, and their message preferences include the option to accept messages from anyone on the site. If disabled, users can only view the list of users in their courses, and they have just two options in message preferences - to accept messages from their contacts only, or their contacts and anyone in their courses.';
@@ -772,6 +773,7 @@ $string['mediapluginyoutube'] = 'Enable YouTube links filter';
 $string['messaging'] = 'Enable messaging system';
 $string['messagingallowemailoverride'] = 'Notification email override';
 $string['messagingallusers'] = 'Allow site-wide messaging';
+$string['messagingdefaultpressenter'] = 'Use enter to send enabled by default';
 $string['messagingdeletereadnotificationsdelay'] = 'Delete read notifications';
 $string['messagingdeleteallnotificationsdelay'] = 'Delete all notifications';
 $string['minpassworddigits'] = 'Digits';
index 42377c7..4047987 100644 (file)
@@ -107,6 +107,7 @@ $string['invalidpersistenterror'] = 'Error: {$a}';
 $string['invalidplan'] = 'Invalid learning plan';
 $string['invalidtaxonomy'] = 'Invalid taxonomy: {$a}';
 $string['invalidurl'] = 'The URL is not valid. Make sure it starts with \'http://\' or \'https://\'.';
+$string['nouserplanswithcompetency'] = 'No learning plans contain this competency.';
 $string['planstatusactive'] = 'Active';
 $string['planstatuscomplete'] = 'Complete';
 $string['planstatusdraft'] = 'Draft';
index 8f452db..ea1bb8e 100644 (file)
@@ -28,6 +28,7 @@ $string['addinstance'] = 'Add method';
 $string['addinstanceanother'] = 'Add method and create another';
 $string['ajaxoneuserfound'] = '1 user found';
 $string['ajaxxusersfound'] = '{$a} users found';
+$string['ajaxxmoreusersfound'] = 'More than {$a} users found';
 $string['ajaxnext25'] = 'Next 25...';
 $string['assignnotpermitted'] = 'You do not have permission or can not assign roles in this course.';
 $string['bulkuseroperation'] = 'Bulk user operation';
diff --git a/lib/amd/build/checkbox-toggleall.min.js b/lib/amd/build/checkbox-toggleall.min.js
new file mode 100644 (file)
index 0000000..90e5ebc
Binary files /dev/null and b/lib/amd/build/checkbox-toggleall.min.js differ
diff --git a/lib/amd/src/checkbox-toggleall.js b/lib/amd/src/checkbox-toggleall.js
new file mode 100644 (file)
index 0000000..c6d4103
--- /dev/null
@@ -0,0 +1,126 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A module to help with toggle select/deselect all.
+ *
+ * @module     core/checkbox-toggleall
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/pubsub'], function($, PubSub) {
+
+    var registered = false;
+
+    var events = {
+        checkboxToggled: 'core/checkbox-toggleall:checkboxToggled',
+    };
+
+    var getAllCheckboxes = function(root, toggleGroup) {
+        return root.find('[data-action="toggle"][data-togglegroup="' + toggleGroup + '"]');
+    };
+
+    var getAllSlaveCheckboxes = function(root, toggleGroup) {
+        return getAllCheckboxes(root, toggleGroup).filter('[data-toggle="slave"]');
+    };
+
+    var getControlCheckboxes = function(root, toggleGroup) {
+        return getAllCheckboxes(root, toggleGroup).filter('[data-toggle="master"]');
+    };
+
+    var toggleSlavesFromMasters = function(e) {
+        var root = e.data.root;
+        var target = $(e.target);
+
+        var toggleGroupName = target.data('togglegroup');
+        var targetState = target.is(':checked');
+
+        var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
+        var checkedSlaves = slaves.filter(':checked');
+
+        setMasterStates(root, toggleGroupName, targetState);
+
+        // Set the slave checkboxes from the masters.
+        slaves.prop('checked', targetState);
+
+        PubSub.publish(events.checkboxToggled, {
+            root: root,
+            toggleGroupName: toggleGroupName,
+            slaves: slaves,
+            checkedSlaves: checkedSlaves,
+            anyChecked: targetState,
+        });
+    };
+
+    var toggleMastersFromSlaves = function(e) {
+        var root = e.data.root;
+        var target = $(e.target);
+
+        var toggleGroupName = target.data('togglegroup');
+
+        var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
+        var checkedSlaves = slaves.filter(':checked');
+        var targetState = (slaves.length === checkedSlaves.length);
+
+        setMasterStates(root, toggleGroupName, targetState);
+
+        PubSub.publish(events.checkboxToggled, {
+            root: root,
+            toggleGroupName: toggleGroupName,
+            slaves: slaves,
+            checkedSlaves: checkedSlaves,
+            anyChecked: !!checkedSlaves.length,
+        });
+    };
+
+    var setMasterStates = function(root, toggleGroupName, targetState) {
+        // Set the master checkboxes value and ARIA labels..
+        var masters = getControlCheckboxes(root, toggleGroupName);
+        masters.prop('checked', targetState);
+        masters.each(function(i, masterCheckbox) {
+            masterCheckbox = $(masterCheckbox);
+            var masterLabel = root.find('[for="' + masterCheckbox.attr('id') + '"]');
+            var targetString;
+            if (masterLabel.length) {
+                if (targetState) {
+                    targetString = masterCheckbox.data('toggle-deselectall');
+                } else {
+                    targetString = masterCheckbox.data('toggle-selectall');
+                }
+
+                if (masterLabel.html() !== targetString) {
+                    masterLabel.html(targetString);
+                }
+            }
+        });
+    };
+
+    var registerListeners = function() {
+        if (!registered) {
+            registered = true;
+
+            var root = $(document.body);
+            root.on('change', '[data-action="toggle"][data-toggle="master"]', {root: root}, toggleSlavesFromMasters);
+            root.on('change', '[data-action="toggle"][data-toggle="slave"]', {root: root}, toggleMastersFromSlaves);
+        }
+    };
+
+    return {
+        init: function() {
+            registerListeners();
+        },
+        events: events,
+    };
+});
index d6da75c..bb011e0 100644 (file)
@@ -136,6 +136,16 @@ class behat_form_field {
         return $instance->matches($expectedvalue);
     }
 
+    /**
+     * Get the value of an attribute set on this field.
+     *
+     * @param string $name The attribute name
+     * @return string The attribute value
+     */
+    public function get_attribute($name) {
+        return $this->field->getAttribute($name);
+    }
+
     /**
      * Guesses the element type we are dealing with in case is not a text-based element.
      *
index 774a590..5440a58 100644 (file)
@@ -159,8 +159,7 @@ class manager {
 
             $s = new \stdClass();
             $s->sitename = format_string($SITE->shortname, true, array('context' => \context_course::instance(SITEID)));
-            // When the new interface lands, the URL may be reintroduced, but for now it isn't supported, so just hit the index.
-            $s->url = $CFG->wwwroot.'/message/index.php';
+            $s->url = $CFG->wwwroot.'/message/index.php?id='.$eventdata->userfrom->id;
             $emailtagline = get_string_manager()->get_string('emailtagline', 'message', $s, $recipient->lang);
 
             $localisedeventdata->fullmessage = $eventdata->fullmessage;
index 7a08841..9c0593c 100644 (file)
@@ -104,7 +104,10 @@ $messageproviders = array (
 
     // User insights.
     'insights' => array (
-         'capability'  => 'moodle/analytics:listinsights'
+        'defaults' => [
+            'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
+            'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
+        ]
     ),
 
     // Message contact requests.
index cf8ad44..3aa8faa 100644 (file)
@@ -2719,5 +2719,22 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019011801.03);
     }
 
+    if ($oldversion < 2019021500.01) {
+        $insights = $DB->get_record('message_providers', ['component' => 'moodle', 'name' => 'insights']);
+        if (!empty($insights)) {
+            $insights->capability = null;
+            $DB->update_record('message_providers', $insights);
+        }
+        upgrade_main_savepoint(true, 2019021500.01);
+    }
+
+    if ($oldversion < 2019021500.02) {
+        // Default 'off' for existing sites as this is the behaviour they had earlier.
+        set_config('messagingdefaultpressenter', false);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019021500.02);
+    }
+
     return true;
 }
index b600418..2823529 100644 (file)
@@ -4,6 +4,7 @@ Feature: Add media to Atto
 
   Background:
     Given I log in as "admin"
+    And I change window size to "large"
     And I follow "Manage private files..."
     And I upload "lib/editor/atto/tests/fixtures/moodle-logo.webm" file to "Files" filemanager
     And I upload "lib/editor/atto/tests/fixtures/moodle-logo.mp4" file to "Files" filemanager
@@ -58,7 +59,6 @@ Feature: Add media to Atto
     And I click on "Private files" "link" in the ".moodle-dialogue-base[aria-hidden='false'] .fp-repo-area" "css_element"
     And I click on "moodle-logo.png" "link"
     And I click on "Select this file" "button" in the ".moodle-dialogue-base[aria-hidden='false']" "css_element"
-    And I change window size to "large"
     And I set the field with xpath "//*[contains(concat(' ', normalize-space(@class), ' '), ' atto_media_width_entry ')]" to "420"
     And I set the field with xpath "//*[contains(concat(' ', normalize-space(@class), ' '), ' atto_media_height_entry ')]" to "69"
     And I set the field "Enter title" to "VideoTitle"
@@ -84,7 +84,6 @@ Feature: Add media to Atto
   @javascript @atto_media_video
   Scenario: Insert some media as a video with tracks
     Given I click on "Video" "link"
-    And I change window size to "large"
     And I click on "Browse repositories..." "button" in the "#id_summary_editor_video .atto_media_source.atto_media_media_source" "css_element"
     And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
     And I click on "moodle-logo.webm" "link"
@@ -201,4 +200,4 @@ Feature: Add media to Atto
     And I set the field "audio_media-mute-toggle" to "1"
     And I set the field "audio_media-loop-toggle" to "1"
     When I click on "Insert media" "button"
-    Then "//audio[descendant::source[contains(@src, 'moodle-logo.mp4')]][@controls='true'][@loop='true'][@autoplay='true'][@autoplay='true']" "xpath_element" should exist
\ No newline at end of file
+    Then "//audio[descendant::source[contains(@src, 'moodle-logo.mp4')]][@controls='true'][@loop='true'][@autoplay='true'][@autoplay='true']" "xpath_element" should exist
diff --git a/lib/form/amd/build/showadvanced.min.js b/lib/form/amd/build/showadvanced.min.js
new file mode 100644 (file)
index 0000000..fb4ccde
Binary files /dev/null and b/lib/form/amd/build/showadvanced.min.js differ
diff --git a/lib/form/amd/src/showadvanced.js b/lib/form/amd/src/showadvanced.js
new file mode 100644 (file)
index 0000000..a9f9645
--- /dev/null
@@ -0,0 +1,219 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A class to help show and hide advanced form content.
+ *
+ * @module     core_form/showadvanced
+ * @class      showadvanced
+ * @package    core_form
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/log', 'core/str', 'core/notification'], function($, Log, Strings, Notification) {
+
+    var SELECTORS = {
+            FIELDSETCONTAINSADVANCED: 'fieldset.containsadvancedelements',
+            DIVFITEMADVANCED: 'div.fitem.advanced',
+            DIVFCONTAINER: 'div.fcontainer',
+            MORELESSLINK: 'fieldset.containsadvancedelements .moreless-toggler'
+        },
+        CSS = {
+            SHOW: 'show',
+            MORELESSACTIONS: 'moreless-actions',
+            MORELESSTOGGLER: 'moreless-toggler',
+            SHOWLESS: 'moreless-less'
+        },
+        WRAPPERS = {
+            FITEM: '<div class="fitem"></div>',
+            FELEMENT: '<div class="felement"></div>'
+        },
+        IDPREFIX = 'showadvancedid-';
+
+    /** @type {Integer} uniqIdSeed Auto incrementing number used to generate ids. */
+    var uniqIdSeed = 0;
+
+    /**
+     * ShowAdvanced behaviour class.
+     * @param {String} id The id of the form.
+     */
+    var ShowAdvanced = function(id) {
+        this.id = id;
+
+        var form = $(document.getElementById(id));
+        this.enhanceForm(form);
+    };
+
+    /** @type {String} id The form id to enhance. */
+    ShowAdvanced.prototype.id = '';
+
+    /**
+     * @method enhanceForm
+     * @param {JQuery} form JQuery selector representing the form
+     * @return {ShowAdvanced}
+     */
+    ShowAdvanced.prototype.enhanceForm = function(form) {
+        var fieldsets = form.find(SELECTORS.FIELDSETCONTAINSADVANCED);
+
+        // Enhance each fieldset in the form matching the selector.
+        fieldsets.each(function(index, item) {
+            this.enhanceFieldset($(item));
+        }.bind(this));
+
+        // Attach some event listeners.
+        // Subscribe more/less links to click event.
+        form.on('click', SELECTORS.MORELESSLINK, this.switchState);
+
+        // Subscribe to key events but filter for space or enter.
+        form.on('keydown', SELECTORS.MORELESSLINK, function(e) {
+            // Enter or space.
+            if (e.which == 13 || e.which == 32) {
+                return this.switchState(e);
+            }
+            return true;
+        }.bind(this));
+        return this;
+    };
+
+
+    /**
+     * Generates a uniq id for the dom element it's called on unless the element already has an id.
+     * The id is set on the dom node before being returned.
+     *
+     * @method generateId
+     * @param {JQuery} node JQuery selector representing a single DOM Node.
+     * @return {String}
+     */
+    ShowAdvanced.prototype.generateId = function(node) {
+        var id = node.prop('id');
+        if (typeof id === 'undefined') {
+            id = IDPREFIX + (uniqIdSeed++);
+            node.prop('id', id);
+        }
+        return id;
+    };
+
+    /**
+     * @method enhanceFieldset
+     * @param {JQuery} fieldset JQuery selector representing a fieldset
+     * @return {ShowAdvanced}
+     */
+    ShowAdvanced.prototype.enhanceFieldset = function(fieldset) {
+        var statuselement = $('input[name=mform_showmore_' + fieldset.prop('id') + ']');
+        if (!statuselement.length) {
+            Log.debug("M.form.showadvanced::processFieldset was called on an fieldset without a status field: '" +
+                fieldset.prop('id') + "'");
+            return this;
+        }
+
+        // Fetch some strings.
+        Strings.get_strings([{
+            key: 'showmore',
+            component: 'core_form'
+        }, {
+            key: 'showless',
+            component: 'core_form'
+        }]).then(function(results) {
+            var showmore = results[0],
+                showless = results[1];
+
+            // Generate more/less links.
+            var morelesslink = $('<a href="#"></a>');
+            morelesslink.addClass(CSS.MORELESSTOGGLER);
+            if (statuselement.val() === '0') {
+                morelesslink.html(showmore);
+            } else {
+                morelesslink.html(showless);
+                morelesslink.addClass(CSS.SHOWLESS);
+                fieldset.find(SELECTORS.DIVFITEMADVANCED).addClass(CSS.SHOW);
+            }
+            // Build a list of advanced fieldsets.
+            var idlist = [];
+            fieldset.find(SELECTORS.DIVFITEMADVANCED).each(function(index, node) {
+                idlist[idlist.length] = this.generateId($(node));
+            }.bind(this));
+
+            // Set aria attributes.
+            morelesslink.attr('role', 'button');
+            morelesslink.attr('aria-controls', idlist.join(' '));
+
+            // Add elements to the DOM.
+            var fitem = $(WRAPPERS.FITEM);
+            fitem.addClass(CSS.MORELESSACTIONS);
+            var felement = $(WRAPPERS.FELEMENT);
+            felement.append(morelesslink);
+            fitem.append(felement);
+
+            fieldset.find(SELECTORS.DIVFCONTAINER).append(fitem);
+            return true;
+        }.bind(this)).fail(Notification.exception);
+
+        return this;
+    };
+
+    /**
+     * @method switchState
+     * @param {Event} e Event that triggered this action.
+     * @return {Boolean}
+     */
+    ShowAdvanced.prototype.switchState = function(e) {
+        e.preventDefault();
+
+        // Fetch some strings.
+        Strings.get_strings([{
+            key: 'showmore',
+            component: 'core_form'
+        }, {
+            key: 'showless',
+            component: 'core_form'
+        }]).then(function(results) {
+            var showmore = results[0],
+                showless = results[1],
+                fieldset = $(e.target).closest(SELECTORS.FIELDSETCONTAINSADVANCED);
+
+            // Toggle collapsed class.
+            fieldset.find(SELECTORS.DIVFITEMADVANCED).toggleClass(CSS.SHOW);
+
+            // Get corresponding hidden variable.
+            var statuselement = $('input[name=mform_showmore_' + fieldset.prop('id') + ']');
+
+            // Invert it and change the link text.
+            if (statuselement.val() === '0') {
+                statuselement.val(1);
+                $(e.target).addClass(CSS.SHOWLESS);
+                $(e.target).html(showless);
+            } else {
+                statuselement.val(0);
+                $(e.target).removeClass(CSS.SHOWLESS);
+                $(e.target).html(showmore);
+            }
+            return true;
+        }).fail(Notification.exception);
+
+        return this;
+    };
+
+    return {
+        /**
+         * Initialise this module.
+         * @method init
+         * @param {String} formid
+         * @return {ShowAdvanced}
+         */
+        init: function(formid) {
+            return new ShowAdvanced(formid);
+        }
+    };
+});
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js
deleted file mode 100644 (file)
index 4a032e0..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js and /dev/null differ
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js
deleted file mode 100644 (file)
index 8cfbaa1..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js and /dev/null differ
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js
deleted file mode 100644 (file)
index 6c2ee05..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js and /dev/null differ
diff --git a/lib/form/yui/src/showadvanced/build.json b/lib/form/yui/src/showadvanced/build.json
deleted file mode 100644 (file)
index 2f81737..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-    "name": "moodle-form-showadvanced",
-    "builds": {
-        "moodle-form-showadvanced": {
-            "jsfiles": [
-                "showadvanced.js"
-            ]
-        }
-    }
-}
diff --git a/lib/form/yui/src/showadvanced/js/showadvanced.js b/lib/form/yui/src/showadvanced/js/showadvanced.js
deleted file mode 100644 (file)
index 564f86f..0000000
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * Provides the form showadvanced class.
- *
- * @module moodle-form-showadvanced
- */
-
-/**
- * A class to help show and hide advanced form content.
- *
- * @class M.form.showadvanced
- * @constructor
- * @extends Base
- */
-function SHOWADVANCED() {
-    SHOWADVANCED.superclass.constructor.apply(this, arguments);
-}
-
-var SELECTORS = {
-        FIELDSETCONTAINSADVANCED: 'fieldset.containsadvancedelements',
-        DIVFITEMADVANCED: 'div.fitem.advanced',
-        DIVFCONTAINER: 'div.fcontainer',
-        MORELESSLINK: 'fieldset.containsadvancedelements .moreless-toggler'
-    },
-    CSS = {
-        SHOW: 'show',
-        MORELESSACTIONS: 'moreless-actions',
-        MORELESSTOGGLER: 'moreless-toggler',
-        SHOWLESS: 'moreless-less'
-    },
-    WRAPPERS = {
-        FITEM: '<div class="fitem"></div>',
-        FELEMENT: '<div class="felement"></div>'
-    },
-    ATTRS = {};
-
-/**
- * The form ID attribute definition.
- *
- * @attribute formid
- * @type String
- * @default null
- * @writeOnce
- */
-ATTRS.formid = {
-    value: null
-};
-
-Y.extend(SHOWADVANCED, Y.Base, {
-    /**
-     * The initializer for the showadvanced instance.
-     *
-     * @method initializer
-     * @protected
-     */
-    initializer: function() {
-        var form = Y.one('#' + this.get('formid')),
-            fieldlist = form.all(SELECTORS.FIELDSETCONTAINSADVANCED);
-
-        // Look through fieldset divs that contain advanced elements.
-        fieldlist.each(this.processFieldset, this);
-
-        // Subscribe more/less links to click event.
-        form.delegate('click', this.switchState, SELECTORS.MORELESSLINK);
-        form.delegate('key', this.switchState, 'down:enter,32', SELECTORS.MORELESSLINK);
-    },
-
-    /**
-     * Process the supplied fieldset to add appropriate links, and ARIA roles.
-     *
-     * @method processFieldset
-     * @param {Node} fieldset The Node relating to the fieldset to add collapsing to.
-     * @chainable
-     */
-    processFieldset: function(fieldset) {
-        var statuselement = Y.one('input[name=mform_showmore_' + fieldset.get('id') + ']');
-        if (!statuselement) {
-            Y.log("M.form.showadvanced::processFieldset was called on an fieldset without a status field: '" +
-                fieldset.get('id') + "'", 'debug', 'moodle-form-showadvanced');
-            return this;
-        }
-
-        var morelesslink = Y.Node.create('<a href="#"></a>');
-        morelesslink.addClass(CSS.MORELESSTOGGLER);
-        if (statuselement.get('value') === '0') {
-            morelesslink.setHTML(M.util.get_string('showmore', 'form'));
-        } else {
-            morelesslink.setHTML(M.util.get_string('showless', 'form'));
-            morelesslink.addClass(CSS.SHOWLESS);
-            fieldset.all(SELECTORS.DIVFITEMADVANCED).addClass(CSS.SHOW);
-        }
-
-        // Get list of IDs controlled by this button to set the aria-controls attribute.
-        var idlist = [];
-        fieldset.all(SELECTORS.DIVFITEMADVANCED).each(function(node) {
-            idlist[idlist.length] = node.generateID();
-        });
-        morelesslink.setAttribute('role', 'button');
-        morelesslink.setAttribute('aria-controls', idlist.join(' '));
-
-        var fitem = Y.Node.create(WRAPPERS.FITEM);
-        fitem.addClass(CSS.MORELESSACTIONS);
-        var felement = Y.Node.create(WRAPPERS.FELEMENT);
-        felement.append(morelesslink);
-        fitem.append(felement);
-
-        fieldset.one(SELECTORS.DIVFCONTAINER).append(fitem);
-
-        return this;
-    },
-
-    /**
-     * Toggle the state for the fieldset that was clicked.
-     *
-     * @method switchState
-     * @param {EventFacade} e
-     */
-    switchState: function(e) {
-        e.preventDefault();
-        var fieldset = this.ancestor(SELECTORS.FIELDSETCONTAINSADVANCED);
-
-        // Toggle collapsed class.
-        fieldset.all(SELECTORS.DIVFITEMADVANCED).toggleClass(CSS.SHOW);
-
-        // Get corresponding hidden variable.
-        var statuselement = Y.one('input[name=mform_showmore_' + fieldset.get('id') + ']');
-
-        // Invert it and change the link text.
-        if (statuselement.get('value') === '0') {
-            statuselement.set('value', 1);
-            this.addClass(CSS.SHOWLESS);
-            this.setHTML(M.util.get_string('showless', 'form'));
-        } else {
-            statuselement.set('value', 0);
-            this.removeClass(CSS.SHOWLESS);
-            this.setHTML(M.util.get_string('showmore', 'form'));
-        }
-    }
-}, {
-    NAME: 'moodle-form-showadvanced',
-    ATTRS: ATTRS
-});
-
-M.form = M.form || {};
-M.form.showadvanced = M.form.showadvanced || function(params) {
-    return new SHOWADVANCED(params);
-};
diff --git a/lib/form/yui/src/showadvanced/meta/showadvanced.json b/lib/form/yui/src/showadvanced/meta/showadvanced.json
deleted file mode 100644 (file)
index befdc20..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-    "moodle-form-showadvanced": {
-        "requires": [
-            "node",
-            "base",
-            "selector-css3"
-        ]
-    }
-}
index caa39ef..acd0b21 100644 (file)
@@ -2919,8 +2919,7 @@ class MoodleQuickForm_Renderer extends HTML_QuickForm_Renderer_Tableless{
             $PAGE->requires->yui_module('moodle-form-shortforms', 'M.form.shortforms', array(array('formid' => $formid)));
         }
         if (!empty($this->_advancedElements)){
-            $PAGE->requires->strings_for_js(array('showmore', 'showless'), 'form');
-            $PAGE->requires->yui_module('moodle-form-showadvanced', 'M.form.showadvanced', array(array('formid' => $formid)));
+            $PAGE->requires->js_call_amd('core_form/showadvanced', 'init', [$formid]);
         }
     }
 
index 5ebe7af..3c44836 100644 (file)
@@ -8010,6 +8010,28 @@ function moodle_major_version($fromdisk = false) {
 
 // MISCELLANEOUS.
 
+/**
+ * Gets the system locale
+ *
+ * @return string Retuns the current locale.
+ */
+function moodle_getlocale() {
+    global $CFG;
+
+    // Fetch the correct locale based on ostype.
+    if ($CFG->ostype == 'WINDOWS') {
+        $stringtofetch = 'localewin';
+    } else {
+        $stringtofetch = 'locale';
+    }
+
+    if (!empty($CFG->locale)) { // Override locale for all language packs.
+        return $CFG->locale;
+    }
+
+    return get_string($stringtofetch, 'langconfig');
+}
+
 /**
  * Sets the system locale
  *
@@ -8023,20 +8045,11 @@ function moodle_setlocale($locale='') {
 
     $oldlocale = $currentlocale;
 
-    // Fetch the correct locale based on ostype.
-    if ($CFG->ostype == 'WINDOWS') {
-        $stringtofetch = 'localewin';
-    } else {
-        $stringtofetch = 'locale';
-    }
-
     // The priority is the same as in get_string() - parameter, config, course, session, user, global language.
     if (!empty($locale)) {
         $currentlocale = $locale;
-    } else if (!empty($CFG->locale)) { // Override locale for all language packs.
-        $currentlocale = $CFG->locale;
     } else {
-        $currentlocale = get_string($stringtofetch, 'langconfig');
+        $currentlocale = moodle_getlocale();
     }
 
     // Do nothing if locale already set up.
index 93d32b5..1090652 100644 (file)
@@ -430,15 +430,16 @@ class navigation_node implements renderable {
      *
      * @param flat_navigation $nodes List of the found flat navigation nodes.
      * @param boolean $showdivider Show a divider before the first node.
+     * @param string $label A label for the collection of navigation links.
      */
-    public function build_flat_navigation_list(flat_navigation $nodes, $showdivider = false) {
+    public function build_flat_navigation_list(flat_navigation $nodes, $showdivider = false, $label = '') {
         if ($this->showinflatnavigation) {
             $indent = 0;
             if ($this->type == self::TYPE_COURSE || $this->key === self::COURSE_INDEX_PAGE) {
                 $indent = 1;
             }
             $flat = new flat_navigation_node($this, $indent);
-            $flat->set_showdivider($showdivider);
+            $flat->set_showdivider($showdivider, $label);
             $nodes->add($flat);
         }
         foreach ($this->children as $child) {
@@ -913,6 +914,12 @@ class navigation_node_collection implements IteratorAggregate, Countable {
      */
     protected $count = 0;
 
+    /**
+     * Label for collection of nodes.
+     * @var string
+     */
+    protected $collectionlabel = '';
+
     /**
      * Adds a navigation node to the collection
      *
@@ -988,6 +995,24 @@ class navigation_node_collection implements IteratorAggregate, Countable {
         return $keys;
     }
 
+    /**
+     * Set a label for this collection.
+     *
+     * @param string $label
+     */
+    public function set_collectionlabel($label) {
+        $this->collectionlabel = $label;
+    }
+
+    /**
+     * Return a label for this collection.
+     *
+     * @return string
+     */
+    public function get_collectionlabel() {
+        return $this->collectionlabel;
+    }
+
     /**
      * Fetches a node from this collection.
      *
@@ -3770,6 +3795,9 @@ class flat_navigation_node extends navigation_node {
     /** @var $showdivider bool Show a divider before this element */
     private $showdivider = false;
 
+    /** @var $collectionlabel string Label for a group of nodes */
+    private $collectionlabel = '';
+
     /**
      * A proxy constructor
      *
@@ -3791,6 +3819,31 @@ class flat_navigation_node extends navigation_node {
         $this->indent = $indent;
     }
 
+    /**
+     * Setter, a label is required for a flat navigation node that shows a divider.
+     *
+     * @param string $label
+     */
+    public function set_collectionlabel($label) {
+        $this->collectionlabel = $label;
+    }
+
+    /**
+     * Getter, get the label for this flat_navigation node, or it's parent if it doesn't have one.
+     *
+     * @return string
+     */
+    public function get_collectionlabel() {
+        if (!empty($this->collectionlabel)) {
+            return $this->collectionlabel;
+        }
+        if ($this->parent && ($this->parent instanceof flat_navigation_node || $this->parent instanceof flat_navigation)) {
+            return $this->parent->get_collectionlabel();
+        }
+        debugging('Navigation region requires a label', DEBUG_DEVELOPER);
+        return '';
+    }
+
     /**
      * Does this node represent a course section link.
      * @return boolean
@@ -3828,9 +3881,15 @@ class flat_navigation_node extends navigation_node {
     /**
      * Setter for "showdivider"
      * @param $val boolean
+     * @param $label string Label for the group of nodes
      */
-    public function set_showdivider($val) {
+    public function set_showdivider($val, $label = '') {
         $this->showdivider = $val;
+        if ($this->showdivider && empty($label)) {
+            debugging('Navigation region requires a label', DEBUG_DEVELOPER);
+        } else {
+            $this->set_collectionlabel($label);
+        }
     }
 
     /**
@@ -3848,7 +3907,6 @@ class flat_navigation_node extends navigation_node {
     public function set_indent($val) {
         $this->indent = $val;
     }
-
 }
 
 /**
@@ -3903,6 +3961,7 @@ class flat_navigation extends navigation_node_collection {
                 format_string($course->fullname, true, array('context' => $coursecontext));
 
             $flat = new flat_navigation_node(navigation_node::create($coursename, $url), 0);
+            $flat->set_collectionlabel($coursename);
             $flat->key = 'coursehome';
             $flat->icon = new pix_icon('i/course', '');
 
@@ -3930,9 +3989,9 @@ class flat_navigation extends navigation_node_collection {
                 }
             }
 
-            $this->page->navigation->build_flat_navigation_list($this, true);
+            $this->page->navigation->build_flat_navigation_list($this, true, get_string('site'));
         } else {
-            $this->page->navigation->build_flat_navigation_list($this, false);
+            $this->page->navigation->build_flat_navigation_list($this, false, get_string('site'));
         }
 
         $admin = $PAGE->settingsnav->find('siteadministration', navigation_node::TYPE_SITE_ADMIN);
@@ -3942,7 +4001,7 @@ class flat_navigation extends navigation_node_collection {
         }
         if ($admin) {
             $flat = new flat_navigation_node($admin, 0);
-            $flat->set_showdivider(true);
+            $flat->set_showdivider(true, get_string('sitesettings'));
             $flat->key = 'sitesettings';
             $flat->icon = new pix_icon('t/preferences', '');
             $this->add($flat);
@@ -3956,7 +4015,7 @@ class flat_navigation extends navigation_node_collection {
             $url = new moodle_url($PAGE->url, ['bui_addblock' => '', 'sesskey' => sesskey()]);
             $addablock = navigation_node::create(get_string('addblock'), $url);
             $flat = new flat_navigation_node($addablock, 0);
-            $flat->set_showdivider(true);
+            $flat->set_showdivider(true, get_string('blocksaddedit'));
             $flat->key = 'addblock';
             $flat->icon = new pix_icon('i/addblock', '');
             $this->add($flat);
@@ -3969,6 +4028,26 @@ class flat_navigation extends navigation_node_collection {
         }
     }
 
+
+    /**
+     * Override the parent so we can set a label for this collection if it has not been set yet.
+     *
+     * @param navigation_node $node Node to add
+     * @param string $beforekey If specified, adds before a node with this key,
+     *   otherwise adds at end
+     * @return navigation_node Added node
+     */
+    public function add(navigation_node $node, $beforekey=null) {
+        $result = parent::add($node, $beforekey);
+        // Extend the parent to get a name for the collection of nodes if required.
+        if (empty($this->collectionlabel)) {
+            if ($node instanceof flat_navigation_node) {
+                $this->set_collectionlabel($node->get_collectionlabel());
+            }
+        }
+
+        return $result;
+    }
 }
 
 /**
index 7aa51a6..9448fea 100644 (file)
                         </label>
                     </div>
                     <div class="form-input">
-                        <input type="text" name="username" id="username" size="15" value="{{username}}">
+                        <input type="text" name="username" id="username" size="15" value="{{username}}" autocomplete="username">
                     </div>
                     <div class="clearer"><!-- --></div>
                     <div class="form-label">
                         <label for="password">{{#str}} password {{/str}}</label>
                     </div>
                     <div class="form-input">
-                        <input type="password" name="password" id="password" size="15" value="">
+                        <input type="password" name="password" id="password" size="15" value="" autocomplete="current-password">
                     </div>
                 </div>
 
index 530bce0..9f6e4af 100644 (file)
@@ -3,6 +3,7 @@ information provided here is intended especially for developers.
 
 === 3.7 ===
 
+* Nodes in the navigation api can have labels for each group. See set/get_collectionlabel().
 * The method core_user::is_real_user() now returns false for userid = 0 parameter
 * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id
 attribute on forms to avoid collisions in forms loaded in AJAX requests.
index 5064690..4a25979 100644 (file)
@@ -26,7 +26,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-require_once $CFG->libdir.'/formslib.php';
+require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 class login_change_password_form extends moodleform {
 
@@ -51,7 +52,8 @@ class login_change_password_form extends moodleform {
         if ($policies) {
             $mform->addElement('static', 'passwordpolicyinfo', '', implode('<br />', $policies));
         }
-        $mform->addElement('password', 'password', get_string('oldpassword'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'password');
+        $mform->addElement('password', 'password', get_string('oldpassword'), $purpose);
         $mform->addRule('password', get_string('required'), 'required', null, 'client');
         $mform->setType('password', PARAM_RAW);
 
index 32e5310..67a4d9f 100644 (file)
@@ -25,6 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 /**
  * Reset forgotten password form definition.
@@ -40,12 +41,15 @@ class login_forgot_password_form extends moodleform {
      * Define the forgot password form.
      */
     function definition() {
+        global $USER;
+
         $mform    = $this->_form;
         $mform->setDisableShortforms(true);
 
         $mform->addElement('header', 'searchbyusername', get_string('searchbyusername'), '');
 
-        $mform->addElement('text', 'username', get_string('username'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'username');
+        $mform->addElement('text', 'username', get_string('username'), 'size="20"' . $purpose);
         $mform->setType('username', PARAM_RAW);
 
         $submitlabel = get_string('search');
@@ -53,7 +57,8 @@ class login_forgot_password_form extends moodleform {
 
         $mform->addElement('header', 'searchbyemail', get_string('searchbyemail'), '');
 
-        $mform->addElement('text', 'email', get_string('email'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'email');
+        $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"' . $purpose);
         $mform->setType('email', PARAM_RAW_TRIMMED);
 
         $submitlabel = get_string('search');
index 0bcc236..ff35374 100644 (file)
Binary files a/message/amd/build/notification_processor_settings.min.js and b/message/amd/build/notification_processor_settings.min.js differ
index d4347b0..3d1c9f6 100644 (file)
Binary files a/message/amd/build/preferences_notifications_list_controller.min.js and b/message/amd/build/preferences_notifications_list_controller.min.js differ
index 6964dfb..8ce9f05 100644 (file)
Binary files a/message/amd/build/preferences_processor_form.min.js and b/message/amd/build/preferences_processor_form.min.js differ
index bb3763b..1fb2132 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/notification', 'core/fragment', 'core/templates', 'core/str', 'tool_lp/dialogue'],
-        function($, Ajax, Notification, Fragment, Templates, Str, Dialogue) {
+define([
+        'jquery',
+        'core/ajax',
+        'core/str',
+        'core/notification',
+        'core/custom_interaction_events',
+        'core/modal',
+        'core/modal_registry',
+        'core/fragment',
+        ],
+        function(
+            $,
+            Ajax,
+            Str,
+            Notification,
+            CustomEvents,
+            Modal,
+            ModalRegistry,
+            Fragment
+        ) {
 
+    var registered = false;
     var SELECTORS = {
+        SAVE_BUTTON: '[data-action="save"]',
+        CANCEL_BUTTON: '[data-action="cancel"]',
         PROCESSOR: '[data-processor-name]',
         PREFERENCE_ROW: '[data-region="preference-row"]',
     };
 
     /**
-     * Constructor for the notification processor settings.
+     * Constructor for the Modal.
      *
-     * @param {object} element jQuery object root element of the processor
+     * @param {object} root The root jQuery element for the modal.
      */
-    var NotificationProcessorSettings = function(element) {
-        this.root = $(element);
-        this.name = this.root.attr('data-name');
-        this.userId = this.root.attr('data-user-id');
-        this.contextId = this.root.attr('data-context-id');
+    var NotificationProcessorSettings = function(root) {
+        Modal.call(this, root);
+        this.name = null;
+        this.userId = null;
+        this.contextId = null;
+        this.element = null;
+        this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
+        this.cancelButton = this.getFooter().find(SELECTORS.CANCEL_BUTTON);
+    };
+
+    NotificationProcessorSettings.TYPE = 'core_message-notification_processor_settings';
+    NotificationProcessorSettings.prototype = Object.create(Modal.prototype);
+    NotificationProcessorSettings.prototype.constructor = NotificationProcessorSettings;
+
+    /**
+     * Set the userid to the given value.
+     *
+     * @method setUserId
+     * @param {int} id The notification userid
+     */
+    NotificationProcessorSettings.prototype.setUserId = function(id) {
+        this.userId = id;
+    };
+
+    /**
+     * Retrieve the current userid, if any.
+     *
+     * @method getUserId
+     * @return {int|null} The notification userid
+     */
+    NotificationProcessorSettings.prototype.getUserId = function() {
+        return this.userId;
+    };
+
+    /**
+     * Set the object to the given value.
+     *
+     * @method setElement
+     * @param {object} element The notification node element.
+     */
+    NotificationProcessorSettings.prototype.setElement = function(element) {
+        this.element = element;
+    };
+
+    /**
+     * Retrieve the current element, if any.
+     *
+     * @method getElement
+     * @return {object|null} The notification node element.
+     */
+    NotificationProcessorSettings.prototype.getElement = function() {
+        return this.element;
+    };
+
+    /**
+     * Set the name to the given value.
+     *
+     * @method setName
+     * @param {string} name The notification name.
+     */
+    NotificationProcessorSettings.prototype.setName = function(name) {
+        this.name = name;
+    };
+
+    /**
+     * Retrieve the current name, if any.
+     *
+     * @method getName
+     * @return {string|null} The notification name.
+     */
+    NotificationProcessorSettings.prototype.getName = function() {
+        return this.name;
+    };
+    /**
+     * Set the context id to the given value.
+     *
+     * @method setContextId
+     * @param {Number} id The notification context id
+     */
+    NotificationProcessorSettings.prototype.setContextId = function(id) {
+        this.contextId = id;
+    };
+
+    /**
+     * Retrieve the current context id, if any.
+     *
+     * @method getContextId
+     * @return {Number|null} The notification context id
+     */
+    NotificationProcessorSettings.prototype.getContextId = function() {
+        return this.contextId;
+    };
+
+    /**
+     * Get the form element from the modal.
+     *
+     * @method getForm
+     * @return {object}
+     */
+    NotificationProcessorSettings.prototype.getForm = function() {
+        return this.getBody().find('form');
+    };
+
+    /**
+     * Disable the buttons in the footer.
+     *
+     * @method disableButtons
+     */
+    NotificationProcessorSettings.prototype.disableButtons = function() {
+        this.saveButton.prop('disabled', true);
+        this.cancelButton.prop('disabled', true);
+    };
+
+    /**
+     * Enable the buttons in the footer.
+     *
+     * @method enableButtons
+     */
+    NotificationProcessorSettings.prototype.enableButtons = function() {
+        this.saveButton.prop('disabled', false);
+        this.cancelButton.prop('disabled', false);
+    };
+
+    /**
+     * Load the title for the modal to the appropriate value
+     * depending on message outputs.
+     *
+     * @method loadTitleContent
+     * @return {object} A promise resolved with the new title text.
+     */
+    NotificationProcessorSettings.prototype.loadTitleContent = function() {
+        this.titlePromise = Str.get_string('processorsettings', 'message');
+        this.setTitle(this.titlePromise);
+
+        return this.titlePromise;
+    };
+
+    /**
+     * Load the body for the modal to the appropriate value
+     * depending on message outputs.
+     *
+     * @method loadBodyContent
+     * @return {object} A promise resolved with the fragment html and js from
+     */
+    NotificationProcessorSettings.prototype.loadBodyContent = function() {
+        this.disableButtons();
+
+        var args = {
+            userid: this.getUserId(),
+            type: this.getName(),
+        };
+
+        this.bodyPromise = Fragment.loadFragment('message', 'processor_settings', this.getContextId(), args);
+        this.setBody(this.bodyPromise);
+
+        this.bodyPromise.then(function() {
+            this.enableButtons();
+            return;
+        }.bind(this))
+        .fail(Notification.exception);
+
+        return this.bodyPromise;
     };
 
     /**
-     * Show the notification processor settings dialogue.
+     * Load both the title and body content.
+     *
+     * @method loadAllContent
+     * @return {object} promise
+     */
+    NotificationProcessorSettings.prototype.loadAllContent = function() {
+        return $.when(this.loadTitleContent(), this.loadBodyContent());
+    };
+
+    /**
+     * Load the modal content before showing it. This
+     * is to allow us to re-use the same modal for creating and
+     * editing different message outputs within the page.
      *
      * @method show
      */
     NotificationProcessorSettings.prototype.show = function() {
-        Fragment.loadFragment('message', 'processor_settings', this.contextId, {
-            userid: this.userId,
-            type: this.name,
-        })
-        .done(function(html, js) {
-            Str.get_string('processorsettings', 'message').done(function(string) {
-                var dialogue = new Dialogue(
-                    string,
-                    html,
-                    function() {
-                        Templates.runTemplateJS(js);
-                    },
-                    function() {
-                        // Removed dialogue from the DOM after close.
-                        dialogue.close();
-                    }
-                );
-
-                $(document).on('mpp:formsubmitted', function() {
-                    dialogue.close();
-                    this.updateConfiguredStatus();
-                }.bind(this));
-
-                $(document).on('mpp:formcancelled', function() {
-                    dialogue.close();
-                });
-            }.bind(this));
-        }.bind(this));
+        this.loadAllContent();
+        Modal.prototype.show.call(this);
+    };
+
+    /**
+     * Clear the notification from the modal when it's closed so
+     * that it is loaded fresh next time it's displayed.
+     *
+     * @method hide
+     */
+    NotificationProcessorSettings.prototype.hide = function() {
+        Modal.prototype.hide.call(this);
+        this.setContextId(null);
+        this.setName(null);
+        this.setUserId(null);
     };
 
     /**
@@ -86,7 +263,7 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/fragment', 'core/templ
      * @return {Promise|boolean}
      */
     NotificationProcessorSettings.prototype.updateConfiguredStatus = function() {
-        var processorHeader = this.root.closest(SELECTORS.PROCESSOR);
+        var processorHeader = $(this.getElement()).closest(SELECTORS.PROCESSOR);
 
         if (!processorHeader.hasClass('unconfigured')) {
             return false;
@@ -114,5 +291,44 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/fragment', 'core/templ
             });
     };
 
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    NotificationProcessorSettings.prototype.registerEventListeners = function() {
+        // Apply parent event listeners.
+        Modal.prototype.registerEventListeners.call(this);
+
+        // When the user clicks the save button we trigger the form submission.
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
+            this.getForm().submit();
+            data.originalEvent.preventDefault();
+        }.bind(this));
+
+        this.getModal().on('mpp:formsubmitted', function(e) {
+            this.hide();
+            this.updateConfiguredStatus();
+            e.stopPropagation();
+        }.bind(this));
+
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
+            this.hide();
+            data.originalEvent.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+    };
+
+    // Automatically register with the modal registry the first time this module is imported
+    // so that you can create modals
+    // of this type using the modal factory.
+    if (!registered) {
+        ModalRegistry.register(
+                                NotificationProcessorSettings.TYPE,
+                                NotificationProcessorSettings,
+                                'core/modal_save_cancel');
+        registered = true;
+    }
+
     return NotificationProcessorSettings;
 });
index 14ab2e6..8b73113 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/notification', 'core/custom_interaction_events', 'core_message/notification_preference',
-        'core_message/notification_processor_settings'],
-        function($, Ajax, Notification, CustomEvents, NotificationPreference, NotificationProcessorSettings) {
+define(['jquery',
+        'core/ajax',
+        'core/notification',
+        'core/custom_interaction_events',
+        'core_message/notification_preference',
+        'core_message/notification_processor_settings',
+        'core/modal_factory',
+        ],
+        function(
+          $,
+          Ajax,
+          Notification,
+          CustomEvents,
+          NotificationPreference,
+          NotificationProcessorSettings,
+          ModalFactory
+        ) {
 
     var SELECTORS = {
         DISABLE_NOTIFICATIONS: '[data-region="disable-notification-container"] [data-disable-notifications]',
@@ -143,11 +157,25 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/custom_interaction_eve
             }
         }.bind(this));
 
-        this.root.on(CustomEvents.events.activate, SELECTORS.PROCESSOR_SETTING, function(e, data) {
+        var eventFormPromise = ModalFactory.create({
+            type: NotificationProcessorSettings.TYPE,
+        });
+
+        this.root.on(CustomEvents.events.activate, SELECTORS.PROCESSOR_SETTING, function(e) {
             var element = $(e.target).closest(SELECTORS.PROCESSOR_SETTING);
-            var processorSettings = new NotificationProcessorSettings(element);
-            processorSettings.show();
-            data.originalEvent.preventDefault();
+
+            e.preventDefault();
+            eventFormPromise.then(function(modal) {
+                // Configure modal with element settings.
+                modal.setUserId($(element).attr('data-user-id'));
+                modal.setName($(element).attr('data-name'));
+                modal.setContextId($(element).attr('data-context-id'));
+                modal.setElement(element);
+                modal.show();
+
+                e.stopImmediatePropagation();
+                return;
+            }).fail(Notification.exception);
         });
 
         CustomEvents.define(disabledNotificationsElement, [
index 53b3766..e458d8b 100644 (file)
@@ -22,8 +22,8 @@
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/notification', 'core/custom_interaction_events'],
-        function($, Ajax, Notification, CustomEvents) {
+define(['jquery', 'core/ajax', 'core/notification'],
+        function($, Ajax, Notification) {
     /**
      * Constructor for the ProcessorForm.
      *
@@ -37,18 +37,9 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/custom_interaction_eve
         this.root.find('form').on('submit', function(e) {
             e.preventDefault();
             this.save().done(function() {
-                $(document).trigger('mpp:formsubmitted');
+                $(element).trigger('mpp:formsubmitted');
             });
         }.bind(this));
-
-        var cancelButton = this.root.find('[data-cancel-button]');
-        CustomEvents.define(cancelButton, [
-            CustomEvents.events.activate
-        ]);
-
-        cancelButton.on(CustomEvents.events.activate, function() {
-            $(document).trigger('mpp:formcancelled');
-        });
     };
 
     /**
index be8aea6..a53f6b7 100644 (file)
@@ -2497,6 +2497,31 @@ class api {
 
         $request->id = $DB->insert_record('message_contact_requests', $request);
 
+        // Send a notification.
+        $userfrom = \core_user::get_user($userid);
+        $userfromfullname = fullname($userfrom);
+        $userto = \core_user::get_user($requesteduserid);
+        $url = new \moodle_url('/message/pendingcontactrequests.php');
+
+        $subject = get_string('messagecontactrequestsnotificationsubject', 'core_message', $userfromfullname);
+        $fullmessage = get_string('messagecontactrequestsnotification', 'core_message', $userfromfullname);
+
+        $message = new \core\message\message();
+        $message->courseid = SITEID;
+        $message->component = 'moodle';
+        $message->name = 'messagecontactrequests';
+        $message->notification = 1;
+        $message->userfrom = $userfrom;
+        $message->userto = $userto;
+        $message->subject = $subject;
+        $message->fullmessage = text_to_html($fullmessage);
+        $message->fullmessageformat = FORMAT_HTML;
+        $message->fullmessagehtml = $fullmessage;
+        $message->smallmessage = '';
+        $message->contexturl = $url->out(false);
+
+        message_send($message);
+
         return $request;
     }
 
index c9c6631..588a4d4 100644 (file)
@@ -4160,7 +4160,7 @@ class core_message_external extends external_api {
      * @since 3.2
      */
     public static function get_user_message_preferences($userid = 0) {
-        global $PAGE;
+        global $CFG, $PAGE;
 
         $params = self::validate_parameters(
             self::get_user_message_preferences_parameters(),
@@ -4189,11 +4189,13 @@ class core_message_external extends external_api {
 
         $renderer = $PAGE->get_renderer('core_message');
 
+        $entertosend = get_user_preferences('message_entertosend', $CFG->messagingdefaultpressenter, $user);
+
         $result = array(
             'warnings' => array(),
             'preferences' => $notificationlistoutput->export_for_template($renderer),
             'blocknoncontacts' => \core_message\api::get_user_privacy_messaging_preference($user->id),
-            'entertosend' => get_user_preferences('message_entertosend', false, $user)
+            'entertosend' => $entertosend
         );
         return $result;
     }
index 474b274..5ed7fff 100644 (file)
@@ -348,19 +348,6 @@ function message_post_message($userfrom, $userto, $message, $format) {
 
     $eventdata->fullmessageformat = $format;
     $eventdata->smallmessage     = $message;//store the message unfiltered. Clean up on output.
-
-    $s = new stdClass();
-    $s->sitename = format_string($SITE->shortname, true, array('context' => context_course::instance(SITEID)));
-    $s->url = $CFG->wwwroot.'/message/index.php?user='.$userto->id.'&id='.$userfrom->id;
-
-    $emailtagline = get_string_manager()->get_string('emailtagline', 'message', $s, $userto->lang);
-    if (!empty($eventdata->fullmessage)) {
-        $eventdata->fullmessage .= "\n\n---------------------------------------------------------------------\n".$emailtagline;
-    }
-    if (!empty($eventdata->fullmessagehtml)) {
-        $eventdata->fullmessagehtml .= "<br /><br />---------------------------------------------------------------------<br />".$emailtagline;
-    }
-
     $eventdata->timecreated     = time();
     $eventdata->notification    = 0;
     return message_send($eventdata);
@@ -855,7 +842,7 @@ function core_message_standard_after_main_region_html() {
     }
 
     // Enter to send.
-    $entertosend = get_user_preferences('message_entertosend', false, $USER);
+    $entertosend = get_user_preferences('message_entertosend', $CFG->messagingdefaultpressenter, $USER);
 
     return $renderer->render_from_template('core_message/message_drawer', [
         'contactrequestcount' => $requestcount,
index 42be717..dd77931 100644 (file)
     </div>
     <form>
         {{{formhtml}}}
-        <div class="form-actions m-t-1">
-            <button type="submit" class="btn btn-primary">{{#str}} savechanges {{/str}}</button>
-            <button type="button" class="btn" data-cancel-button>{{#str}} cancel {{/str}}</button>
-        </div>
     </form>
 </div>
 {{#js}}
index 4d1971c..425c425 100644 (file)
@@ -904,8 +904,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 conversation members.
         $this->assertEquals(4, $DB->count_records('message_conversation_members'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         provider::delete_data_for_all_users_in_context($user1context);
 
@@ -943,8 +943,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // And user1 is not in any conversation.
         $this->assertEquals(0, $DB->count_records('message_conversation_members', ['userid' => $user1->id]));
 
-        // Confirm there is only 1 notification.
-        $this->assertEquals(1, $DB->count_records('notifications'));
+        // Confirm there are only 2 notifications.
+        $this->assertEquals(2, $DB->count_records('notifications'));
         // And it is not related to user1.
         $this->assertEquals(0,
                 $DB->count_records_select('notifications', 'useridfrom = ? OR useridto = ? ', [$user1->id, $user1->id]));
@@ -1011,8 +1011,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be two conversation members.
         $this->assertEquals(2, $DB->count_records('message_conversation_members'));
 
-        // There should be three notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         $user1context = context_user::instance($user1->id);
         $contextlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_message',
@@ -1053,10 +1053,10 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $mcm = reset($mcms);
         $this->assertEquals($user2->id, $mcm->userid);
 
-        $this->assertCount(1, $notifications);
+        $this->assertCount(2, $notifications);
         ksort($notifications);
 
-        $notification = array_shift($notifications);
+        $notification = array_pop($notifications);
         $this->assertEquals($user2->id, $notification->useridfrom);
         $this->assertEquals($user3->id, $notification->useridto);
     }
@@ -1323,7 +1323,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_conversation_members'));
 
         // There should be three notifications + two for the contact requests.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         $user1context = context_user::instance($user1->id);
         $approveduserlist = new \core_privacy\local\request\approved_userlist($user1context, 'core_message',
@@ -1366,10 +1366,10 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $mcm = reset($mcms);
         $this->assertEquals($user2->id, $mcm->userid);
 
-        $this->assertCount(1, $notifications);
+        $this->assertCount(2, $notifications);
         ksort($notifications);
 
-        $notification = array_shift($notifications);
+        $notification = array_pop($notifications);
         $this->assertEquals($user2->id, $notification->useridfrom);
         $this->assertEquals($user3->id, $notification->useridto);
     }
@@ -1843,8 +1843,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 9 conversation members - (2 + 2) individual + (3 + 2) group.
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 3 favourite conversations.
         $this->assertEquals(3, $DB->count_records('favourite'));
@@ -1861,8 +1861,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be still 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications.
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 5 messages - 3 individual - 2 group (course2).
         $this->assertEquals(5, $DB->count_records('messages'));
@@ -2016,8 +2016,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 9 conversation members - (2 + 2) individual + (3 + 2) group.
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 3 favourite conversations.
         $this->assertEquals(3, $DB->count_records('favourite'));
@@ -2033,7 +2033,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(3, $DB->count_records('favourite'));
 
         // Delete individual conversations for all users in system context.
@@ -2047,7 +2047,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(3, $DB->count_records('favourite'));
     }
 
@@ -2175,8 +2175,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 9 conversation members - (2 + 2) individual + (3 + 2) group.
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 3 favourite conversations.
         $this->assertEquals(3, $DB->count_records('favourite'));
@@ -2192,7 +2192,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(3, $DB->count_records('favourite'));
 
         // Delete individual conversations for all users in user context.
@@ -2206,7 +2206,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(3, $DB->count_records('favourite'));
     }
 
@@ -2310,8 +2310,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications.
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 6 messages.
         $this->assertEquals(6, $DB->count_records('messages'));
@@ -2347,8 +2347,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be still 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications.
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 4 messages - 3 private + 1 group sent by user2.
         $this->assertEquals(4, $DB->count_records('messages'));
@@ -2493,8 +2493,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications (3 from create_notification and 2 from create_contact_request).
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 6 messages.
         $this->assertEquals(6, $DB->count_records('messages'));
@@ -2526,7 +2526,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_contacts'));
         $this->assertEquals(2, $DB->count_records('message_contact_requests'));
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(6, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
@@ -2542,7 +2542,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_contacts'));
         $this->assertEquals(2, $DB->count_records('message_contact_requests'));
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(6, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
@@ -2564,8 +2564,8 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be still 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 3 notifications.
-        $this->assertEquals(3, $DB->count_records('notifications'));
+        // There should be 5 notifications.
+        $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 4 messages - 3 private + 1 group sent by user3.
         $this->assertEquals(4, $DB->count_records('messages'));
index bb7d06b..c0b5e2d 100644 (file)
@@ -60,6 +60,9 @@ abstract class base extends \core\event\base {
         if ($assign->get_context()->id != $this->get_context()->id) {
             throw new \coding_exception('Invalid assign isntance supplied!');
         }
+        if ($assign->is_blind_marking()) {
+            $this->data['anonymous'] = 1;
+        }
         $this->assign = $assign;
     }
 
index cd9f4c1..cacc73a 100644 (file)
@@ -8987,10 +8987,15 @@ class assign {
 
         // Trigger the course module viewed event.
         $assigninstance = $this->get_instance();
-        $event = \mod_assign\event\course_module_viewed::create(array(
-                'objectid' => $assigninstance->id,
-                'context' => $this->get_context()
-        ));
+        $params = [
+            'objectid' => $assigninstance->id,
+            'context' => $this->get_context()
+        ];
+        if ($this->is_blind_marking()) {
+            $params['anonymous'] = 1;
+        }
+
+        $event = \mod_assign\event\course_module_viewed::create($params);
 
         $event->add_record_snapshot('assign', $assigninstance);
         $event->trigger();
index 38e2da0..a4153dd 100644 (file)
@@ -252,6 +252,9 @@ class assign_submission_file extends assign_submission_plugin {
         if (!empty($submission->userid) && ($submission->userid != $USER->id)) {
             $params['relateduserid'] = $submission->userid;
         }
+        if ($this->assignment->is_blind_marking()) {
+            $params['anonymous'] = 1;
+        }
         $event = \assignsubmission_file\event\assessable_uploaded::create($params);
         $event->set_legacy_files($files);
         $event->trigger();
index a67bd06..3013327 100644 (file)
@@ -251,6 +251,9 @@ class assign_submission_onlinetext extends assign_submission_plugin {
         if (!empty($submission->userid) && ($submission->userid != $USER->id)) {
             $params['relateduserid'] = $submission->userid;
         }
+        if ($this->assignment->is_blind_marking()) {
+            $params['anonymous'] = 1;
+        }
         $event = \assignsubmission_onlinetext\event\assessable_uploaded::create($params);
         $event->trigger();
 
index b2fc975..fe50250 100644 (file)
@@ -1353,4 +1353,43 @@ class assign_events_testcase extends advanced_testcase {
         $this->assertInstanceOf('\mod_assign\event\course_module_viewed', $event);
         $this->assertEquals($context, $event->get_context());
     }
+
+    /**
+     * Test that all events generated with blindmarking enabled are anonymous
+     */
+    public function test_anonymous_events() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+        $instance = $generator->create_instance(array('course' => $course->id, 'blindmarking' => 1));
+
+        $cm = get_coursemodule_from_instance('assign', $instance->id, $course->id);
+        $context = context_module::instance($cm->id);
+        $assign = new assign($context, $cm, $course);
+
+        $this->setUser($teacher);
+        $sink = $this->redirectEvents();
+
+        $assign->lock_submission($student1->id);
+
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        $this->assertTrue((bool)$event->anonymous);
+
+        $assign->reveal_identities();
+        $sink = $this->redirectEvents();
+        $assign->lock_submission($student2->id);
+
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        $this->assertFalse((bool)$event->anonymous);
+    }
+
 }
index fdecbcd..0db27f2 100644 (file)
@@ -1449,16 +1449,31 @@ function chat_view($chat, $course, $cm, $context) {
  *
  * @param calendar_event $event
  * @param \core_calendar\action_factory $factory
+ * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
  * @return \core_calendar\local\event\entities\action_interface|null
  */
 function mod_chat_core_calendar_provide_event_action(calendar_event $event,
-                                                     \core_calendar\action_factory $factory) {
-    global $DB;
+                                                     \core_calendar\action_factory $factory,
+                                                     int $userid = 0) {
+    global $USER, $DB;
+
+    if ($userid) {
+        $user = core_user::get_user($userid, 'id, timezone');
+    } else {
+        $user = $USER;
+    }
+
+    $cm = get_fast_modinfo($event->courseid, $user->id)->instances['chat'][$event->instance];
+
+    if (!$cm->uservisible) {
+        // The module is not visible to the user for any reason.
+        return null;
+    }
 
-    $cm = get_fast_modinfo($event->courseid)->instances['chat'][$event->instance];
     $chattime = $DB->get_field('chat', 'chattime', array('id' => $event->instance));
-    $chattimemidnight = usergetmidnight($chattime);
-    $todaymidnight = usergetmidnight(time());
+    $usertimezone = core_date::get_user_timezone($user);
+    $chattimemidnight = usergetmidnight($chattime, $usertimezone);
+    $todaymidnight = usergetmidnight(time(), $usertimezone);
 
     if ($chattime < $todaymidnight) {
         // The chat is before today. Do not show at all.
index 9df1d41..7609906 100644 (file)
@@ -39,6 +39,76 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $this->resetAfterTest();
     }
 
+    /*
+     * The chat's event should not be shown to a user when the user cannot view the chat at all.
+     */
+    public function test_chat_core_calendar_provide_event_action_in_hidden_section() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a student.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => usergetmidnight(time())));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Set sections 0 as hidden.
+        set_section_visible($course->id, 0, 0);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users might still have some capabilities.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event is not shown at all.
+        $this->assertNull($actionevent);
+    }
+
+    /*
+     * The chat's event should not be shown to a user who does not have permission to view the chat at all.
+     */
+    public function test_chat_core_calendar_provide_event_action_for_non_user() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => usergetmidnight(time())));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users might still have some capabilities.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory);
+
+        // Confirm the event is not shown at all.
+        $this->assertNull($actionevent);
+    }
+
     public function test_chat_core_calendar_provide_event_action_chattime_event_yesterday() {
         $this->setAdminUser();
 
@@ -62,6 +132,38 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $this->assertNull($actionevent);
     }
 
+    public function test_chat_core_calendar_provide_event_action_chattime_event_yesterday_for_user() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Enrol a student in the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => time() - DAYSECS));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users have mod/chat:view capability by default.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event is not shown at all.
+        $this->assertNull($actionevent);
+    }
+
     public function test_chat_core_calendar_provide_event_action_chattime_event_today() {
         $this->setAdminUser();
 
@@ -89,6 +191,42 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $this->assertTrue($actionevent->is_actionable());
     }
 
+    public function test_chat_core_calendar_provide_event_action_chattime_event_today_for_user() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Enrol a student in the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => usergetmidnight(time())));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users have mod/chat:view capability by default.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event was decorated.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertTrue($actionevent->is_actionable());
+    }
+
     public function test_chat_core_calendar_provide_event_action_chattime_event_tonight() {
         $this->setAdminUser();
 
@@ -116,6 +254,42 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $this->assertTrue($actionevent->is_actionable());
     }
 
+    public function test_chat_core_calendar_provide_event_action_chattime_event_tonight_for_user() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Enrol a student in the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => usergetmidnight(time()) + (23 * HOURSECS)));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users have mod/chat:view capability by default.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event was decorated.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertTrue($actionevent->is_actionable());
+    }
+
     public function test_chat_core_calendar_provide_event_action_chattime_event_tomorrow() {
         $this->setAdminUser();
 
@@ -143,6 +317,124 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $this->assertFalse($actionevent->is_actionable());
     }
 
+    public function test_chat_core_calendar_provide_event_action_chattime_event_tomorrow_for_user() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Enrol a student in the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a chat.
+        $chat = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => time() + DAYSECS));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $chat->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users have mod/chat:view capability by default.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_chat_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event was decorated.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertFalse($actionevent->is_actionable());
+    }
+
+    public function test_chat_core_calendar_provide_event_action_chattime_event_different_timezones() {
+        global $CFG;
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        $hour = gmdate('H');
+
+        // This could have been much easier if MDL-37327 were implemented.
+        // We don't know when this test is being ran and there is no standard way to
+        // mock the time() function (MDL-37327 to handle that).
+        if ($hour < 10) {
+            $timezone1 = 'Europe/London';       // GMT or GMT +01:00.
+            $timezone2 = 'Pacific/Pago_Pago';   // GMT -11:00.
+        } else if ($hour < 11) {
+            $timezone1 = 'Pacific/Kiritimati';  // GMT +14:00.
+            $timezone2 = 'America/Sao_Paulo';   // GMT -03:00.
+        } else {
+            $timezone1 = 'Pacific/Kiritimati';  // GMT +14:00.
+            $timezone2 = 'Europe/London';       // GMT or GMT +01:00.
+        }
+
+        $this->setTimezone($timezone2);
+
+        // Enrol 2 students with different timezones in the course.
+        $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student', (object)['timezone' => $timezone1]);
+        $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student', (object)['timezone' => $timezone2]);
+
+        // Create a chat.
+        $chat1 = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => mktime(1, 0, 0)));    // This is always yesterday in timezone1 time
+                                                    // and always today in timezone2 time.
+
+        // Create a chat.
+        $chat2 = $this->getDataGenerator()->create_module('chat', array('course' => $course->id,
+                'chattime' => mktime(1, 0, 0) + DAYSECS));  // This is always today in timezone1 time
+                                                            // and always tomorrow in timezone2 time.
+
+        // Create calendar events for the 2 chats above.
+        $event1 = $this->create_action_event($course->id, $chat1->id, CHAT_EVENT_TYPE_CHATTIME);
+        $event2 = $this->create_action_event($course->id, $chat2->id, CHAT_EVENT_TYPE_CHATTIME);
+
+        // Now, log out.
+        $CFG->forcelogin = true; // We don't want to be logged in as guest, as guest users have mod/chat:view capability by default.
+        $this->setUser();
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for student1.
+        $actionevent11 = mod_chat_core_calendar_provide_event_action($event1, $factory, $student1->id);
+        $actionevent12 = mod_chat_core_calendar_provide_event_action($event1, $factory, $student2->id);
+        $actionevent21 = mod_chat_core_calendar_provide_event_action($event2, $factory, $student1->id);
+        $actionevent22 = mod_chat_core_calendar_provide_event_action($event2, $factory, $student2->id);
+
+        // Confirm event1 is not shown to student1 at all.
+        $this->assertNull($actionevent11);
+
+        // Confirm event1 was decorated for student2 and it is actionable.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent12);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent12->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent12->get_url());
+        $this->assertEquals(1, $actionevent12->get_item_count());
+        $this->assertTrue($actionevent12->is_actionable());
+
+        // Confirm event2 was decorated for student1 and it is actionable.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent21);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent21->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent21->get_url());
+        $this->assertEquals(1, $actionevent21->get_item_count());
+        $this->assertTrue($actionevent21->is_actionable());
+
+        // Confirm event2 was decorated for student2 and it is not actionable.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent22);
+        $this->assertEquals(get_string('enterchat', 'chat'), $actionevent22->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent22->get_url());
+        $this->assertEquals(1, $actionevent22->get_item_count());
+        $this->assertFalse($actionevent22->is_actionable());
+    }
+
     /**
      * Test for chat_get_sessions().
      */
index 5adc32f..749be69 100644 (file)
@@ -279,6 +279,7 @@ if (isset($mode)) {
     $url->param('mode', $mode);
 }
 $PAGE->set_url($url);
+$PAGE->force_settings_menu();
 
 if (!empty($CFG->enablerssfeeds) && !empty($CFG->glossary_enablerssfeeds)
     && $glossary->rsstype && $glossary->rssarticles) {
diff --git a/question/amd/build/qbankmanager.min.js b/question/amd/build/qbankmanager.min.js
new file mode 100644 (file)
index 0000000..50dd343
Binary files /dev/null and b/question/amd/build/qbankmanager.min.js differ
diff --git a/question/amd/src/qbankmanager.js b/question/amd/src/qbankmanager.js
new file mode 100644 (file)
index 0000000..78ff864
--- /dev/null
@@ -0,0 +1,55 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle question ajax actions.
+ *
+ * @module     core_question/qbankmanager
+ * @class      qbankmanager
+ * @package    core_question
+ * @copyright 2018 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/pubsub', 'core/checkbox-toggleall'], function($, PubSub, ToggleAll) {
+
+    var registerListeners = function() {
+        PubSub.subscribe(ToggleAll.events.checkboxToggled, toggleButtonStates);
+    };
+
+    var toggleButtonStates = function(data) {
+        if ('qbank' !== data.toggleGroupName) {
+            return;
+        }
+
+        setButtonState(data.anyChecked);
+    };
+
+    var setButtonState = function(state) {
+        var buttons = $('.modulespecificbuttonscontainer').find('input, select, link');
+        buttons.attr('disabled', !state);
+    };
+
+    return {
+        /**
+         * Set up the Question Bank Manager.
+         *
+         * @method init
+         */
+        init: function() {
+            setButtonState(false);
+            registerListeners();
+        },
+    };
+});
index 289aaba..cddbaf7 100644 (file)
@@ -26,7 +26,9 @@ class checkbox_column extends column_base {
     protected $strselect;
 
     public function init() {
-        $this->strselect = get_string('select');
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('core/checkbox-toggleall', 'init');
     }
 
     public function get_name() {
@@ -34,21 +36,41 @@ class checkbox_column extends column_base {
     }
 
     protected function get_title() {
-        return '<input type="checkbox" disabled="disabled" id="qbheadercheckbox" />';
+        $input = \html_writer::empty_tag('input', [
+            'id' => 'qbheadercheckbox',
+            'name' => 'qbheadercheckbox',
+            'type' => 'checkbox',
+            'value' => '1',
+            'data-action' => 'toggle',
+            'data-toggle' => 'master',
+            'data-togglegroup' => 'qbank',
+            'data-toggle-selectall' => get_string('selectall', 'moodle'),
+            'data-toggle-deselectall' => get_string('deselectall', 'moodle'),
+        ]);
+
+        $label = \html_writer::tag('label', get_string('selectall', 'moodle'), [
+            'class' => 'accesshide',
+            'for' => 'qbheadercheckbox',
+        ]);
+
+        return $input . $label;
     }
 
     protected function get_title_tip() {
-        global $PAGE;
-        $PAGE->requires->strings_for_js(array('selectall', 'deselectall'), 'moodle');
-        $PAGE->requires->yui_module('moodle-question-qbankmanager', 'M.question.qbankmanager.init');
         return get_string('selectquestionsforbulk', 'question');
-
     }
 
     protected function display_content($question, $rowclasses) {
-        global $PAGE;
-        echo '<input title="' . $this->strselect . '" type="checkbox" name="q' .
-                $question->id . '" id="checkq' . $question->id . '" value="1"/>';
+        echo \html_writer::empty_tag('input', [
+            'title' => get_string('select'),
+            'type' => 'checkbox',
+            'name' => "q{$question->id}",
+            'id' => "checkq{$question->id}",
+            'value' => '1',
+            'data-action' => 'toggle',
+            'data-toggle' => 'slave',
+            'data-togglegroup' => 'qbank',
+        ]);
     }
 
     public function get_required_fields() {
index ec5b81f..9225af8 100644 (file)
@@ -688,7 +688,7 @@ class view {
     protected function display_question_list($contexts, $pageurl, $categoryandcontext,
             $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false,
             $showquestiontext = false, $addcontexts = array()) {
-        global $CFG, $DB, $OUTPUT;
+        global $CFG, $DB, $OUTPUT, $PAGE;
 
         // This function can be moderately slow with large question counts and may time out.
         // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
@@ -758,6 +758,8 @@ class view {
         }
         echo '</div>';
 
+        $PAGE->requires->js_call_amd('core_question/qbankmanager', 'init');
+
         $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts);
 
         echo '</fieldset>';
diff --git a/question/tests/behat/select_questions.feature b/question/tests/behat/select_questions.feature
new file mode 100644 (file)
index 0000000..b4b766e
--- /dev/null
@@ -0,0 +1,56 @@
+@core @core_question
+Feature: The questions in the question bank can be selected in various ways
+  In selected to do something for questions
+  As a teacher
+  I want to choose them to move, delete it.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | weeks  |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name              | user     | questiontext    |
+      | Test questions   | essay     | A question 1 name | admin    | Question 1 text |
+      | Test questions   | essay     | B question 2 name | teacher1 | Question 2 text |
+      | Test questions   | numerical | C question 3 name | teacher1 | Question 3 text |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Question bank > Questions" in current page administration
+
+  @javascript
+  Scenario: The question text can be chosen all in the list of questions
+    Given the field "Select all" matches value ""
+    When I click on "Select all" "checkbox"
+    And the field "A question 1 name" matches value "1"
+    And the field "B question 2 name" matches value "1"
+    And the field "C question 3 name" matches value "1"
+    Then I click on "Deselect all" "checkbox"
+    And the field "A question 1 name" matches value ""
+    And the field "B question 2 name" matches value ""
+    And the field "C question 3 name" matches value ""
+
+  @javascript
+  Scenario: The question text can be chosen in the list of questions
+    Given the field "Select all" matches value ""
+    When I click on "A question 1 name" "checkbox"
+    Then the field "Select all" matches value ""
+    And I click on "B question 2 name" "checkbox"
+    And I click on "C question 3 name" "checkbox"
+    And the field "Deselect all" matches value "1"
+
+  @javascript
+  Scenario: The action button can be disabled when the question not be chosen in the list of questions
+    Given the "Delete" "button" should be disabled
+    And the "Move to >>" "button" should be disabled
+    When I click on "Select all" "checkbox"
+    Then the "Delete" "button" should be enabled
+    And the "Move to >>" "button" should be enabled
index 36b929c..d806e4b 100644 (file)
@@ -95,15 +95,15 @@ class restore_qtype_essay_plugin extends restore_qtype_plugin {
         global $DB;
 
         $essayswithoutoptions = $DB->get_records_sql("
-                    SELECT *
+                    SELECT q.*
                       FROM {question} q
+                      JOIN {backup_ids_temp} bi ON bi.newitemid = q.id
+                 LEFT JOIN {qtype_essay_options} qeo ON qeo.questionid = q.id
                      WHERE q.qtype = ?
-                       AND NOT EXISTS (
-                        SELECT 1
-                          FROM {qtype_essay_options}
-                         WHERE questionid = q.id
-                     )
-                ", array('essay'));
+                       AND qeo.id IS NULL
+                       AND bi.backupid = ?
+                       AND bi.itemname = ?
+                ", array('essay', $this->get_restoreid(), 'question_created'));
 
         foreach ($essayswithoutoptions as $q) {
             $defaultoptions = new stdClass();
diff --git a/question/type/essay/tests/restore_test.php b/question/type/essay/tests/restore_test.php
new file mode 100644 (file)
index 0000000..4d2811b
--- /dev/null
@@ -0,0 +1,68 @@
+<?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/>.
+
+/**
+ * Test restore logic.
+ *
+ * @package    qtype_essay
+ * @copyright  2019 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php");
+
+/**
+ * Test restore logic.
+ *
+ * @copyright  2019 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_essay_restore_testcase extends restore_date_testcase  {
+
+    /**
+     * Test missing qtype_essay_options creation.
+     *
+     * Old backup files may contain essays with no qtype_essay_options record.
+     * During restore, we add default options for any questions like that.
+     * That is what is tested in this file.
+     */
+    public function test_restore_create_missing_qtype_essay_options() {
+        global $DB;
+
+        // Create a course with one essay question in its question bank.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $contexts = new question_edit_contexts(context_course::instance($course->id));
+        $category = question_make_default_categories($contexts->all());
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $essay = $questiongenerator->create_question('essay', null, array('category' => $category->id));
+
+        // Remove the options record, which means that the backup will look like a backup made in an old Moodle.
+        $DB->delete_records('qtype_essay_options', ['questionid' => $essay->id]);
+
+        // Do backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+
+        // Verify that the restored question has options.
+        $contexts = new question_edit_contexts(context_course::instance($newcourseid));
+        $newcategory = question_make_default_categories($contexts->all());
+        $newessay = $DB->get_record('question', ['category' => $newcategory->id, 'qtype' => 'essay']);
+        $this->assertTrue($DB->record_exists('qtype_essay_options', ['questionid' => $newessay->id]));
+    }
+}
index 76d459d..9fd591a 100644 (file)
@@ -71,17 +71,9 @@ class qtype_gapselect_renderer extends qtype_elements_embedded_in_question_text_
             }
         }
 
-        // If the text is short use non-breaking space.
-        $choose = '&nbsp;';
-        foreach ($selectoptions as $key => $text) {
-            if (strlen(get_string('choosedots')) / 2 <= strlen($text)) {
-                $choose = get_string('choosedots');
-                break;
-            }
-        }
-
+        // Use non-breaking space instead of 'Choose...'.
         $selecthtml = html_writer::select($selectoptions, $qa->get_qt_field_name($fieldname),
-                $value, $choose, $attributes) . ' ' . $feedbackimage;
+                        $value, '&nbsp;', $attributes) . ' ' . $feedbackimage;
         return html_writer::tag('span', $selecthtml, array('class' => 'control '.$groupclass));
     }
 
index 5e21fef..ba9ea8e 100644 (file)
@@ -60,9 +60,9 @@ class qtype_gapselect_walkthrough_test extends qbehaviour_walkthrough_test_base
         // Also note the ' ' in the p2 example below is a nbsp (used when names are short).
         $this->check_output_contains_selectoptions(
                 $this->get_contains_select_expectation('p1',
-                        ['' => get_string('choosedots'), '1' => 'quick', '2' => 'slow'], null, true),
+                        ['0' => '&nbsp;', '1' => 'quick', '2' => 'slow'], null, true),
                 $this->get_contains_select_expectation('p2',
-                        ['' => ' ', '1' => 'fox', '2' => 'dog'], null, true),
+                        ['0' => '&nbsp;', '1' => 'fox', '2' => 'dog'], null, true),
                 $this->get_contains_select_expectation('p3',
                         ['1' => 'lazy', '2' => 'assiduous'], null, true));
 
diff --git a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-debug.js b/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-debug.js
deleted file mode 100644 (file)
index fbe45fe..0000000
Binary files a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-debug.js and /dev/null differ
diff --git a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-min.js b/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-min.js
deleted file mode 100644 (file)
index 22b2432..0000000
Binary files a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-min.js and /dev/null differ
diff --git a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager.js b/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager.js
deleted file mode 100644 (file)
index fbe45fe..0000000
Binary files a/question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager.js and /dev/null differ
diff --git a/question/yui/src/qbankmanager/build.json b/question/yui/src/qbankmanager/build.json
deleted file mode 100644 (file)
index 2c8d36e..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-    "name": "moodle-question-qbankmanager",
-    "builds": {
-        "moodle-question-qbankmanager": {
-            "jsfiles": [
-                "qbankmanager.js"
-            ]
-        }
-    }
-}
diff --git a/question/yui/src/qbankmanager/js/qbankmanager.js b/question/yui/src/qbankmanager/js/qbankmanager.js
deleted file mode 100644 (file)
index cb6ea38..0000000
+++ /dev/null
@@ -1,137 +0,0 @@
-// 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/>.
-
-/*
- * Question Bank Management.
- *
- * @package    question
- * @copyright  2014 Andrew Nicols
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/**
- * Questionbank Management.
- *
- * @module moodle-question-qbankmanager
- */
-
-/**
- * Question Bank Management.
- *
- * @class M.question.qbankmanager
- */
-
-var manager = {
-    /**
-     * A reference to the header checkbox.
-     *
-     * @property _header
-     * @type Node
-     * @private
-     */
-    _header: null,
-
-    /**
-     * A reference to the add to quiz button.
-     *
-     * @property _addbutton
-     * @type Node
-     * @private
-     */
-    _addbutton: null,
-
-    /**
-     * The ID of the first checkbox on the page.
-     *
-     * @property _firstCheckbox
-     * @type Node
-     * @private
-     */
-    _firstCheckbox: null,
-
-    /**
-     * Set up the Question Bank Manager.
-     *
-     * @method init
-     */
-    init: function() {
-        // Find the header checkbox, and set the initial values.
-        this._header = Y.one('#qbheadercheckbox');
-        if (!this._header) {
-            return;
-        }
-        this._header.setAttrs({
-            disabled: false,
-            title: M.util.get_string('selectall', 'moodle')
-        });
-
-        this._header.on('click', this._headerClick, this);
-
-        this._addbutton = Y.one('.modulespecificbuttonscontainer input[name="add"]');
-        // input[name="add"] is not always available.
-        if (this._addbutton) {
-            this._addbutton.setAttrs({
-                disabled: true
-            });
-
-            this._header.on('click', this._questionClick, this);
-            Y.one('.categoryquestionscontainer').delegate('change', this._questionClick,
-                'td.checkbox input[type="checkbox"]', this);
-        }
-
-        // Store the first checkbox details.
-        var table = this._header.ancestor('table');
-        this._firstCheckbox = table.one('tbody tr td.checkbox input');
-    },
-
-    /**
-     * Handle toggling of the header checkbox.
-     *
-     * @method _headerClick
-     * @private
-     */
-    _headerClick: function() {
-        // Get the list of questions we affect.
-        var categoryQuestions = Y.one('#categoryquestions')
-                .all('[type=checkbox],[type=radio]');
-
-        // We base the state of all of the questions on the state of the first.
-        if (this._firstCheckbox.get('checked')) {
-            categoryQuestions.set('checked', false);
-            this._header.setAttribute('title', M.util.get_string('selectall', 'moodle'));
-        } else {
-            categoryQuestions.set('checked', true);
-            this._header.setAttribute('title', M.util.get_string('deselectall', 'moodle'));
-        }
-
-        this._header.set('checked', false);
-    },
-
-    /**
-     * Handle toggling of a question checkbox.
-     *
-     * @method _questionClick
-     * @private
-     */
-    _questionClick: function() {
-        var areChecked = Y.all('td.checkbox input[type="checkbox"]:checked').size();
-        this._addbutton.setAttrs({
-            disabled: (areChecked === 0)
-        });
-    }
-};
-
-M.question = M.question || {};
-M.question.qbankmanager = M.question.qbankmanager || manager;
diff --git a/question/yui/src/qbankmanager/meta/qbankmanager.json b/question/yui/src/qbankmanager/meta/qbankmanager.json
deleted file mode 100644 (file)
index 6410cbc..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-{
-    "moodle-question-qbankmanager": {
-        "requires": [
-            "node",
-            "selector-css3"
-        ]
-    }
-}
index f05413e..1a918e9 100644 (file)
@@ -535,7 +535,7 @@ class core_renderer extends \core_renderer {
                 if ($skipped) {
                     $text = get_string('morenavigationlinks');
                     $url = new moodle_url('/course/admin.php', array('courseid' => $this->page->course->id));
-                    $link = new action_link($url, $text, null, null, new pix_icon('t/edit', $text));
+                    $link = new action_link($url, $text, null, null, new pix_icon('t/edit', ''));
                     $menu->add_secondary_action($link);
                 }
             }
@@ -549,7 +549,7 @@ class core_renderer extends \core_renderer {
                 if ($skipped) {
                     $text = get_string('morenavigationlinks');
                     $url = new moodle_url('/course/admin.php', array('courseid' => $this->page->course->id));
-                    $link = new action_link($url, $text, null, null, new pix_icon('t/edit', $text));
+                    $link = new action_link($url, $text, null, null, new pix_icon('t/edit', ''));
                     $menu->add_secondary_action($link);
                 }
             }
index d942fcb..b44726f 100644 (file)
@@ -51,6 +51,8 @@ $templatecontext = [
     'hasregionmainsettingsmenu' => !empty($regionmainsettingsmenu)
 ];
 
-$templatecontext['flatnavigation'] = $PAGE->flatnav;
+$nav = $PAGE->flatnav;
+$templatecontext['flatnavigation'] = $nav;
+$templatecontext['firstcollectionlabel'] = $nav->get_collectionlabel();
 echo $OUTPUT->render_from_template('theme_boost/columns2', $templatecontext);
 
index 469e034..ad9e889 100644 (file)
@@ -169,6 +169,11 @@ div.backup-section + form {
     direction: ltr;
 }
 
+// Reduce the mediaplugin width when using inside forms.
+.que.match .mediaplugin {
+    width: 50vw;
+}
+
 /* rtl:ignore */
 #page-admin-grade-edit-scale-edit .error input#id_name {
     margin-right: 170px;
index a083d60..672a8c7 100644 (file)
@@ -15119,6 +15119,9 @@ div.backup-section + form {
   text-align: left;
   direction: ltr; }
 
+.que.match .mediaplugin {
+  width: 50vw; }
+
 /* rtl:ignore */
 #page-admin-grade-edit-scale-edit .error input#id_name {
   margin-right: 170px; }
index 022df60..49ed3ba 100644 (file)
@@ -47,7 +47,7 @@
     <div id="page" class="container-fluid">
         <div id="page-content" class="row pb-3">
             <div id="region-main-box" class="col-12">
-                <section id="region-main">
+                <section id="region-main" aria-label="{{#str}}content{{/str}}">
                     {{{ output.course_content_header }}}
                     {{{ output.main_content }}}
                     {{{ output.activity_navigation }}}
index 7b329cb..d0425d3 100644 (file)
@@ -68,7 +68,7 @@
                     <div> {{{ output.region_main_settings_menu }}} </div>
                 </div>
                 {{/hasregionmainsettingsmenu}}
-                <section id="region-main" {{#hasblocks}}class="has-blocks mb-3"{{/hasblocks}}>
+                <section id="region-main" {{#hasblocks}}class="has-blocks mb-3"{{/hasblocks}} aria-label="{{#str}}content{{/str}}">
 
                     {{#hasregionmainsettingsmenu}}
                         <div class="region_main_settings_menu_proxy"></div>
@@ -80,7 +80,7 @@
 
                 </section>
                 {{#hasblocks}}
-                <section data-region="blocks-column" class="d-print-none">
+                <section data-region="blocks-column" class="d-print-none" aria-label="{{#str}}blocks{{/str}}">
                     {{{ sidepreblocks }}}
                 </section>
                 {{/hasblocks}}
index ed8881e..a52440b 100644 (file)
@@ -21,5 +21,5 @@
     data-container="body" data-toggle="popover"
     data-placement="{{#ltr}}right{{/ltr}}{{^ltr}}left{{/ltr}}" data-content="{{text}} {{completedoclink}}"
     data-html="true" tabindex="0" data-trigger="focus">
-  {{#pix}}help, core, {{alt}}{{/pix}}
+  {{#pix}}help, core, {{{alt}}}{{/pix}}
 </a>
index 5450089..b0cdaa0 100644 (file)
                             <input type="text" name="username" id="username"
                                 class="form-control"
                                 value="{{username}}"
-                                placeholder={{#quote}}{{^canloginbyemail}}{{#str}}username{{/str}}{{/canloginbyemail}}{{#canloginbyemail}}{{#str}}usernameemail{{/str}}{{/canloginbyemail}}{{/quote}}>
+                                placeholder={{#quote}}{{^canloginbyemail}}{{#str}}username{{/str}}{{/canloginbyemail}}{{#canloginbyemail}}{{#str}}usernameemail{{/str}}{{/canloginbyemail}}{{/quote}}
+                                autocomplete="username">
                         </div>
                         <div class="form-group">
                             <label for="password" class="sr-only">{{#str}} password {{/str}}</label>
                             <input type="password" name="password" id="password" value=""
                                 class="form-control"
-                                placeholder={{#quote}}{{#str}}password{{/str}}{{/quote}}>
+                                placeholder={{#quote}}{{#str}}password{{/str}}{{/quote}}
+                                autocomplete="current-password">
                         </div>
                         {{#rememberusername}}
                             <div class="rememberpass mt-3">
index 07d0c04..207081f 100644 (file)
@@ -63,7 +63,7 @@
         ]
     }
 }}
-<nav role="navigation">
+<nav role="navigation" aria-label="{{#str}}breadcrumb, access{{/str}}">
     <ol class="breadcrumb">
         {{#get_items}}
             {{#has_action}}
index b2532fb..09f779f 100644 (file)
@@ -9,7 +9,7 @@
                 size="{{element.size}}"
                 {{#error}}
                     autofocus aria-describedby="id_error_{{element.name}}"
-                {{/error}} {{{attributes}}}>
+                {{/error}} {{{element.attributes}}}>
         {{/element.frozen}}
     {{/element}}
 {{/ core_form/element-template }}
index f19e907..3c9d44a 100644 (file)
         ]
     }
 }}
-<nav class="list-group">
+<nav class="list-group" aria-label="{{firstcollectionlabel}}">
 {{# flatnavigation }}
     {{#showdivider}}
 </nav>
-<nav class="list-group m-t-1">
+<nav class="list-group m-t-1" aria-label="{{get_collectionlabel}}">
     {{/showdivider}}
     {{#action}}
     <a class="list-group-item list-group-item-action {{#isactive}}active{{/isactive}}" href="{{{action}}}" data-key="{{key}}" data-isexpandable="{{isexpandable}}" data-indent="{{get_indent}}" data-showdivider="{{showdivider}}" data-type="{{type}}" data-nodetype="{{nodetype}}" data-collapse="{{collapse}}" data-forceopen="{{forceopen}}" data-isactive="{{isactive}}" data-hidden="{{hidden}}" data-preceedwithhr="{{preceedwithhr}}" {{#parent.key}}data-parent-key="{{.}}"{{/parent.key}}>
index 4cb4c34..9827bbd 100644 (file)
@@ -28,7 +28,7 @@
         {{{ output.login_info }}}
         <div class="tool_usertours-resettourcontainer"></div>
         {{{ output.home_link }}}
-        <nav class="nav navbar-nav d-md-none">
+        <nav class="nav navbar-nav d-md-none" aria-label="{{#str}}custommenu, admin{{/str}}">
             {{# output.custom_menu_flat }}
                 <ul class="list-unstyled pt-3">
                     {{> theme_boost/custom_menu_footer }}
@@ -38,4 +38,4 @@
         {{{ output.standard_footer_html }}}
         {{{ output.standard_end_of_body_html }}}
     </div>
-</footer>
\ No newline at end of file
+</footer>
index ebca109..f44017d 100644 (file)
@@ -40,7 +40,7 @@
     <div id="page" class="container-fluid mt-0">
         <div id="page-content" class="row">
             <div id="region-main-box" class="col-12">
-                <section id="region-main" class="col-12">
+                <section id="region-main" class="col-12" aria-label="{{#str}}content{{/str}}">
                     {{{ output.course_content_header }}}
                     {{{ output.main_content }}}
                     {{{ output.course_content_footer }}}
index f62c6c8..0330892 100644 (file)
@@ -56,7 +56,7 @@
         </div>
 
         <div id="page-content" class="row">
-            <section id="region-main" class="col-12">
+            <section id="region-main" class="col-12" aria-label="{{#str}}content{{/str}}">
                 {{{ output.main_content }}}
             </section>
         </div>
index dbbca33..8dadab0 100644 (file)
@@ -17,7 +17,7 @@
 {{!
     secure navbar.
 }}
-<nav class="fixed-top navbar navbar-light bg-white navbar-expand moodle-has-zindex">
+<nav class="fixed-top navbar navbar-light bg-white navbar-expand moodle-has-zindex" aria-label="{{#str}}navigation{{/str}}">
 
         <a href="{{{ config.wwwroot }}}" class="navbar-brand {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
             {{^ output.should_display_navbar_logo }}
@@ -37,4 +37,4 @@
             {{{ output.secure_login_info }}}
             </li>
         </ul>
-</nav>
\ No newline at end of file
+</nav>
index 385e3ab..a6ceaba 100644 (file)
@@ -59,7 +59,7 @@
 
         <div id="page-content" class="row">
             <div id="region-main-box" class="col-12">
-                <section id="region-main" {{#hasblocks}}class="has-blocks"{{/hasblocks}}>
+                <section id="region-main" {{#hasblocks}}class="has-blocks"{{/hasblocks}} aria-label="{{#str}}content{{/str}}">
 
                     {{{ output.course_content_header }}}
                     {{{ output.main_content }}}
@@ -67,7 +67,7 @@
 
                 </section>
                 {{#hasblocks}}
-                <section data-region="blocks-column">
+                <section data-region="blocks-column" aria-label="{{#str}}blocks{{/str}}">
                     {{{ sidepreblocks }}}
                 </section>
                 {{/hasblocks}}
index b6b2bc6..fbca741 100644 (file)
@@ -48,17 +48,23 @@ form {
     display: none;
 }
 
+// MDL-63512 Override to handle issues in clean where the video styling is off.
 .mform .fitem .fitemtitle {
-    div {
+    & > div:not(.mediaplugin) div {
         display: inline;
     }
 
-    // MDL-63512 Override to handle issues in clean where the video styling is off.
-    .mediaplugin,
-    .mediaplugin div {
-        display: block;
+    // MDL-64450 Override for videos so they don't appear off the screen.
+    .mediaplugin > div {
+        margin: 0;
     }
 }
+
+// Reduce the mediaplugin width when using inside forms.
+.que.match .mediaplugin {
+    width: 50vw;
+}
+
 #adminsettings .error,
 .loginpanel .error,
 .mform .error {
index d3e0cf4..7b59b14 100644 (file)
@@ -16591,12 +16591,14 @@ form {
 .jsenabled .mform .collapsed .fcontainer {
   display: none;
 }
-.mform .fitem .fitemtitle div {
+.mform .fitem .fitemtitle > div:not(.mediaplugin) div {
   display: inline;
 }
-.mform .fitem .fitemtitle .mediaplugin,
-.mform .fitem .fitemtitle .mediaplugin div {
-  display: block;
+.mform .fitem .fitemtitle .mediaplugin > div {
+  margin: 0;
+}
+.que.match .mediaplugin {
+  width: 50vw;
 }
 #adminsettings .error,
 .loginpanel .error,
index fe2fd31..31dd330 100644 (file)
@@ -27,6 +27,7 @@ if (!defined('MOODLE_INTERNAL')) {
 }
 
 require_once($CFG->dirroot.'/lib/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 /**
  * Class user_editadvanced_form.
@@ -96,7 +97,8 @@ class user_editadvanced_form extends moodleform {
             }
         }
 
-        $mform->addElement('text', 'username', get_string('username'), 'size="20"');
+        $purpose = user_edit_map_field_purpose($userid, 'username');
+        $mform->addElement('text', 'username', get_string('username'), 'size="20"' . $purpose);
         $mform->addHelpButton('username', 'username', 'auth');
         $mform->setType('username', PARAM_RAW);
 
@@ -116,7 +118,9 @@ class user_editadvanced_form extends moodleform {
         if (!empty($CFG->passwordpolicy)) {
             $mform->addElement('static', 'passwordpolicyinfo', '', print_password_policy());
         }
-        $mform->addElement('passwordunmask', 'newpassword', get_string('newpassword'), 'size="20"');
+
+        $purpose = user_edit_map_field_purpose($userid, 'password');
+        $mform->addElement('passwordunmask', 'newpassword', get_string('newpassword'), 'size="20"' . $purpose);
         $mform->addHelpButton('newpassword', 'newpassword');
         $mform->setType('newpassword', core_user::get_property_type('password'));
         $mform->disabledIf('newpassword', 'createpassword', 'checked');
index b071e25..c8d2f68 100644 (file)
@@ -22,6 +22,8 @@
  * @package core_user
  */
 
+require_once($CFG->dirroot . '/user/lib.php');
+
 /**
  * Cancels the requirement for a user to update their email address.
  *
@@ -258,7 +260,8 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
 
     // Add the necessary names.
     foreach (useredit_get_required_name_fields() as $fullname) {
-        $mform->addElement('text', $fullname,  get_string($fullname),  'maxlength="100" size="30"');
+        $purpose = user_edit_map_field_purpose($user->id, $fullname);
+        $mform->addElement('text', $fullname,  get_string($fullname),  'maxlength="100" size="30"' . $purpose);
         if ($stringman->string_exists('missing'.$fullname, 'core')) {
             $strmissingfield = get_string('missing'.$fullname, 'core');
         } else {
@@ -271,7 +274,8 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
     $enabledusernamefields = useredit_get_enabled_name_fields();
     // Add the enabled additional name fields.
     foreach ($enabledusernamefields as $addname) {
-        $mform->addElement('text', $addname,  get_string($addname), 'maxlength="100" size="30"');
+        $purpose = user_edit_map_field_purpose($user->id, $addname);
+        $mform->addElement('text', $addname,  get_string($addname), 'maxlength="100" size="30"' . $purpose);
         $mform->setType($addname, PARAM_NOTAGS);
     }
 
@@ -282,7 +286,8 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
                 . get_string('emailchangecancel', 'auth') . '</a>';
         $mform->addElement('static', 'emailpending', get_string('email'), $notice);
     } else {
-        $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"');
+        $purpose = user_edit_map_field_purpose($user->id, 'email');
+        $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"' . $purpose);
         $mform->addRule('email', $strrequired, 'required', null, 'client');
         $mform->setType('email', PARAM_RAW_TRIMMED);
     }
@@ -301,9 +306,10 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
         $mform->setDefault('city', $CFG->defaultcity);
     }
 
+    $purpose = user_edit_map_field_purpose($user->id, 'country');
     $choices = get_string_manager()->get_list_of_countries();
     $choices = array('' => get_string('selectacountry') . '...') + $choices;
-    $mform->addElement('select', 'country', get_string('selectacountry'), $choices);
+    $mform->addElement('select', 'country', get_string('selectacountry'), $choices, $purpose);
     if (!empty($CFG->country)) {
         $mform->setDefault('country', core_user::get_property_default('country'));
     }
@@ -319,7 +325,9 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
     }
 
     if ($user->id < 0) {
-        $mform->addElement('select', 'lang', get_string('preferredlanguage'), get_string_manager()->get_list_of_translations());
+        $purpose = user_edit_map_field_purpose($user->id, 'lang');
+        $translations = get_string_manager()->get_list_of_translations();
+        $mform->addElement('select', 'lang', get_string('preferredlanguage'), $translations, $purpose);
         $lang = empty($user->lang) ? $CFG->lang : $user->lang;
         $mform->setDefault('lang', $lang);
     }
@@ -366,7 +374,8 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions
     if (count($disabledusernamefields) > 0) {
         $mform->addElement('header', 'moodle_additional_names', get_string('additionalnames'));
         foreach ($disabledusernamefields as $allname) {
-            $mform->addElement('text', $allname, get_string($allname), 'maxlength="100" size="30"');
+            $purpose = user_edit_map_field_purpose($user->id, $allname);
+            $mform->addElement('text', $allname, get_string($allname), 'maxlength="100" size="30"' . $purpose);
             $mform->setType($allname, PARAM_NOTAGS);
         }
     }
index 4dab6be..50d0911 100644 (file)
@@ -27,6 +27,7 @@ if (!defined('MOODLE_INTERNAL')) {
 }
 
 require_once($CFG->dirroot.'/lib/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 /**
  * Class user_edit_form.
@@ -57,7 +58,9 @@ class user_edit_language_form extends moodleform {
         $mform->addElement('hidden', 'course', $COURSE->id);
         $mform->setType('course', PARAM_INT);
 
-        $mform->addElement('select', 'lang', get_string('preferredlanguage'), get_string_manager()->get_list_of_translations());
+        $purpose = user_edit_map_field_purpose($userid, 'lang');
+        $translations = get_string_manager()->get_list_of_translations();
+        $mform->addElement('select', 'lang', get_string('preferredlanguage'), $translations, $purpose);
         $mform->setDefault('lang', core_user::get_property_default('lang'));
 
         $this->add_action_buttons(true, get_string('savechanges'));
index c8b5ccc..967f925 100644 (file)
@@ -1573,3 +1573,39 @@ function core_user_inplace_editable($itemtype, $itemid, $newvalue) {
         return \core_user\output\user_roles_editable::update($itemid, $newvalue);
     }
 }
+
+/**
+ * Map an internal field name to a valid purpose from: "https://www.w3.org/TR/WCAG21/#input-purposes"
+ *
+ * @param integer $userid
+ * @param string $fieldname
+ * @return string $purpose (empty string if there is no mapping).
+ */
+function user_edit_map_field_purpose($userid, $fieldname) {
+    global $USER;
+
+    $currentuser = ($userid == $USER->id) && !\core\session\manager::is_loggedinas();
+    // These are the fields considered valid to map and auto fill from a browser.
+    // We do not include fields that are in a collapsed section by default because
+    // the browser could auto-fill the field and cause a new value to be saved when
+    // that field was never visible.
+    $validmappings = array(
+        'username' => 'username',
+        'password' => 'current-password',
+        'firstname' => 'given-name',
+        'lastname' => 'family-name',
+        'middlename' => 'additional-name',
+        'email' => 'email',
+        'country' => 'country',
+        'lang' => 'language'
+    );
+
+    $purpose = '';
+    // Only set a purpose when editing your own user details.
+    if ($currentuser && isset($validmappings[$fieldname])) {
+        $purpose = ' autocomplete="' . $validmappings[$fieldname] . '" ';
+    }
+
+    return $purpose;
+}
+
index 36339e6..a3f4bba 100644 (file)
@@ -27,6 +27,8 @@
 
 require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
 
+use Behat\Mink\Exception\ExpectationException as ExpectationException;
+
 /**
  * Steps definitions for users.
  *
@@ -53,4 +55,37 @@ class behat_user extends behat_base {
         $this->execute("behat_general::i_click_on", array("//select[@id='formactionid']" .
                                                           "/option[contains(., " . $nodetext . ")]", "xpath_element"));
     }
+
+    /**
+     * The input field should have autocomplete set to this value.
+     *
+     * @Then /^the field "(?P<field_string>(?:[^"]|\\")*)" should have purpose "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     * @param string $field The field to select.
+     * @param string $purpose The expected purpose.
+     */
+    public function the_field_should_have_purpose($field, $purpose) {
+        $fld = behat_field_manager::get_form_field_from_label($field, $this);
+
+        $value = $fld->get_attribute('autocomplete');
+        if ($value != $purpose) {
+            $reason = 'The "' . $field . '" field does not have purpose "' . $purpose . '"';
+            throw new ExpectationException($reason, $this->getSession());
+        }
+    }
+
+    /**
+     * The input field should not have autocomplete set to this value.
+     *
+     * @Then /^the field "(?P<field_string>(?:[^"]|\\")*)" should not have purpose "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     * @param string $field The field to select.
+     * @param string $purpose The expected purpose we do not want.
+     */
+    public function the_field_should_not_have_purpose($field, $purpose) {
+        $fld = behat_field_manager::get_form_field_from_label($field, $this);
+
+        $value = $fld->get_attribute('autocomplete');
+        if ($value == $purpose) {
+            throw new ExpectationException('The "' . $field . '" field does have purpose "' . $purpose . '"', $this->getSession());
+        }
+    }
 }
diff --git a/user/tests/behat/input-purpose.feature b/user/tests/behat/input-purpose.feature
new file mode 100644 (file)
index 0000000..f466f65
--- /dev/null
@@ -0,0 +1,43 @@
+@core @core_user
+Feature: The purpose of each input field collecting information about the user can be determined
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname          | lastname | email                           |
+      | unicorn  | unicorn | 1        | unicorn@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "course enrolments" exist:
+      | user                | course | role    |
+      | unicorn             | C1     | student |
+
+  @javascript
+  Scenario: Fields for other users are not auto filled
+    When I log in as "admin"
+    And I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I click on ".icon[title=Edit]" "css_element" in the "unicorn@example.com" "table_row"
+    And I expand all fieldsets
+    Then the field "Username" should not have purpose "username"
+    And the field "First name" should not have purpose "given-name"
+    And the field "Surname" should not have purpose "family-name"
+    And the field "Email" should not have purpose "email"
+    And the field "Select a country" should not have purpose "country"
+    And I press "Cancel"
+    And I follow "Preferred language"
+    And the field "Preferred language" should not have purpose "language"
+
+  @javascript
+  Scenario: My own user fields are auto filled
+    When I log in as "unicorn"
+    And I follow "Profile" in the user menu
+    And I click on "Edit profile" "link" in the "region-main" "region"
+    And I expand all fieldsets
+    Then the field "First name" should have purpose "given-name"
+    And the field "Surname" should have purpose "family-name"
+    And the field "Email" should have purpose "email"
+    And the field "Select a country" should have purpose "country"
+    And I press "Cancel"
+    And I follow "Preferences" in the user menu
+    And I follow "Preferred language"
+    And the field "Preferred language" should have purpose "language"
index e978fad..34c3163 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2019021500.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2019021500.02;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.