Merge branch 'MDL-66915-master' of https://github.com/MartinGauk/moodle
authorJake Dallimore <jake@moodle.com>
Mon, 21 Oct 2019 07:54:12 +0000 (15:54 +0800)
committerJake Dallimore <jake@moodle.com>
Mon, 21 Oct 2019 07:54:12 +0000 (15:54 +0800)
243 files changed:
admin/settings/courses.php
admin/tool/dataprivacy/amd/build/expand_contract.min.js
admin/tool/dataprivacy/amd/build/expand_contract.min.js.map
admin/tool/dataprivacy/amd/src/expand_contract.js
admin/tool/uploaduser/example.csv [new file with mode: 0644]
admin/tool/uploaduser/lang/en/tool_uploaduser.php
admin/tool/uploaduser/user_form.php
auth/tests/behat/behat_auth.php
badges/upgradelib.php [new file with mode: 0644]
blocks/starredcourses/classes/external.php
blocks/timeline/templates/event-list-content.mustache
blog/classes/privacy/provider.php
blog/tests/privacy_test.php
config-dist.php
course/classes/analytics/target/course_enrolments.php
course/classes/category.php
course/classes/management_renderer.php
course/externallib.php
course/lib.php
course/pending.php
course/request.php
course/request_form.php
course/tests/behat/course_request.feature [new file with mode: 0644]
course/tests/courselib_test.php
course/tests/courserequest_test.php
course/tests/targets_test.php
lang/en/admin.php
lang/en/deprecated.txt
lang/en/message.php
lang/en/question.php
lib/badgeslib.php
lib/behat/behat_base.php
lib/behat/classes/behat_context_helper.php
lib/behat/classes/behat_selectors.php
lib/behat/classes/component_named_replacement.php [new file with mode: 0644]
lib/behat/classes/component_named_selector.php [new file with mode: 0644]
lib/behat/classes/exact_named_selector.php
lib/behat/classes/named_selector.php [new file with mode: 0644]
lib/behat/classes/partial_named_selector.php
lib/behat/classes/util.php
lib/classes/output/checkbox_toggleall.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/useragent.php
lib/db/access.php
lib/db/install.php
lib/db/upgrade.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/filestorage/file_system.php
lib/filestorage/stored_file.php
lib/form/dateselector.php
lib/form/datetimeselector.php
lib/formslib.php
lib/moodlelib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/questionlib.php
lib/tablelib.php
lib/templates/checkbox-toggleall-master-button.mustache
lib/templates/checkbox-toggleall-master.mustache
lib/templates/checkbox-toggleall-slave.mustache
lib/tests/behat/behat_general.php
lib/tests/behat/behat_hooks.php
lib/tests/behat/behat_navigation.php
lib/tests/core_media_player_native.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player_native.php [new file with mode: 0644]
lib/tests/medialib_test.php
lib/tests/tablelib_test.php
lib/upgrade.txt
login/change_password.php
login/change_password_form.php
login/forgot_password_form.php
login/index.php
login/lib.php
login/set_password_form.php
login/signup.php
login/signup_form.php
media/player/html5audio/tests/player_test.php
media/player/html5video/tests/player_test.php
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
message/templates/message_drawer_conversations_list.mustache
message/tests/behat/behat_message.php
message/tests/behat/delete_messages.feature
message/tests/behat/favourite_conversations.feature
message/tests/behat/group_conversation.feature
message/tests/behat/message_delete_conversation.feature
message/tests/behat/message_send_messages.feature
message/tests/behat/mute_conversations.feature
message/tests/behat/self_conversation.feature
message/tests/behat/unread_messages.feature
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/submission/file/locallib.php
mod/assign/submissionplugin.php
mod/assign/upgrade.txt
mod/choice/renderer.php
mod/data/import.php
mod/data/lib.php
mod/data/tests/fixtures/test_data_import.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_field_username.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_userdata.csv [new file with mode: 0644]
mod/data/tests/import_test.php [new file with mode: 0644]
mod/data/view.php
mod/feedback/show_nonrespondents.php
mod/forum/backup/moodle2/restore_forum_stepslib.php
mod/forum/classes/local/data_mappers/legacy/post.php
mod/forum/classes/local/entities/post.php
mod/forum/classes/local/exporters/post.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/task/refresh_forum_post_counts.php [new file with mode: 0644]
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/lib.php
mod/forum/report/summary/classes/output/filters.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/forum/report/summary/lang/en/forumreport_summary.php
mod/forum/report/summary/tests/behat/private_replies.feature [new file with mode: 0644]
mod/forum/tests/entities_discussion_summary_test.php
mod/forum/tests/entities_discussion_test.php
mod/forum/tests/entities_post_read_receipt_collection_test.php
mod/forum/tests/entities_post_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/generator/lib.php
mod/forum/upgrade.txt
mod/forum/version.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/classes/question/bank/custom_view.php
mod/quiz/classes/question/bank/question_name_text_column.php
mod/quiz/locallib.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/overview/report.php
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/responses/report.php
mod/quiz/report/upgrade.txt
mod/quiz/styles.css
mod/quiz/tests/behat/add_quiz.feature
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/attempt_begin.feature
mod/quiz/tests/behat/attempt_redo_questions.feature
mod/quiz/tests/behat/attempt_require_previous.feature
mod/quiz/tests/behat/backup.feature
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/editing_add.feature
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/behat/editing_add_random.feature
mod/quiz/tests/behat/editing_move_by_click.feature
mod/quiz/tests/behat/editing_remove_multiple_questions.feature
mod/quiz/tests/behat/editing_remove_question.feature
mod/quiz/tests/behat/editing_repaginate.feature
mod/quiz/tests/behat/editing_require_previous.feature
mod/quiz/tests/behat/editing_section_headings.feature
mod/quiz/tests/behat/editing_set_marks_no_attempts.feature
mod/quiz/tests/behat/editing_set_marks_with_attempts.feature
mod/quiz/tests/behat/manually_mark_question.feature
mod/quiz/tests/behat/preview.feature
mod/quiz/tests/behat/quiz_group_override.feature
mod/quiz/tests/behat/quiz_no_calendar_capabilities.feature
mod/quiz/tests/behat/quiz_reset.feature
phpunit.xml.dist
pix/i/messagecontentaudio.png [new file with mode: 0644]
pix/i/messagecontentaudio.svg [new file with mode: 0644]
pix/i/messagecontentimage.png [new file with mode: 0644]
pix/i/messagecontentimage.svg [new file with mode: 0644]
pix/i/messagecontentmultimediageneral.png [new file with mode: 0644]
pix/i/messagecontentmultimediageneral.svg [new file with mode: 0644]
pix/i/messagecontentvideo.png [new file with mode: 0644]
pix/i/messagecontentvideo.svg [new file with mode: 0644]
question/category_class.php
question/classes/bank/action_column_base.php
question/classes/bank/checkbox_column.php
question/classes/bank/column_base.php
question/classes/bank/copy_action_column.php
question/classes/bank/creator_name_column.php
question/classes/bank/delete_action_column.php
question/classes/bank/edit_action_column.php
question/classes/bank/edit_menu_column.php [new file with mode: 0644]
question/classes/bank/export_xml_action_column.php [new file with mode: 0644]
question/classes/bank/menu_action_column_base.php [new file with mode: 0644]
question/classes/bank/menuable_action.php [new file with mode: 0644]
question/classes/bank/modifier_name_column.php
question/classes/bank/preview_action_column.php
question/classes/bank/question_name_column.php
question/classes/bank/question_name_idnumber_tags_column.php [new file with mode: 0644]
question/classes/bank/question_text_row.php
question/classes/bank/question_type_column.php
question/classes/bank/row_base.php
question/classes/bank/tags_action_column.php
question/classes/bank/view.php
question/tests/behat/behat_question.php
question/tests/behat/copy_questions.feature
question/tests/behat/delete_questions.feature
question/tests/behat/edit_questions.feature
question/tests/behat/filter_questions_by_tag.feature
question/tests/behat/preview_question.feature
question/tests/behat/question_categories.feature
question/tests/behat/question_categories_idnumber.feature
question/tests/behat/sort_questions.feature
question/tests/category_class_test.php [new file with mode: 0644]
question/type/ddimageortext/tests/behat/backup_and_restore.feature
question/type/ddimageortext/tests/behat/edit.feature
question/type/ddimageortext/tests/behat/preview.feature
question/type/ddmarker/tests/behat/backup_and_restore.feature
question/type/ddmarker/tests/behat/edit.feature
question/type/ddmarker/tests/behat/preview.feature
question/type/ddwtos/tests/behat/backup_and_restore.feature
question/type/ddwtos/tests/behat/edit.feature
question/type/ddwtos/tests/behat/preview.feature
question/type/description/tests/behat/backup_and_restore.feature
question/type/description/tests/behat/edit.feature
question/type/description/tests/behat/preview.feature
question/type/essay/tests/behat/backup_and_restore.feature
question/type/essay/tests/behat/edit.feature
question/type/essay/tests/behat/preview.feature
question/type/gapselect/tests/behat/basic_test.feature
question/type/match/tests/behat/backup_and_restore.feature
question/type/match/tests/behat/edit.feature
question/type/match/tests/behat/preview.feature
question/type/multichoice/tests/behat/backup_and_restore.feature
question/type/multichoice/tests/behat/edit.feature
question/type/multichoice/tests/behat/preview.feature
question/type/numerical/tests/behat/backup_and_restore.feature
question/type/numerical/tests/behat/edit.feature
question/type/numerical/tests/behat/preview.feature
question/type/shortanswer/tests/behat/backup_and_restore.feature
question/type/shortanswer/tests/behat/edit.feature
question/type/shortanswer/tests/behat/preview.feature
question/type/truefalse/tests/behat/backup_and_restore.feature
question/type/truefalse/tests/behat/edit.feature
question/type/truefalse/tests/behat/preview.feature
question/type/upgrade.txt
question/upgrade.txt
tag/classes/output/taglist.php
tag/classes/tag.php
tag/templates/taglist.mustache
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index 2525f22..5989158 100644 (file)
@@ -163,9 +163,15 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
-    $temp->add(new admin_setting_configcheckbox('enablecourserequests', new lang_string('enablecourserequests', 'admin'), new lang_string('configenablecourserequests', 'admin'), 0));
-    $temp->add(new admin_settings_coursecat_select('defaultrequestcategory', new lang_string('defaultrequestcategory', 'admin'), new lang_string('configdefaultrequestcategory', 'admin'), 1));
-    $temp->add(new admin_setting_configcheckbox('requestcategoryselection', new lang_string('requestcategoryselection', 'admin'), new lang_string('configrequestcategoryselection', 'admin'), 0));
+    $temp->add(new admin_setting_configcheckbox('enablecourserequests',
+        new lang_string('enablecourserequests', 'admin'),
+        new lang_string('configenablecourserequests', 'admin'), 1));
+    $temp->add(new admin_settings_coursecat_select('defaultrequestcategory',
+        new lang_string('defaultrequestcategory', 'admin'),
+        new lang_string('configdefaultrequestcategory', 'admin'), 1));
+    $temp->add(new admin_setting_configcheckbox('lockrequestcategory',
+        new lang_string('lockrequestcategory', 'admin'),
+        new lang_string('configlockrequestcategory', 'admin'), 0));
     $temp->add(new admin_setting_users_with_capability('courserequestnotify', new lang_string('courserequestnotify', 'admin'), new lang_string('configcourserequestnotify2', 'admin'), array(), 'moodle/site:approvecourse'));
     $ADMIN->add('courses', $temp);
 
index ac0d11f..7f73e4c 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js differ
index 9ee561c..5f7d8ab 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map differ
index cf509b5..a369f7c 100644 (file)
@@ -28,6 +28,14 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
     var expandedImage = $('<img alt="" src="' + url.imageUrl('t/expanded') + '"/>');
     var collapsedImage = $('<img alt="" src="' + url.imageUrl('t/collapsed') + '"/>');
 
+    /*
+     * Class names to apply when expanding/collapsing nodes.
+     */
+    var CLASSES = {
+        EXPAND: 'fa-caret-right',
+        COLLAPSE: 'fa-caret-down'
+    };
+
     return /** @alias module:tool_dataprivacy/expand-collapse */ {
         /**
          * Expand or collapse a selected node.
@@ -40,15 +48,15 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
                 targetnode.removeClass('hide');
                 targetnode.addClass('visible');
                 targetnode.attr('aria-expanded', true);
-                thisnode.find(':header i.fa').removeClass('fa-plus-square');
-                thisnode.find(':header i.fa').addClass('fa-minus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.EXPAND);
+                thisnode.find(':header i.fa').addClass(CLASSES.COLLAPSE);
                 thisnode.find(':header img.icon').attr('src', expandedImage.attr('src'));
             } else {
                 targetnode.removeClass('visible');
                 targetnode.addClass('hide');
                 targetnode.attr('aria-expanded', false);
-                thisnode.find(':header i.fa').removeClass('fa-minus-square');
-                thisnode.find(':header i.fa').addClass('fa-plus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.COLLAPSE);
+                thisnode.find(':header i.fa').addClass(CLASSES.EXPAND);
                 thisnode.find(':header img.icon').attr('src', collapsedImage.attr('src'));
             }
         },
@@ -61,8 +69,8 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
         expandCollapseAll: function(nextstate) {
             var currentstate = (nextstate == 'visible') ? 'hide' : 'visible';
             var ariaexpandedstate = (nextstate == 'visible') ? true : false;
-            var iconclassnow = (nextstate == 'visible') ? 'fa-plus-square' : 'fa-minus-square';
-            var iconclassnext = (nextstate == 'visible') ? 'fa-minus-square' : 'fa-plus-square';
+            var iconclassnow = (nextstate == 'visible') ? CLASSES.EXPAND : CLASSES.COLLAPSE;
+            var iconclassnext = (nextstate == 'visible') ? CLASSES.COLLAPSE : CLASSES.EXPAND;
             var imagenow = (nextstate == 'visible') ? expandedImage.attr('src') : collapsedImage.attr('src');
             $('.' + currentstate).each(function() {
                 $(this).removeClass(currentstate);
diff --git a/admin/tool/uploaduser/example.csv b/admin/tool/uploaduser/example.csv
new file mode 100644 (file)
index 0000000..355c3e1
--- /dev/null
@@ -0,0 +1,4 @@
+username,firstname,lastname,email
+student1,Student,One,s1@example.com
+student2,Student,Two,s2@example.com
+student3,Student,Three,s3@example.com
\ No newline at end of file
index e94ba5e..f0903ae 100644 (file)
@@ -33,6 +33,10 @@ $string['deleteerrors'] = 'Delete errors';
 $string['encoding'] = 'Encoding';
 $string['errormnetadd'] = 'Can not add remote users';
 $string['errors'] = 'Errors';
+$string['examplecsv'] = 'Example text file';
+$string['examplecsv_help'] = 'To use the example text file, download it then open it with a text or spreadsheet editor. Leave the first line unchanged, then edit the following lines (records) and add your user data, adding more lines as necessary. Save the file as CSV then upload it.
+
+The example text file may also be used for testing, as you are able to preview user data and can choose to cancel the action before user accounts are created.';
 $string['invalidupdatetype'] = 'This option cannot be selected with the chosen upload type.';
 $string['invaliduserdata'] = 'Invalid data detected for user {$a} and it has been automatically cleaned.';
 $string['invalidtheme'] = 'Theme "{$a}" is not installed and will be ignored.';
@@ -63,7 +67,9 @@ $string['uploadusers_help'] = 'Users may be uploaded (and optionally enrolled in
 * Each line of the file contains one record
 * Each record is a series of data separated by commas (or other delimiters)
 * The first record contains a list of fieldnames defining the format of the rest of the file
-* Required fieldnames are username, password, firstname, lastname, email';
+* Required fieldnames are username, password, firstname, lastname, email
+
+<a href="https://docs.moodle.org/en/Upload_users" target="_blank">More help</a>';
 $string['uploaduserspreview'] = 'Upload users preview';
 $string['uploadusersresult'] = 'Upload users results';
 $string['uploaduser:uploaduserpictures'] = 'Upload user pictures';
index 59b9131..552ea4c 100644 (file)
@@ -40,6 +40,11 @@ class admin_uploaduser_form1 extends moodleform {
 
         $mform->addElement('header', 'settingsheader', get_string('upload'));
 
+        $url = new moodle_url('example.csv');
+        $link = html_writer::link($url, 'example.csv');
+        $mform->addElement('static', 'examplecsv', get_string('examplecsv', 'tool_uploaduser'), $link);
+        $mform->addHelpButton('examplecsv', 'examplecsv', 'tool_uploaduser');
+
         $mform->addElement('filepicker', 'userfile', get_string('file'));
         $mform->addRule('userfile', null, 'required');
 
index 30d1691..e7b65e5 100644 (file)
@@ -42,16 +42,23 @@ class behat_auth extends behat_base {
      * Logs in the user. There should exist a user with the same value as username and password.
      *
      * @Given /^I log in as "(?P<username_string>(?:[^"]|\\")*)"$/
+     * @param string $username the user to log in as.
+     * @param moodle_url|null $wantsurl optional, URL to go to after logging in.
      */
-    public function i_log_in_as($username) {
-        // In the mobile app the required tasks are different.
+    public function i_log_in_as(string $username, moodle_url $wantsurl = null) {
+        // In the mobile app the required tasks are different (does not support $wantsurl).
         if ($this->is_in_app()) {
             $this->execute('behat_app::login', [$username]);
             return;
         }
 
+        $loginurl = new moodle_url('/login/index.php');
+        if ($wantsurl !== null) {
+            $loginurl->param('wantsurl', $wantsurl->out_as_local_url());
+        }
+
         // Visit login page.
-        $this->getSession()->visit($this->locate_path('login/index.php'));
+        $this->getSession()->visit($this->locate_path($loginurl->out_as_local_url()));
 
         // Enter username and password.
         $this->execute('behat_forms::i_set_the_field_to', array('Username', $this->escape($username)));
diff --git a/badges/upgradelib.php b/badges/upgradelib.php
new file mode 100644 (file)
index 0000000..d1acd8b
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+
+/**
+ * Contains upgrade and install functions for badges.
+ *
+ * @package    core_badges
+ * @copyright  2019 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Called on install or upgrade to create default list of backpacks a user can connect to.
+ * Don't use the global defines from badgeslib because this is for install/upgrade.
+ *
+ * @return void
+ */
+function badges_install_default_backpacks() {
+    global $DB;
+
+    $record = new stdClass();
+    $record->backpackweburl = 'https://backpack.openbadges.org';
+    $record->backpackapiurl = 'https://backpack.openbadges.org';
+    $record->apiversion = 1;
+    $record->sortorder = 0;
+    $record->password = '';
+
+    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
+        $bpid = $DB->insert_record('badge_external_backpack', $record);
+    } else {
+        $bpid = $bp->id;
+    }
+    set_config('badges_site_backpack', $bpid);
+
+    // All existing backpacks default to V1.
+    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
+
+    $record = new stdClass();
+    $record->backpackapiurl = 'https://api.badgr.io/v2';
+    $record->backpackweburl = 'https://badgr.io';
+    $record->apiversion = 2;
+    $record->sortorder = 1;
+    $record->password = '';
+
+    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
+        $DB->insert_record('badge_external_backpack', $record);
+    }
+
+}
+
index f5cb1c2..d1ace2c 100644 (file)
@@ -88,13 +88,18 @@ class block_starredcourses_external extends core_course_external {
             return ($a->timemodified > $b->timemodified) ? -1 : 1;
         });
 
-        $formattedcourses = array_map(function($favourite) use ($renderer) {
+        $formattedcourses = array();
+        foreach ($favourites as $favourite) {
             $course = get_course($favourite->itemid);
             $context = \context_course::instance($favourite->itemid);
-
-            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
-            return $exporter->export($renderer);
-        }, $favourites);
+            $canviewhiddencourses = has_capability('moodle/course:viewhiddencourses', $context);
+
+            if ($course->visible || $canviewhiddencourses) {
+                $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
+                $formattedcourse = $exporter->export($renderer);
+                $formattedcourses[] = $formattedcourse;
+            }
+        }
 
         return $formattedcourses;
     }
index b8df729..f138e9c 100644 (file)
@@ -65,7 +65,7 @@
 }}
 <div class="border-bottom pb-2">
     {{#eventsbyday}}
-        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}}  {{/userdate}}</h5>
+        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}}  {{/userdate}}</h5>
         {{> block_timeline/event-list-items }}
     {{/eventsbyday}}
 </div>
\ No newline at end of file
index 4bbc744..8ff19c3 100644 (file)
@@ -460,8 +460,7 @@ class provider implements
             $params = array_merge($inparams, ['userid' => $userid]);
             $associds = $DB->get_fieldset_sql($sql, $params);
 
-            list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
-            $DB->delete_records_select('blog_association', "id $insql", $inparams);
+            $DB->delete_records_list('blog_association', 'id', $associds);
         }
     }
 
index 8db5bdd..7002552 100644 (file)
@@ -370,6 +370,37 @@ class core_blog_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('post', ['courseid' => $c1->id, 'userid' => $u1->id, 'module' => 'notes']));
     }
 
+    /**
+     * Test provider delete_data_for_user with a context that contains no entries
+     *
+     * @return void
+     */
+    public function test_delete_data_for_user_empty_context() {
+        global $DB;
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a blog entry for user, associated with course.
+        $entry = new blog_entry($this->create_post(['userid' => $user->id, 'courseid' => $course->id])->id);
+        $entry->add_association($context->id);
+
+        // Generate list of contexts for user.
+        $contexts = provider::get_contexts_for_userid($user->id);
+        $this->assertContains($context->id, $contexts->get_contextids());
+
+        // Now delete the blog entry.
+        $entry->delete();
+
+        // Try to delete user data using contexts obtained prior to entry deletion.
+        $contextlist = new approved_contextlist($user, 'core_blog', $contexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+
+        // Sanity check to ensure blog_associations is really empty.
+        $this->assertEmpty($DB->get_records('blog_association', ['contextid' => $context->id]));
+    }
+
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
 
index 5227584..a16bebe 100644 (file)
@@ -626,6 +626,33 @@ $CFG->admin = 'admin';
 //
 //      $CFG->uninstallclionly = true;
 //
+//
+// Customise question bank display
+//
+// The display of Moodle's question bank is made up of a number of columns.
+// You can customise this display by giving a comma-separated list of column class
+// names here. Each class must be a subclass of \core_question\bank\column_base.
+// For example you might define a class like
+//      class \local_qbank_extensions\my_column extends \core_question\bank\column_base
+// in a local plugin, then add it to the list here. At the time of writing,
+// the default question bank display is equivalent to the following, but you  might like
+// to check the latest default in question/classes/bank/view.php before setting this.
+//
+//      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
+//              . 'question_name_idnumber_tags_column,'
+//              . 'tags_action_column,edit_action_column,copy_action_column,'
+//              . 'preview_action_column,delete_action_column,export_xml_action_column,'
+//              . 'creator_name_column,modifier_name_column,edit_menu_column';
+//
+// Forum summary report
+//
+// In order for the forum summary report to calculate word count and character count data, those details are now stored
+// for each post in the database when posts are created or updated. For posts that existed prior to a Moodle 3.8 upgrade,
+// these are calculated by the refresh_forum_post_counts ad-hoc task in chunks of 5000 posts per batch by default.
+// That default can be overridden by setting an integer value for $CFG->forumpostcountchunksize.
+//
+//      $CFG->forumpostcountchunksize = 5000;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index f2fc3e7..233fa09 100644 (file)
@@ -115,6 +115,10 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
             return get_string('coursenotyetstarted', 'course');
         }
 
+        if (!$fortraining && !$course->get_course_data()->visible) {
+            return get_string('hiddenfromstudents');
+        }
+
         if (!$this->students = $course->get_students()) {
             return get_string('nocoursestudents', 'course');
         }
index 251c460..7c247c9 100644 (file)
@@ -2973,11 +2973,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_request_course() {
-        global $CFG;
-        if (empty($CFG->enablecourserequests) || $this->id != $CFG->defaultrequestcategory) {
-            return false;
-        }
-        return !$this->can_create_course() && has_capability('moodle/course:request', $this->get_context());
+        return course_request::can_request($this->get_context());
     }
 
     /**
index 1f12b7f..162a68d 100644 (file)
@@ -664,7 +664,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         if ($category->can_request_course()) {
             // Request a new course.
-            $url = new moodle_url('/course/request.php', array('return' => 'management'));
+            $url = new moodle_url('/course/request.php', array('category' => $category->id, 'return' => 'management'));
             $actions[] = html_writer::link($url, get_string('requestcourse'));
         }
         if ($category->can_resort_courses()) {
index da5d4ee..bb2b08a 100644 (file)
@@ -3619,7 +3619,7 @@ class core_course_external extends external_api {
     }
 
     /**
-     * Return structure for edit_module()
+     * Return structure for get_module()
      *
      * @since Moodle 3.3
      * @return external_description
index fbeea7a..23088e5 100644 (file)
@@ -752,16 +752,21 @@ function make_categories_options() {
 /**
  * Print the buttons relating to course requests.
  *
- * @param object $context current page context.
+ * @param context $context current page context.
  */
 function print_course_request_buttons($context) {
     global $CFG, $DB, $OUTPUT;
     if (empty($CFG->enablecourserequests)) {
         return;
     }
-    if (!has_capability('moodle/course:create', $context) && has_capability('moodle/course:request', $context)) {
-    /// Print a button to request a new course
-        echo $OUTPUT->single_button(new moodle_url('/course/request.php'), get_string('requestcourse'), 'get');
+    if (course_request::can_request($context)) {
+        // Print a button to request a new course.
+        $params = [];
+        if ($context instanceof context_coursecat) {
+            $params['category'] = $context->instanceid;
+        }
+        echo $OUTPUT->single_button(new moodle_url('/course/request.php', $params),
+            get_string('requestcourse'), 'get');
     }
     /// Print a button to manage pending requests
     if (has_capability('moodle/site:approvecourse', $context)) {
@@ -2833,7 +2838,7 @@ class course_request {
         $data->requester = $USER->id;
 
         // Setting the default category if none set.
-        if (empty($data->category) || empty($CFG->requestcategoryselection)) {
+        if (empty($data->category) || !empty($CFG->lockrequestcategory)) {
             $data->category = $CFG->defaultrequestcategory;
         }
 
@@ -2972,6 +2977,31 @@ class course_request {
         return $this->properties->collision;
     }
 
+    /**
+     * Checks user capability to approve a requested course
+     *
+     * If course was requested without category for some reason (might happen if $CFG->defaultrequestcategory is
+     * misconfigured), we check capabilities 'moodle/site:approvecourse' and 'moodle/course:changecategory'.
+     *
+     * @return bool
+     */
+    public function can_approve() {
+        global $CFG;
+        $category = null;
+        if ($this->properties->category) {
+            $category = core_course_category::get($this->properties->category, IGNORE_MISSING);
+        } else if ($CFG->defaultrequestcategory) {
+            $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING);
+        }
+        if ($category) {
+            return has_capability('moodle/site:approvecourse', $category->get_context());
+        }
+
+        // We can not determine the context where the course should be created. The approver should have
+        // both capabilities to approve courses and change course category in the system context.
+        return has_all_capabilities(['moodle/site:approvecourse', 'moodle/course:changecategory'], context_system::instance());
+    }
+
     /**
      * Returns the category where this course request should be created
      *
@@ -2983,17 +3013,14 @@ class course_request {
      */
     public function get_category() {
         global $CFG;
-        // If the category is not set, if the current user does not have the rights to change the category, or if the
-        // category does not exist, we set the default category to the course to be approved.
-        // The system level is used because the capability moodle/site:approvecourse is based on a system level.
-        if (empty($this->properties->category) || !has_capability('moodle/course:changecategory', context_system::instance()) ||
-                (!$category = core_course_category::get($this->properties->category, IGNORE_MISSING, true))) {
-            $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING, true);
-        }
-        if (!$category) {
-            $category = core_course_category::get_default();
+        if ($this->properties->category && ($category = core_course_category::get($this->properties->category, IGNORE_MISSING))) {
+            return $category;
+        } else if ($CFG->defaultrequestcategory &&
+                ($category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING))) {
+            return $category;
+        } else {
+            return core_course_category::get_default();
         }
-        return $category;
     }
 
     /**
@@ -3119,6 +3146,33 @@ class course_request {
         $eventdata->notification      = 1;
         message_send($eventdata);
     }
+
+    /**
+     * Checks if current user can request a course in this context
+     *
+     * @param context $context
+     * @return bool
+     */
+    public static function can_request(context $context) {
+        global $CFG;
+        if (empty($CFG->enablecourserequests)) {
+            return false;
+        }
+        if (has_capability('moodle/course:create', $context)) {
+            return false;
+        }
+
+        if ($context instanceof context_system) {
+            $defaultcontext = context_coursecat::instance($CFG->defaultrequestcategory, IGNORE_MISSING);
+            return $defaultcontext &&
+                has_capability('moodle/course:request', $defaultcontext);
+        } else if ($context instanceof context_coursecat) {
+            if (!$CFG->lockrequestcategory || $CFG->defaultrequestcategory == $context->instanceid) {
+                return has_capability('moodle/course:request', $context);
+            }
+        }
+        return false;
+    }
 }
 
 /**
index 710ca55..85a59f4 100644 (file)
@@ -39,7 +39,20 @@ $approve = optional_param('approve', 0, PARAM_INT);
 $reject = optional_param('reject', 0, PARAM_INT);
 
 $baseurl = $CFG->wwwroot . '/course/pending.php';
-admin_externalpage_setup('coursespending');
+$context = context_system::instance();
+if (has_capability('moodle/site:approvecourse', $context)) {
+    // Similar to course management capabilities, if user has approve capability in system context
+    // we add the link to the admin menu. Otherwise we check if user has capability anywhere.
+    admin_externalpage_setup('coursespending');
+} else {
+    require_login(null, false);
+    $categories = core_course_category::make_categories_list('moodle/site:approvecourse');
+    if (!$categories) {
+        require_capability('moodle/site:approvecourse', $context);
+    }
+    $PAGE->set_context($context);
+    $PAGE->set_url(new moodle_url('/course/pending.php'));
+}
 
 /// Process approval of a course.
 if (!empty($approve) and confirm_sesskey()) {
@@ -48,7 +61,11 @@ if (!empty($approve) and confirm_sesskey()) {
     $courseid = $course->approve();
 
     if ($courseid !== false) {
-        redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+        if (has_capability('moodle/course:update', context_course::instance($courseid))) {
+            redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+        } else {
+            redirect(new moodle_url('/course/view.php', ['id' => $courseid]));
+        }
     } else {
         print_error('courseapprovedfailed');
     }
@@ -109,6 +126,9 @@ if (empty($pending)) {
         // Check here for shortname collisions and warn about them.
         $course->check_shortname_collision();
 
+        if (!$course->can_approve()) {
+            continue;
+        }
         $category = $course->get_category();
 
         $row = array();
index e330e1d..3b1813b 100644 (file)
@@ -30,6 +30,7 @@ require_once($CFG->dirroot . '/course/request_form.php');
 // Where we came from. Used in a number of redirects.
 $url = new moodle_url('/course/request.php');
 $return = optional_param('return', null, PARAM_ALPHANUMEXT);
+$categoryid = optional_param('category', null, PARAM_INT);
 if ($return === 'management') {
     $url->param('return', $return);
     $returnurl = new moodle_url('/course/management.php', array('categoryid' => $CFG->defaultrequestcategory));
@@ -47,12 +48,24 @@ if (isguestuser()) {
 if (empty($CFG->enablecourserequests)) {
     print_error('courserequestdisabled', '', $returnurl);
 }
-$context = context_system::instance();
+
+if ($CFG->lockrequestcategory) {
+    // Course request category is locked, user will always request in the default request category.
+    $categoryid = null;
+} else if (!$categoryid) {
+    // Category selection is enabled but category is not specified.
+    // Find a category where user has capability to request courses (preferably the default category).
+    $list = core_course_category::make_categories_list('moodle/course:request');
+    $categoryid = array_key_exists($CFG->defaultrequestcategory, $list) ? $CFG->defaultrequestcategory : key($list);
+}
+
+$context = context_coursecat::instance($categoryid ?: $CFG->defaultrequestcategory);
 $PAGE->set_context($context);
 require_capability('moodle/course:request', $context);
 
 // Set up the form.
-$data = course_request::prepare();
+$data = $categoryid ? (object)['category' => $categoryid] : null;
+$data = course_request::prepare($data);
 $requestform = new course_request_form($url);
 $requestform->set_data($data);
 
index 054149d..3107584 100644 (file)
@@ -68,8 +68,8 @@ class course_request_form extends moodleform {
         $mform->addRule('shortname', get_string('missingshortname'), 'required', null, 'client');
         $mform->setType('shortname', PARAM_TEXT);
 
-        if (!empty($CFG->requestcategoryselection)) {
-            $displaylist = core_course_category::make_categories_list();
+        if (empty($CFG->lockrequestcategory)) {
+            $displaylist = core_course_category::make_categories_list('moodle/course:request');
             $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
             $mform->setDefault('category', $CFG->defaultrequestcategory);
             $mform->addHelpButton('category', 'coursecategory');
diff --git a/course/tests/behat/course_request.feature b/course/tests/behat/course_request.feature
new file mode 100644 (file)
index 0000000..c50671f
--- /dev/null
@@ -0,0 +1,106 @@
+@core @core_course
+Feature: Users can request and approve courses
+  As a moodle admin
+  In order to improve course creation process
+  I need to be able to enable course approval
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | user1 | User | 1 | user1@example.com |
+      | user2 | User | 2 | user2@example.com |
+      | user3 | User | 3 | user3@example.com |
+
+  Scenario: Simple course request workflow
+    Given the following "system role assigns" exist:
+      | user  | course | role |
+      | user2 | Acceptance test site | manager |
+    Given I log in as "admin"
+    And I set the following administration settings values:
+      | lockrequestcategory | 1 |
+    And I set the following system permissions of "Authenticated user" role:
+      | capability | permission |
+      | moodle/course:request | Allow |
+    And I log out
+    When I log in as "user1"
+    And I am on course index
+    And I press "Request a course"
+    And I set the following fields to these values:
+      | Course full name  | My new course |
+      | Course short name | Mynewcourse   |
+      | Supporting information | pretty please |
+    And I press "Request a course"
+    And I should see "Your course request has been saved successfully."
+    And I press "Continue"
+    And I am on course index
+    And I should not see "My new course"
+    And I log out
+    And I log in as "user2"
+    And I am on course index
+    And I press "Courses pending approval"
+    And I should see "Miscellaneous" in the "My new course" "table_row"
+    And I click on "Approve" "button" in the "My new course" "table_row"
+    And I press "Save and return"
+    And I should see "There are no courses pending approval"
+    And I press "Back to course listing"
+    And I should see "My new course"
+    And I log out
+    And I log in as "user1"
+    And I am on course index
+    And I follow "My new course"
+    And I navigate to course participants
+    And I should see "Teacher" in the "User 1" "table_row"
+    And I log out
+
+  Scenario: Course request with category selection
+    Given the following "categories" exist:
+      | name             | category | idnumber |
+      | Science category | 0        | SCI |
+      | English category | 0        | ENG |
+      | Other category   | 0        | MISC |
+    Given the following "roles" exist:
+      | name             | shortname       | description      | archetype      |
+      | Course requestor | courserequestor | My custom role 1 |                |
+    And the following "role assigns" exist:
+      | user  | role            | contextlevel | reference |
+      | user1 | courserequestor | Category     | SCI       |
+      | user1 | courserequestor | Category     | ENG       |
+      | user2 | manager         | Category     | SCI       |
+      | user3 | manager         | Category     | ENG       |
+    Given I log in as "admin"
+    And I set the following system permissions of "Course requestor" role:
+      | capability            | permission |
+      | moodle/course:request | Allow      |
+    And I log out
+    And I log in as "user1"
+    And I am on course index
+    And I follow "English category"
+    And I press "Request a course"
+    And the field "Course category" matches value "English category"
+    And I set the following fields to these values:
+      | Course full name  | My new course |
+      | Course short name | Mynewcourse   |
+      | Supporting information | pretty please |
+    And I press "Request a course"
+    And I log out
+    And I log in as "user2"
+    And I am on course index
+    And I follow "English category"
+    And "Courses pending approval" "button" should not exist
+    And I am on course index
+    And I follow "Science category"
+    And I press "Courses pending approval"
+    And I should not see "Mynewcourse"
+    And I press "Back to course listing"
+    And I log out
+    And I log in as "user3"
+    And I am on course index
+    And I follow "English category"
+    And I press "Courses pending approval"
+    And I should see "English category" in the "Mynewcourse" "table_row"
+    And I click on "Approve" "button" in the "Mynewcourse" "table_row"
+    And I press "Save and return"
+    And I am on course index
+    And I follow "English category"
+    And I should see "My new course"
+    And I log out
index 6779405..c5b37fc 100644 (file)
@@ -6811,4 +6811,110 @@ class core_course_courselib_testcase extends advanced_testcase {
         course_delete_module($moduleinstances[$indextodelete]->cmid, true); // Try to delete the instance asynchronously.
         $this->assertEquals($expected, course_modules_pending_deletion($course->id, $gradable));
     }
+
+    /**
+     * Tests for the course_request::can_request
+     */
+    public function test_can_request_course() {
+        global $CFG, $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $cat1 = $CFG->defaultrequestcategory;
+        $cat2 = $this->getDataGenerator()->create_category()->id;
+        $cat3 = $this->getDataGenerator()->create_category()->id;
+        $context1 = context_coursecat::instance($cat1);
+        $context2 = context_coursecat::instance($cat2);
+        $context3 = context_coursecat::instance($cat3);
+        $this->setUser($user);
+
+        // By default users don't have capability to request courses.
+        $this->assertFalse(course_request::can_request(context_system::instance()));
+        $this->assertFalse(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+
+        // Allow for the 'user' role the capability to request courses.
+        $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']);
+        assign_capability('moodle/course:request', CAP_ALLOW, $userroleid,
+            context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        // Lock category selection.
+        $CFG->lockrequestcategory = 1;
+
+        // Now user can only request course in the default category or in system context.
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+
+        // Enable category selection. User can request course anywhere.
+        $CFG->lockrequestcategory = 0;
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertTrue(course_request::can_request($context2));
+        $this->assertTrue(course_request::can_request($context3));
+
+        // Remove cap from cat2.
+        $roleid = create_role('Test role', 'testrole', 'Test role description');
+        assign_capability('moodle/course:request', CAP_PROHIBIT, $roleid,
+            $context2->id, true);
+        role_assign($roleid, $user->id, $context2->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertTrue(course_request::can_request($context3));
+
+        // Disable course request functionality.
+        $CFG->enablecourserequests = false;
+        $this->assertFalse(course_request::can_request(context_system::instance()));
+        $this->assertFalse(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+    }
+
+    /**
+     * Tests for the course_request::can_approve
+     */
+    public function test_can_approve_course_request() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        $requestor = $this->getDataGenerator()->create_user();
+        $user = $this->getDataGenerator()->create_user();
+        $cat1 = $CFG->defaultrequestcategory;
+        $cat2 = $this->getDataGenerator()->create_category()->id;
+        $cat3 = $this->getDataGenerator()->create_category()->id;
+
+        // Enable course requests. Default 'user' role has capability to request courses.
+        $CFG->enablecourserequests = true;
+        $CFG->lockrequestcategory = 0;
+        $this->setUser($requestor);
+        $requestdata = ['summary_editor' => ['text' => '', 'format' => 0], 'name' => 'Req', 'reason' => 'test'];
+        $request1 = course_request::create((object)($requestdata));
+        $request2 = course_request::create((object)($requestdata + ['category' => $cat2]));
+        $request3 = course_request::create((object)($requestdata + ['category' => $cat3]));
+
+        $this->setUser($user);
+        // Add capability to approve courses.
+        $roleid = create_role('Test role', 'testrole', 'Test role description');
+        assign_capability('moodle/site:approvecourse', CAP_ALLOW, $roleid,
+            context_system::instance()->id, true);
+        role_assign($roleid, $user->id, context_coursecat::instance($cat2)->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $this->assertFalse($request1->can_approve());
+        $this->assertTrue($request2->can_approve());
+        $this->assertFalse($request3->can_approve());
+
+        // Delete category where course was requested. Now only site-wide manager can approve it.
+        core_course_category::get($cat2, MUST_EXIST, true)->delete_full(false);
+        $this->assertFalse($request2->can_approve());
+
+        $this->setAdminUser();
+        $this->assertTrue($request2->can_approve());
+    }
 }
index 1e96d21..fa76479 100644 (file)
@@ -37,7 +37,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $defaultcategory);
 
         // Create some categories.
@@ -70,7 +70,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         // Request with category different than default and category selection allowed.
         set_config('defaultrequestcategory', $cat3->id);
-        set_config('requestcategoryselection', 1);
+        set_config('lockrequestcategory', 0);
         $data->category = $cat1->id;
         $cr = course_request::create($data);
         $this->assertEquals($cat1->id, $cr->category);
@@ -83,14 +83,20 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $defaultcategory);
 
         // Create some categories.
         $cat1 = $this->getDataGenerator()->create_category();
         $cat2 = $this->getDataGenerator()->create_category();
 
+        // Create a user and allow course requests for him.
         $requester = $this->getDataGenerator()->create_user();
+        $roleid = create_role('Course requestor role', 'courserequestor', '');
+        assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+            context_system::instance()->id);
+        role_assign($roleid, $requester->id, context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
 
         $data = new stdClass();
         $data->fullname = 'Həllo World!';
@@ -116,7 +122,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
         $this->assertEquals($defaultcategory, $course->category);
 
         // Test with category.
-        set_config('requestcategoryselection', 1);
+        set_config('lockrequestcategory', 0);
         set_config('defaultrequestcategory', $cat2->id);
         $data->shortname .= ' 2nd';
         $data->category = $cat1->id;
@@ -138,10 +144,16 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $this->setAdminUser();
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $DB->get_field_select('course_categories', "MIN(id)", "parent=0"));
 
+        // Create a user and allow course requests for him.
         $requester = $this->getDataGenerator()->create_user();
+        $roleid = create_role('Course requestor role', 'courserequestor', '');
+        assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+            context_system::instance()->id);
+        role_assign($roleid, $requester->id, context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
 
         $data = new stdClass();
         $data->fullname = 'Həllo World!';
index 7c9a1d1..90a9710 100644 (file)
@@ -130,6 +130,27 @@ class core_analytics_targets_testcase extends advanced_testcase {
                 ],
                 'isvalid' => get_string('completionnotenabledforcourse', 'completion')
             ],
+            'coursehiddentraining' => [
+                'params' => [
+                    'enablecompletion' => 1,
+                    'startdate' => mktime(0, 0, 0, $month - 1, 24, $year - 1),
+                    'enddate' => mktime(0, 0, 0, $month - 1, 23, $year),
+                    'students' => true,
+                    'visible' => '0',
+                ],
+                'isvalid' => true,
+            ],
+            'coursehiddenprediction' => [
+                'params' => [
+                    'enablecompletion' => 1,
+                    'startdate' => mktime(0, 0, 0, $month - 1, 24, $year),
+                    'enddate' => mktime(0, 0, 0, $month - 1, 23, $year + 1),
+                    'students' => true,
+                    'visible' => '0',
+                ],
+                'isvalid' => get_string('hiddenfromstudents'),
+                'fortraining' => false
+            ],
         ];
     }
 
index 3a51249..b49c0a3 100644 (file)
@@ -203,7 +203,7 @@ $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the H
 $string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
 $string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
 $string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
-$string['configdefaultrequestcategory'] = 'Courses requested by users will be automatically placed in this category.';
+$string['configdefaultrequestcategory'] = 'Courses requested by users will be placed in this category if the category is not specified.';
 $string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
 $string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given.  The default is the Authenticated user role.  Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
 $string['configdeleteincompleteusers'] = 'After this period, any account without the first name, last name or email field filled in is deleted.';
@@ -225,7 +225,7 @@ $string['configemailfromvia'] = 'Add via information in the "From" section of ou
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
-$string['configenablecourserequests'] = 'This will allow any user to request a course be created.';
+$string['configenablecourserequests'] = 'Enable course request functionality. Users with capability to request courses but without capability to create courses will be able to request courses.';
 $string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
@@ -270,6 +270,7 @@ $string['configlanglist'] = 'If left blank, all languages installed on the site
 $string['configlangmenu'] = 'Choose whether or not you want to display the general-purpose language menu on the home page, login page etc.  This does not affect the user\'s ability to set the preferred language in their own profile.';
 $string['configlatinexcelexport'] = 'Choose the encoding for Excel exports.';
 $string['configlocale'] = 'Choose a sitewide locale - this will override the format and language of dates for all language packs (though names of days in calendar are not affected). You need to have this locale data installed on your operating system (eg for linux en_US.UTF-8 or es_ES.UTF-8). In most cases this field should be left blank.';
+$string['configlockrequestcategory'] = 'Only allow course requests in the default course request category. This is a legacy setting, it is better not to use it but instead assign capability to request courses in the appropriate course category context';
 $string['configloglifetime'] = 'This specifies the length of time you want to keep logs about user activity.  Logs that are older than this age are automatically deleted.  It is best to keep logs as long as possible, in case you need them, but if you have a very busy server and are experiencing performance problems, then you may want to lower the log lifetime. Values lower than 30 are not recommended because statistics may not work properly.';
 $string['configlookahead'] = 'Days to look ahead';
 $string['configmailnewline'] = 'Newline characters used in mail messages. CRLF is required according to RFC 822bis, some mail servers do automatic conversion from LF to CRLF, other mail servers do incorrect conversion from CRLF to CRCRLF, yet others reject mails with bare LF (qmail for example). Try changing this setting if you are having problems with undelivered emails or double newlines.';
@@ -317,7 +318,6 @@ $string['configproxytype'] = 'Type of web proxy (PHP5 and cURL extension require
 $string['configproxyuser'] = 'Username needed to access internet through proxy if required, empty if none (PHP cURL extension required).';
 $string['configrecaptchaprivatekey'] = 'String of characters (secret key) used to communicate between your Moodle server and the recaptcha server. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
 $string['configrecaptchapublickey'] = 'String of characters (site key) used to display the reCAPTCHA element in the signup form. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
-$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
 $string['configrequestedstudentname'] = 'Word for student used in requested courses';
 $string['configrequestedstudentsname'] = 'Word for students used in requested courses';
 $string['configrequestedteachername'] = 'Word for teacher used in requested courses';
@@ -716,6 +716,7 @@ $string['lockoutthreshold'] = 'Account lockout threshold';
 $string['lockoutthreshold_desc'] = 'Select number of failed login attempts that result in account lockout. This feature may be abused in denial of service attacks.';
 $string['lockoutwindow'] = 'Account lockout observation window';
 $string['lockoutwindow_desc'] = 'Observation time for lockout threshold, if there are no failed attempts the threshold counter is reset after this time.';
+$string['lockrequestcategory'] = 'Lock category for the course requests';
 $string['log'] = 'Logs';
 $string['logguests'] = 'Log guest access';
 $string['logguests_help'] = 'This setting enables logging of actions by guest account and not logged in users. High profile sites may want to disable this logging for performance reasons. It is recommended to keep this setting enabled on production sites.';
@@ -1045,7 +1046,6 @@ $string['purgeselectedcaches'] = 'Purge selected caches';
 $string['purgeselectedcachesfinished'] = 'The selected caches were purged.';
 $string['purgetemplates'] = 'Templates';
 $string['purgethemecache'] = 'Themes';
-$string['requestcategoryselection'] = 'Enable category selection';
 $string['restorecourse'] = 'Restore course';
 $string['restorernewroleid'] = 'Restorers\' role in courses';
 $string['restorernewroleid_help'] = 'If the user does not already have the permission to manage the newly restored course, the user is automatically assigned this role and enrolled if necessary. Select "None" if you do not want restorers to be able to manage every restored course.';
@@ -1436,3 +1436,5 @@ $string['registermoodleorg'] = 'When you register your site';
 $string['registermoodleorgli1'] = 'You are added to a low-volume mailing list for important notifications such as security alerts and new releases of Moodle.';
 $string['registermoodleorgli2'] = 'Statistics about your site will be added to the {$a} of the worldwide Moodle community.';
 $string['registerwithmoodleorg'] = 'Register your site';
+$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
+$string['requestcategoryselection'] = 'Enable category selection';
index 0595e0c..861c15a 100644 (file)
@@ -115,3 +115,5 @@ completeregistration,core_hub
 registersite,core_hub
 updatesite,core_hub
 unregisterexplained,core_hub
+configrequestcategoryselection,core_admin
+requestcategoryselection,core_admin
\ No newline at end of file
index 6ff6bfc..86b8085 100644 (file)
@@ -103,6 +103,10 @@ $string['messagepreferences'] = 'Message preferences';
 $string['message'] = 'Message';
 $string['messagecontactrequestsnotification'] = '{$a} is requesting to be added as a contact.';
 $string['messagecontactrequestsnotificationsubject'] = 'Contact request from {$a}';
+$string['messagecontentaudio'] = 'Audio';
+$string['messagecontentimage'] = 'Image';
+$string['messagecontentmultimediageneral'] = 'Other media';
+$string['messagecontentvideo'] = 'Video';
 $string['messagedrawerviewcontact'] = 'User details for {$a}';
 $string['messagedrawerviewcontacts'] = 'Message contacts';
 $string['messagedrawerviewconversation'] = 'Conversation with {$a}';
index 7dc290e..94cc9df 100644 (file)
@@ -70,6 +70,9 @@ $string['categoryinfo'] = 'Category info';
 $string['categorymove'] = 'The category \'{$a->name}\' contains {$a->count} questions (some of which may be hidden questions or random questions that are still in use in a quiz). Please choose another category to move them to.';
 $string['categorymoveto'] = 'Save in category';
 $string['categorynamecantbeblank'] = 'The category name cannot be blank.';
+$string['categorynamewithcount'] = '{$a->name} ({$a->questioncount})';
+$string['categorynamewithidnumber'] = '{$a->name} [{$a->idnumber}]';
+$string['categorynamewithidnumberandcount'] = '{$a->name} [{$a->idnumber}] ({$a->questioncount})';
 $string['clickflag'] = 'Flag question';
 $string['clicktoflag'] = 'Flag this question for future reference';
 $string['clicktounflag'] = 'Remove flag';
@@ -154,6 +157,7 @@ $string['eventquestionsexported'] = 'Questions exported';
 $string['eventquestionsimported'] = 'Questions imported';
 $string['eventquestionupdated'] = 'Question updated';
 $string['export'] = 'Export';
+$string['exportasxml'] = 'Export as Moodle XML';
 $string['exportcategory'] = 'Export category';
 $string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
 
index 986e503..572a520 100644 (file)
@@ -233,13 +233,16 @@ function badges_calculate_message_schedule($schedule) {
 
     switch ($schedule) {
         case BADGE_MESSAGE_DAILY:
-            $nextcron = time() + 60 * 60 * 24;
+            $tomorrow = new DateTime("1 day", core_date::get_server_timezone_object());
+            $nextcron = $tomorrow->getTimestamp();
             break;
         case BADGE_MESSAGE_WEEKLY:
-            $nextcron = time() + 60 * 60 * 24 * 7;
+            $nextweek = new DateTime("1 week", core_date::get_server_timezone_object());
+            $nextcron = $nextweek->getTimestamp();
             break;
         case BADGE_MESSAGE_MONTHLY:
-            $nextcron = time() + 60 * 60 * 24 * 7 * 30;
+            $nextmonth = new DateTime("1 month", core_date::get_server_timezone_object());
+            $nextcron = $nextmonth->getTimestamp();
             break;
     }
 
@@ -856,45 +859,6 @@ function badges_get_badge_api_versions() {
     ];
 }
 
-/**
- * Called on install or upgrade to create default list of backpacks a user can connect to.
- *
- * @return void
- */
-function badges_install_default_backpacks() {
-    global $DB;
-
-    $record = new stdClass();
-    $record->backpackweburl = BADGE_BACKPACKWEBURL;
-    $record->backpackapiurl = BADGE_BACKPACKAPIURL;
-    $record->apiversion = OPEN_BADGES_V1;
-    $record->sortorder = 0;
-    $record->password = '';
-
-    $bpid = 0;
-    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
-        $bpid = $DB->insert_record('badge_external_backpack', $record);
-    } else {
-        $bpid = $bp->id;
-    }
-    set_config('badges_site_backpack', $bpid);
-
-    // All existing backpacks default to V1.
-    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
-
-    $record = new stdClass();
-    $record->backpackapiurl = BADGRIO_BACKPACKAPIURL;
-    $record->backpackweburl = BADGRIO_BACKPACKWEBURL;
-    $record->apiversion = OPEN_BADGES_V2;
-    $record->sortorder = 1;
-    $record->password = '';
-
-    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
-        $DB->insert_record('badge_external_backpack', $record);
-    }
-
-}
-
 /**
  * Get the default issuer for a badge from this site.
  *
index c6053b9..7a23e2d 100644 (file)
@@ -35,6 +35,9 @@ use Behat\Mink\Element\NodeElement;
 use Behat\Mink\Element\Element;
 use Behat\Mink\Session;
 
+require_once(__DIR__ . '/classes/component_named_selector.php');
+require_once(__DIR__ . '/classes/component_named_replacement.php');
+
 /**
  * Steps definitions base class.
  *
@@ -216,10 +219,22 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
             $selector = 'xpath';
         }
 
-        // Convert to named_partial where the selector type is not named_partial, named_exact, xpath, or css.
+        // Convert to a named selector where the selector type is not a known selector.
         $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
         $converttonamed = $converttonamed && 'xpath' !== $selector;
         if ($converttonamed) {
+            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            }
+
             $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
             $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
             if (isset($allowedpartialselectors[$selector])) {
@@ -229,7 +244,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                 $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
                 $selector = 'named_exact';
             } else {
-                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this);
+                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
             }
         }
 
@@ -1043,6 +1058,56 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         }
     }
 
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Page            | Description                                                    |
+     *
+     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_url(string $page): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $page .
+                '" page\' navigation step.');
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * A typical example might be:
+     *     When I am on the "Test quiz" "mod_quiz > Responses report" page
+     * which would cause this method in behat_mod_quiz to be called with
+     * arguments 'Responses report', 'Test quiz'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_instance_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Type      | identifier meaning | Description                                     |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
+     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $identifier .
+                '" "' . $type . '" page\' navigation step.');
+    }
+
     /**
      * Gets the required timeout in seconds.
      *
@@ -1092,4 +1157,58 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     public static function get_extended_timeout() : int {
         return self::get_real_timeout(10);
     }
+
+    /**
+     * Return a list of the exact named selectors for the component.
+     *
+     * Named selectors are what make Behat steps like
+     *   Then I should see "Useful text" in the "General" "fieldset"
+     * work. Here, "fieldset" is the named selector, and "General" is the locator.
+     *
+     * If you override this method in your plugin (e.g. mod_mymod), to define
+     * new selectors specific to your plugin. For example, if you returned
+     *   new behat_component_named_selector('Thingy',
+     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
+     * then
+     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
+     * would work.
+     *
+     * This method should return a list of {@link behat_component_named_selector} and
+     * the docs on that class explain how it works.
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_exact_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the partial named selectors for the component.
+     *
+     * Like the exact named selectors above, but the locator only
+     * needs to match part of the text. For example, the standard
+     * "button" is a partial selector, so:
+     *   When I click "Save" "button"
+     * will activate "Save changes".
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_partial_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [];
+    }
 }
index 8b050cc..d7a2b60 100644 (file)
@@ -90,12 +90,8 @@ class behat_context_helper {
      * @return behat_base
      */
     public static function get($classname) {
-        $contexts = self::$environment->getContexts();
-
-        foreach ($contexts as $context) {
-            if (is_a($context, $classname)) {
-                return $context;
-            }
+        if (self::$environment->hasContextClass($classname)) {
+            return self::$environment->getContext($classname);
         }
 
         $suitename = self::$environment->getSuite()->getName();
@@ -121,6 +117,16 @@ class behat_context_helper {
         return self::$environment->getContext($classname);
     }
 
+    /**
+     * Return whether there is a context of the specified classname.
+     *
+     * @param string $classname
+     * @return bool
+     */
+    public static function has_context(string $classname): bool {
+        return self::$environment->hasContextClass($classname);
+    }
+
     /**
      * Translates string to XPath literal.
      *
index f093e75..e90ec56 100644 (file)
@@ -23,6 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+require_once(__DIR__ . '/named_selector.php');
 require_once(__DIR__ . '/exact_named_selector.php');
 require_once(__DIR__ . '/partial_named_selector.php');
 
diff --git a/lib/behat/classes/component_named_replacement.php b/lib/behat/classes/component_named_replacement.php
new file mode 100644 (file)
index 0000000..f11a4ce
--- /dev/null
@@ -0,0 +1,99 @@
+<?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/>.
+
+/**
+ * A class for recording the definition of Mink replacements.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A class for recording the definition of Mink replacements for use in Mink selectors.
+ *
+ * These are comprised of a source string, and a replacement.
+ *
+ * During use the source string is converted from the string to be in the format:
+ *
+ *      %[component]/[string]%
+ *
+ * For example:
+ *
+ *      %mod_forum/title%
+ *
+ * Mink replacements are used in xpath translation to translate regularly used items such as title.
+ * Here is an example from the upstream Mink project:
+ *
+ * '%tagTextMatch%' => 'contains(normalize-space(string(.)), %locator%)'
+ *
+ * And can be used in an xpath:
+ *
+ *      .//label[%tagTextMatch%]
+ *
+ * This would be expanded to:
+ *
+ *      .//label[contains(normalize-space(string(.)), %locator%)]
+ *
+ * Replacements can also be used in other replacements, as long as that replacement is defined later.
+ *
+ *      '%linkMatch%' => '(%idMatch% or %tagTextMatch% or %titleMatch% or %relMatch%)'
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_replacement {
+    /** @var string */
+    protected $from;
+
+    /** @var string */
+    protected $to;
+
+    /**
+     * Create the replacement.
+     *
+     * @param string $from this is the old selector that should no longer be used.
+     *      For example 'group_message'.
+     * @param string $to this is the new equivalent that should be used instead.
+     *      For example 'core_message > Message'.
+     */
+    public function __construct(string $from, string $to) {
+        $this->from = $from;
+        $this->to = $to;
+    }
+
+    /**
+     * Get the 'from' part of the replacement, formatted for the component.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_from(string $component): string {
+        return "%{$component}/{$this->from}%";
+    }
+
+    /**
+     * Get the 'to' part of the replacement.
+     *
+     * @return string Target xpath
+     */
+    public function get_to(): string {
+        return $this->to;
+    }
+}
diff --git a/lib/behat/classes/component_named_selector.php b/lib/behat/classes/component_named_selector.php
new file mode 100644 (file)
index 0000000..8b58484
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * Named selectors are what make Behat steps like
+ *   Then I should see "Useful text" in the "General" "fieldset"
+ * Here, "fieldset" is the named selector, and "General" is the locator.
+ *
+ * Selectors can either be exact, in which case the locator needs to
+ * match exactly, or can be partial, for example the way
+ *   When I click "Save" "button"
+ * will trigger a "Save changes" button.
+ *
+ * Instances of this class get returned by the get_exact_named_selectors()
+ * and get_partial_named_selectors() methods in classes like behat_mod_mymod.
+ * The code that makes the magic work is in the trait behat_named_selector
+ * used by both behat_exact_named_selector and behat_partial_named_selector.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_selector {
+    /** @var string */
+    protected $alias;
+
+    /** @var array List of xpaths */
+    protected $xpaths;
+
+    /** @var string */
+    protected $istextselector;
+
+    /**
+     * Create the selector definition.
+     *
+     * As an example, if you define
+     *   new behat_component_named_selector('Message',
+     *           [".//*[@data-conversation-id]//img[contains(@alt, %locator%)]/.."])
+     * in get_partial_named_selectors in behat_message in
+     * message/tests/behat/behat_message.php, then steps like
+     *   When "Group 1" "core_message > Message" should exist
+     * will work.
+     *
+     * Text selectors are things that contain other things (e.g. some particular text), e.g.
+     *   Then I can see "Some text" in the "Whatever" "text_selector"
+     * whereas non-text selectors are atomic things, like
+     *   When I click the "Whatever" "widget".
+     *
+     * @param string $alias The 'friendly' name of the thing. This will be prefixed with the component name.
+     *      For example, if the mod_mymod plugin, says 'Thingy', then "mod_mymod > Thingy" becomes a selector.
+     * @param array $xpaths A list of xpaths one or more XPaths that the selector gets transformed into.
+     * @param bool $istextselector Whether this selector can also be used as a text selector.
+     */
+    public function __construct(string $alias, array $xpaths, bool $istextselector = true) {
+        $this->alias = $alias;
+        $this->xpaths = $xpaths;
+        $this->istextselector = $istextselector;
+    }
+
+    /**
+     * Whether this is a text selector.
+     *
+     * @return bool
+     */
+    public function is_text_selector(): bool {
+        return $this->istextselector;
+    }
+
+    /**
+     * Get the name of the selector.
+     * This is a back-end feature and contains a namespaced md5 of the human-readable name.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_name(string $component): string {
+        return implode('_', [$component, md5($this->alias)]);
+    }
+
+    /**
+     * Get the alias of the selector.
+     * This is the human-readable name that you would typically interact with.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_alias(string $component): string {
+        return implode(" > ", [$component, $this->alias]);;
+    }
+
+    /**
+     * Get the list of combined xpaths.
+     *
+     * @return string The list of xpaths combined with the xpath | (OR) operator
+     */
+    public function get_combined_xpath(): string {
+        return implode(' | ', $this->xpaths);
+    }
+}
index 4902075..e178dae 100644 (file)
@@ -32,6 +32,9 @@
  */
 class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -63,6 +66,9 @@ class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector
         'text_exact' => 'text',
     );
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [];
+
     /**
      * Allowed selectors getter.
      *
diff --git a/lib/behat/classes/named_selector.php b/lib/behat/classes/named_selector.php
new file mode 100644 (file)
index 0000000..e683ffc
--- /dev/null
@@ -0,0 +1,113 @@
+<?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/>.
+
+/**
+ * Moodle-specific common functions for named selectors.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Common functions for named selectors.
+ *
+ * This has to be a trait, because we need this in both the classes
+ * behat_exact_named_selector and behat_partial_named_selector, and
+ * those classes have to be subclasses of \Behat\Mink\Selector\ExactNamedSelector
+ * and \Behat\Mink\Selector\PartialNamedSelector. This trait is a way achieve
+ * that without duplciated code.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait behat_named_selector {
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_selector $selector
+     */
+    public function register_component_selector(string $component, behat_component_named_selector $selector) {
+        $alias = $selector->get_alias($component);
+        $name = $selector->get_name($component);
+        static::$allowedselectors[$alias] = $name;
+
+        if ($selector->is_text_selector()) {
+            static::$allowedtextselectors[$alias] = $name;
+        }
+
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $replacements = $r->getValue($this);
+
+        $selectorxpath = strtr($selector->get_combined_xpath(), $replacements);
+
+        parent::registerNamedXpath($name, $selectorxpath);
+    }
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_replacement $replacement
+     */
+    public function register_replacement(string $component, behat_component_named_replacement $replacement) {
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $existing = $r->getValue($this);
+
+        $from = $replacement->get_from($component);
+
+        if (isset($existing[$from])) {
+            throw new \coding_exception("A named replacement already exists in the partial named selector for '{$from}'.  " .
+                "Replacement names must be unique, and should be namespaced to the component");
+        }
+
+        $translatedto = strtr($replacement->get_to(), $existing);
+        $this->registerReplacement($from, $translatedto);
+    }
+
+    /**
+     * Check whether the specified selector has been deprecated and marked for replacement.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function is_deprecated_selector(string $selector): bool {
+        return array_key_exists($selector, static::$deprecatedselectors);
+    }
+
+    /**
+     * Fetch the replacement name of a deprecated selector.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function get_deprecated_replacement(string $selector): ?string {
+        return static::$deprecatedselectors[$selector];
+    }
+}
index 8503da8..86830de 100644 (file)
@@ -33,6 +33,9 @@
  */
 class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -271,6 +274,15 @@ XPATH
         ],
     ];
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [
+        'group_message' => 'core_message > Message',
+        'group_message_member' => 'core_message > Message member',
+        'group_message_tab' => 'core_message > Message tab',
+        'group_message_list_area' => 'core_message > Message list area',
+        'group_message_message_content' => 'core_message > Message content',
+    ];
+
     /**
      * Allowed selectors getter.
      *
index 1988e01..5338be1 100644 (file)
@@ -345,6 +345,23 @@ class behat_util extends testing_util {
         return behat_command::get_parent_behat_dir() . '/test_environment_enabled.txt';
     }
 
+    /**
+     * Removes config settings that were added to the main $CFG config within the Behat CLI
+     * run.
+     *
+     * Database storage is already handled by reset_database and existing config values will
+     * be reset automatically by initialise_cfg(), so we only need to remove added ones.
+     */
+    public static function remove_added_config() {
+        global $CFG;
+        if (!empty($CFG->behat_cli_added_config)) {
+            foreach ($CFG->behat_cli_added_config as $key => $value) {
+                unset($CFG->{$key});
+            }
+            unset($CFG->behat_cli_added_config);
+        }
+    }
+
     /**
      * Reset contents of all database tables to initial values, reset caches, etc.
      */
@@ -375,6 +392,7 @@ class behat_util extends testing_util {
 
         // Initialise $CFG with default values. This is needed for behat cli process, so we don't have modified
         // $CFG values from the old run. @see set_config.
+        self::remove_added_config();
         initialise_cfg();
     }
 
index be81f80..5a900b8 100644 (file)
@@ -60,7 +60,9 @@ class checkbox_toggleall implements renderable, templatable {
      *     <ul>
      *         <li><b>id          </b> string - The element ID.</li>
      *         <li><b>name        </b> string - The element name.</li>
-     *         <li><b>classes     </b> string - CSS classes that you want to add for your checkbox.</li>
+     *         <li><b>classes     </b> string - CSS classes that you want to add for your checkbox or toggle controls.
+     *                                          For button type master toggle controls, this could be any Bootstrap 4 btn classes
+     *                                          that you might want to add. Defaults to "btn-secondary".</li>
      *         <li><b>value       </b> string|int - The element's value.</li>
      *         <li><b>checked     </b> boolean - Whether to render this initially as checked.</li>
      *         <li><b>label       </b> string - The label for the checkbox element.</li>
index dc308e0..5bbb841 100644 (file)
@@ -257,6 +257,10 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/mean' => 'fa-calculator',
             'core:i/menu' => 'fa-ellipsis-v',
             'core:i/menubars' => 'fa-bars',
+            'core:i/messagecontentaudio' => 'fa-headphones',
+            'core:i/messagecontentimage' => 'fa-image',
+            'core:i/messagecontentvideo' => 'fa-film',
+            'core:i/messagecontentmultimediageneral' => 'fa-file-video-o',
             'core:i/mnethost' => 'fa-external-link',
             'core:i/moodle_host' => 'fa-graduation-cap',
             'core:i/moremenu' => 'fa-ellipsis-h',
index bcf2cc9..c8d7085 100644 (file)
@@ -1123,8 +1123,7 @@ class core_useragent {
         $extension = strtolower($extension);
 
         $supportedvideo = array('m4v', 'webm', 'ogv', 'mp4', 'mov');
-        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav');
-        // TODO MDL-56549 Flac will be supported in Firefox 51 in January 2017.
+        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav', 'flac');
 
         // Basic extension support.
         if (!in_array($extension, $supportedvideo) && !in_array($extension, $supportedaudio)) {
@@ -1158,6 +1157,11 @@ class core_useragent {
         if ($isogg && (self::is_ie() || self::is_edge() || self::is_safari() || self::is_safari_ios())) {
             return false;
         }
+        // FLAC is not supported in IE and Edge (below 16.0).
+        if ($extension === 'flac' &&
+                (self::is_ie() || (self::is_edge() && !self::check_edge_version('16.0')))) {
+            return false;
+        }
         // Wave is not supported in IE.
         if ($extension === 'wav' && self::is_ie()) {
             return false;
index 4253f7d..47a0392 100644 (file)
@@ -133,7 +133,7 @@ $capabilities = array(
         'riskbitmask' => RISK_XSS,
 
         'captype' => 'write',
-        'contextlevel' => CONTEXT_SYSTEM,
+        'contextlevel' => CONTEXT_COURSECAT,
         'archetypes' => array(
             'manager' => CAP_ALLOW
         )
@@ -782,10 +782,7 @@ $capabilities = array(
 
     'moodle/course:request' => array(
         'captype' => 'write',
-        'contextlevel' => CONTEXT_SYSTEM,
-        'archetypes' => array(
-            'user' => CAP_ALLOW,
-        )
+        'contextlevel' => CONTEXT_COURSECAT,
     ),
 
     'moodle/course:delete' => array(
index 71f216e..be9692d 100644 (file)
@@ -321,6 +321,6 @@ function xmldb_main_install() {
     make_default_scale();
     make_competence_scale();
 
-    require_once($CFG->libdir . '/badgeslib.php');
+    require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
     badges_install_default_backpacks();
 }
index 667b6c1..5f27ae1 100644 (file)
@@ -2028,16 +2028,18 @@ function xmldb_main_upgrade($oldversion) {
 
     if ($oldversion < 2018092800.02) {
         // Delete any contacts that are not mutual (meaning they both haven't added each other).
-        $sql = "SELECT c1.id
-                  FROM {message_contacts} c1
-             LEFT JOIN {message_contacts} c2
-                    ON c1.userid = c2.contactid
-                   AND c1.contactid = c2.userid
-                 WHERE c2.id IS NULL";
-        if ($contacts = $DB->get_records_sql($sql)) {
-            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
-            $DB->delete_records_select('message_contacts', "id $insql", $inparams);
-        }
+        do {
+            $sql = "SELECT c1.id
+                      FROM {message_contacts} c1
+                 LEFT JOIN {message_contacts} c2
+                        ON c1.userid = c2.contactid
+                       AND c1.contactid = c2.userid
+                     WHERE c2.id IS NULL";
+            if ($contacts = $DB->get_records_sql($sql, null, 0, 1000)) {
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
+                $DB->delete_records_select('message_contacts', "id $insql", $inparams);
+            }
+        } while ($contacts);
 
         upgrade_main_savepoint(true, 2018092800.02);
     }
@@ -3361,7 +3363,7 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Add default backpacks.
-        require_once($CFG->libdir.'/badgeslib.php'); // Core Upgrade-related functions for badges only.
+        require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
         badges_install_default_backpacks();
 
         // Main savepoint reached.
@@ -3609,5 +3611,13 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019100900.00);
     }
 
+    if ($oldversion < 2019101600.01) {
+
+        // Change the setting $CFG->requestcategoryselection into $CFG->lockrequestcategory with opposite value.
+        set_config('lockrequestcategory', !$CFG->requestcategoryselection);
+
+        upgrade_main_savepoint(true, 2019101600.01);
+    }
+
     return true;
 }
index cb20d06..2d5e675 100644 (file)
@@ -2159,23 +2159,26 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
-    if ($accelerate and !empty($CFG->xsendfile)) {
-        if (empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
-            header('Accept-Ranges: bytes');
-        } else {
-            header('Accept-Ranges: none');
-        }
+    if ($accelerate and empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
+        header('Accept-Ranges: bytes');
+    } else {
+        header('Accept-Ranges: none');
+    }
 
+    if ($accelerate) {
         if (is_object($file)) {
             $fs = get_file_storage();
-            if ($fs->xsendfile($file->get_contenthash())) {
-                return;
+            if ($fs->supports_xsendfile()) {
+                if ($fs->xsendfile($file->get_contenthash())) {
+                    return;
+                }
             }
-
         } else {
-            require_once("$CFG->libdir/xsendfilelib.php");
-            if (xsendfile($file)) {
-                return;
+            if (!empty($CFG->xsendfile)) {
+                require_once("$CFG->libdir/xsendfilelib.php");
+                if (xsendfile($file)) {
+                    return;
+                }
             }
         }
     }
@@ -2185,7 +2188,6 @@ function readfile_accel($file, $mimetype, $accelerate) {
     header('Last-Modified: '. gmdate('D, d M Y H:i:s', $lastmodified) .' GMT');
 
     if ($accelerate and empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
-        header('Accept-Ranges: bytes');
 
         if (!empty($_SERVER['HTTP_RANGE']) and strpos($_SERVER['HTTP_RANGE'],'bytes=') !== FALSE) {
             // byteserving stuff - for acrobat reader and download accelerators
@@ -2223,9 +2225,6 @@ function readfile_accel($file, $mimetype, $accelerate) {
                 byteserving_send_file($handle, $mimetype, $ranges, $filesize);
             }
         }
-    } else {
-        // Do not byteserve
-        header('Accept-Ranges: none');
     }
 
     header('Content-Length: '.$filesize);
index 743beb9..0334499 100644 (file)
@@ -1833,6 +1833,15 @@ class file_storage {
         return $this->filesystem->xsendfile($contenthash);
     }
 
+    /**
+     * Returns true if filesystem is configured to support xsendfile.
+     *
+     * @return bool
+     */
+    public function supports_xsendfile() {
+        return $this->filesystem->supports_xsendfile();
+    }
+
     /**
      * Content exists
      *
index 3f7d6f0..ae1d362 100644 (file)
@@ -443,6 +443,16 @@ abstract class file_system {
         return xsendfile($this->get_remote_path_from_hash($contenthash));
     }
 
+    /**
+     * Returns true if filesystem is configured to support xsendfile.
+     *
+     * @return bool
+     */
+    public function supports_xsendfile() {
+        global $CFG;
+        return !empty($CFG->xsendfile);
+    }
+
     /**
      * Validate that the content hash matches the content hash of the file on disk.
      *
index f1c3f75..fc01b1e 100644 (file)
@@ -1130,4 +1130,47 @@ class stored_file {
     public function compare_to_string($content) {
         return $this->get_contenthash() === file_storage::hash_from_string($content);
     }
+
+    /**
+     * Generate a rotated image for this stored_file based on exif information.
+     *
+     * @return array|false False when a problem occurs, else the image data and image size.
+     * @since Moodle 3.8
+     */
+    public function rotate_image() {
+        $content = $this->get_content();
+        $mimetype = $this->get_mimetype();
+
+        if ($mimetype === "image/jpeg" && function_exists("exif_read_data")) {
+            $exif = @exif_read_data("data://image/jpeg;base64," . base64_encode($content));
+            if (isset($exif['ExifImageWidth']) && isset($exif['ExifImageLength']) && isset($exif['Orientation'])) {
+                $rotation = [
+                    3 => -180,
+                    6 => -90,
+                    8 => -270,
+                ];
+                $orientation = $exif['Orientation'];
+                if ($orientation !== 1) {
+                    $source = @imagecreatefromstring($content);
+                    $data = @imagerotate($source, $rotation[$orientation], 0);
+                    if (!empty($data)) {
+                        if ($orientation == 1 || $orientation == 3) {
+                            $size = [
+                                'width' => $exif["ExifImageWidth"],
+                                'height' => $exif["ExifImageLength"],
+                            ];
+                        } else {
+                            $size = [
+                                'height' => $exif["ExifImageWidth"],
+                                'width' => $exif["ExifImageLength"],
+                            ];
+                        }
+                        imagedestroy($source);
+                        return [$data, $size];
+                    }
+                }
+            }
+        }
+        return [false, false];
+    }
 }
index da095b9..048b119 100644 (file)
@@ -97,11 +97,6 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -256,9 +251,21 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 311e233..094b05f 100644 (file)
@@ -100,11 +100,6 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -282,9 +277,21 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 2dd9b33..990ab73 100644 (file)
@@ -77,7 +77,12 @@ function form_init_date_js() {
     global $PAGE;
     static $done = false;
     if (!$done) {
+        $done = true;
         $calendar = \core_calendar\type_factory::get_calendar_instance();
+        if ($calendar->get_name() !== 'gregorian') {
+            // The YUI2 calendar only supports the gregorian calendar type.
+            return;
+        }
         $module   = 'moodle-form-dateselector';
         $function = 'M.form.dateselector.init_date_selectors';
         $defaulttimezone = date_default_timezone_get();
@@ -105,7 +110,6 @@ function form_init_date_js() {
             'december'          => date_format_string(strtotime("December 1"), '%B', $defaulttimezone)
         ));
         $PAGE->requires->yui_module($module, $function, $config);
-        $done = true;
     }
 }
 
index dc8b0b0..c8341c5 100644 (file)
@@ -1405,6 +1405,14 @@ function set_config($name, $value, $plugin=null) {
                 $config->value = $value;
                 $DB->insert_record('config', $config, false);
             }
+            // When setting config during a Behat test (in the CLI script, not in the web browser
+            // requests), remember which ones are set so that we can clear them later.
+            if (defined('BEHAT_TEST')) {
+                if (!property_exists($CFG, 'behat_cli_added_config')) {
+                    $CFG->behat_cli_added_config = [];
+                }
+                $CFG->behat_cli_added_config[$name] = true;
+            }
         }
         if ($name === 'siteidentifier') {
             cache_helper::update_site_identifier($value);
index dd0834b..3a450b8 100644 (file)
@@ -4200,32 +4200,32 @@ class action_menu implements renderable, templatable {
 
     /**
      * An icon to use for the toggling the secondary menu (dropdown).
-     * @var actionicon
+     * @var pix_icon
      */
     public $actionicon;
 
     /**
      * Any text to use for the toggling the secondary menu (dropdown).
-     * @var menutrigger
+     * @var string
      */
     public $menutrigger = '';
 
     /**
      * Any extra classes for toggling to the secondary menu.
-     * @var triggerextraclasses
+     * @var string
      */
     public $triggerextraclasses = '';
 
     /**
      * Place the action menu before all other actions.
-     * @var prioritise
+     * @var bool
      */
     public $prioritise = false;
 
     /**
      * Constructs the action menu with the given items.
      *
-     * @param array $actions An array of actions.
+     * @param array $actions An array of actions (action_menu_link|pix_icon|string).
      */
     public function __construct(array $actions = array()) {
         static $initialised = 0;
@@ -4259,7 +4259,6 @@ class action_menu implements renderable, templatable {
      * Sets the label for the menu trigger.
      *
      * @param string $label The text
-     * @return null
      */
     public function set_action_label($label) {
         $this->actionlabel = $label;
@@ -4270,7 +4269,6 @@ class action_menu implements renderable, templatable {
      *
      * @param string $trigger The text
      * @param string $extraclasses Extra classes to style the secondary menu toggle.
-     * @return null
      */
     public function set_menu_trigger($trigger, $extraclasses = '') {
         $this->menutrigger = $trigger;
index e0570e4..10124c0 100644 (file)
@@ -4511,10 +4511,12 @@ EOD;
      * @param int $limit limit the number of tags to display, if size of $tags is more than this limit the "more" link
      *               will be appended to the end, JS will toggle the rest of the tags
      * @param context $pagecontext specify if needed to overwrite the current page context for the view tag link
+     * @param bool $accesshidelabel if true, the label should have class="accesshide" added.
      * @return string
      */
-    public function tag_list($tags, $label = null, $classes = '', $limit = 10, $pagecontext = null) {
-        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext);
+    public function tag_list($tags, $label = null, $classes = '', $limit = 10,
+            $pagecontext = null, $accesshidelabel = false) {
+        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext, $accesshidelabel);
         return $this->render_from_template('core_tag/taglist', $list->export_for_template($this));
     }
 
index 8ab9a38..7aee57f 100644 (file)
@@ -1440,11 +1440,25 @@ function question_category_options($contexts, $top = false, $currentcat = 0,
             if ($category->contextid == $contextid) {
                 $cid = $category->id;
                 if ($currentcat != $cid || $currentcat == 0) {
-                    $countstring = !empty($category->questioncount) ?
-                            " ($category->questioncount)" : '';
-                    $categoriesarray[$contextstring][$cid] =
-                            format_string($category->indentedname, true,
-                                array('context' => $context)) . $countstring;
+                    $a = new stdClass;
+                    $a->name = format_string($category->indentedname, true,
+                            array('context' => $context));
+                    if ($category->idnumber !== null && $category->idnumber !== '') {
+                        $a->idnumber = s($category->idnumber);
+                    }
+                    if (!empty($category->questioncount)) {
+                        $a->questioncount = $category->questioncount;
+                    }
+                    if (isset($a->idnumber) && isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
+                    } else if (isset($a->idnumber)) {
+                        $formattedname = get_string('categorynamewithidnumber', 'question', $a);
+                    } else if (isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithcount', 'question', $a);
+                    } else {
+                        $formattedname = $a->name;
+                    }
+                    $categoriesarray[$contextstring][$cid] = $formattedname;
                 }
             }
         }
@@ -1875,14 +1889,14 @@ class question_edit_contexts {
     }
 
     /**
-     * @return array all parent contexts
+     * @return context[] all parent contexts
      */
     public function all() {
         return $this->allcontexts;
     }
 
     /**
-     * @return object lowest context which must be either the module or course context
+     * @return context lowest context which must be either the module or course context
      */
     public function lowest() {
         return $this->allcontexts[0];
@@ -1890,7 +1904,7 @@ class question_edit_contexts {
 
     /**
      * @param string $cap capability
-     * @return array parent contexts having capability, zero based index
+     * @return context[] parent contexts having capability, zero based index
      */
     public function having_cap($cap) {
         $contextswithcap = array();
@@ -1904,7 +1918,7 @@ class question_edit_contexts {
 
     /**
      * @param array $caps capabilities
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_cap($caps) {
         $contextswithacap = array();
@@ -1921,14 +1935,14 @@ class question_edit_contexts {
 
     /**
      * @param string $tabname edit tab name
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_edit_tab_cap($tabname) {
         return $this->having_one_cap(self::$caps[$tabname]);
     }
 
     /**
-     * @return those contexts where a user can add a question and then use it.
+     * @return context[] those contexts where a user can add a question and then use it.
      */
     public function having_add_and_use() {
         $contextswithcap = array();
@@ -1993,7 +2007,7 @@ class question_edit_contexts {
     /**
      * Throw error if at least one parent context hasn't got one of the caps $caps
      *
-     * @param array $cap capabilities
+     * @param array $caps capabilities
      */
     public function require_one_cap($caps) {
         if (!$this->have_one_cap($caps)) {
index 2b72e08..81a944a 100644 (file)
@@ -863,9 +863,9 @@ class flexible_table {
      * @return string contents of cell in column 'fullname', for this row.
      */
     function col_fullname($row) {
-        global $COURSE;
+        global $PAGE, $COURSE;
 
-        $name = fullname($row);
+        $name = fullname($row, has_capability('moodle/site:viewfullnames', $PAGE->context));
         if ($this->download) {
             return $name;
         }
index 4648b43..7ebc0d0 100644 (file)
         "togglegroup": "toggle-group",
         "label": "Select everything!",
         "checked": true,
-        "classes": "p-1",
+        "classes": "btn-primary btn-lg",
         "selectall": "Select all",
         "deselectall": "Deselect all"
     }
 }}
-<button type="button" id="{{id}}" name="{{name}}" class="btn btn-secondary {{classes}}"
+<button type="button" id="{{id}}" name="{{name}}" class="btn {{^classes}}btn-secondary{{/classes}}{{#classes}}{{.}}{{/classes}}"
         data-action="toggle"
         data-toggle="master"
         data-togglegroup="{{togglegroup}}"
index 1a514a4..7b6f149 100644 (file)
@@ -33,7 +33,8 @@
         "checked": true,
         "classes": "p-1",
         "selectall": "Select all",
-        "deselectall": "Deselect all"
+        "deselectall": "Deselect all",
+        "labelclasses": "badge badge-info"
     }
 }}
 <input id="{{id}}" name="{{name}}" type="checkbox" {{#classes}}class="{{.}}"{{/classes}} value="{{value}}"
index ac013ad..d68ba8e 100644 (file)
@@ -31,7 +31,8 @@
         "togglegroup": "toggle-group",
         "label": "Select me!",
         "checked": true,
-        "classes": "p-1"
+        "classes": "p-1",
+        "labelclasses": "badge badge-info"
     }
 }}
 <input id="{{id}}" name="{{name}}" type="checkbox" {{#classes}}class="{{.}}"{{/classes}} value="{{value}}"
index b1c9806..57a2a7a 100644 (file)
@@ -765,7 +765,7 @@ class behat_general extends behat_base {
      * @Then :preelement :preselectortype should appear before :postelement :postselectortype in the :containerelement :containerselectortype
      * @throws ExpectationException
      * @param string $preelement The locator of the preceding element
-     * @param string $preselectortype The locator of the preceding element
+     * @param string $preselectortype The selector type of the preceding element
      * @param string $postelement The locator of the latest element
      * @param string $postselectortype The selector type of the latest element
      * @param string $containerelement
@@ -779,7 +779,7 @@ class behat_general extends behat_base {
         ?string $containerelement = null,
         ?string $containerselectortype = null
     ) {
-        $msg = "'{$preelement}' '{$preselectortype}' does not appear after '{$postelement}' '{$postselectortype}'";
+        $msg = "'{$preelement}' '{$preselectortype}' does not appear before '{$postelement}' '{$postselectortype}'";
         $this->check_element_order(
             $containerelement,
             $containerselectortype,
@@ -800,7 +800,7 @@ class behat_general extends behat_base {
      * @param string $postelement The locator of the latest element
      * @param string $postselectortype The selector type of the latest element
      * @param string $preelement The locator of the preceding element
-     * @param string $preselectortype The locator of the preceding element
+     * @param string $preselectortype The selector type of the preceding element
      * @param string $containerelement
      * @param string $containerselectortype
      */
index 46b3c87..41f6f3d 100644 (file)
@@ -322,6 +322,7 @@ class behat_hooks extends behat_base {
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
         if ($suitename !== self::$runningsuite) {
+            self::$runningsuite = $suitename;
             behat_context_helper::set_environment($scope->getEnvironment());
 
             // We need the Mink session to do it and we do it only before the first scenario.
@@ -342,6 +343,12 @@ class behat_hooks extends behat_base {
 
             $this->getSession()->getSelectorsHandler()->registerSelector('named_partial', new $namedpartialclass());
             $this->getSession()->getSelectorsHandler()->registerSelector('named_exact', new $namedexactclass());
+
+            // Register component named selectors.
+            foreach (\core_component::get_component_names() as $component) {
+                $this->register_component_selectors_for_component($component);
+            }
+
         }
 
         // Reset mink session between the scenarios.
@@ -373,7 +380,6 @@ class behat_hooks extends behat_base {
         // Set the theme if not default.
         if ($suitename !== "default") {
             set_config('theme', $suitename);
-            self::$runningsuite = $suitename;
         }
 
         // Reset the scenariorunning variable to ensure that Step 0 occurs.
@@ -396,7 +402,7 @@ class behat_hooks extends behat_base {
      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
      * to the test being incorrectly marked as skipped with no way to force the test to be failed.
      *
-     * @param   BeforeStepScope $scope
+     * @param BeforeStepScope $scope
      * @BeforeStep
      */
     public function before_step(BeforeStepScope $scope) {
@@ -425,7 +431,6 @@ class behat_hooks extends behat_base {
                         new ExpectationException($message, $session)
                     );
 
-                self::$initprocessesfinished = true;
             }
             $this->scenariorunning = true;
         }
@@ -709,6 +714,58 @@ class behat_hooks extends behat_base {
     protected static function is_first_scenario() {
         return !(self::$initprocessesfinished);
     }
+
+    /**
+     * Register a set of component selectors.
+     *
+     * @param string $component
+     */
+    public function register_component_selectors_for_component(string $component): void {
+        $componentclassname = "behat_{$component}";
+
+        if (!behat_context_helper::has_context($componentclassname)) {
+            if ("core_" === substr($component, 0, 5)) {
+                $componentclassname = "behat_" . substr($component, 5);
+                if (!behat_context_helper::has_context($componentclassname)) {
+                    return;
+                }
+            } else {
+                return;
+            }
+        }
+
+        $context = behat_context_helper::get($componentclassname);
+        $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
+        $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
+
+        // Replacements must come before selectors as they are used in the selectors.
+        foreach ($context->get_named_replacements() as $replacement) {
+            $namedpartial->register_replacement($component, $replacement);
+            $namedexact->register_replacement($component, $replacement);
+        }
+
+        foreach ($context->get_partial_named_selectors() as $selector) {
+            $namedpartial->register_component_selector($component, $selector);
+        }
+
+        foreach ($context->get_exact_named_selectors() as $selector) {
+            $namedexact->register_component_selector($component, $selector);
+        }
+
+    }
+
+    /**
+     * Mark the first step as having been completed.
+     *
+     * This must be the last BeforeStep hook in the setup.
+     *
+     * @param BeforeStepScope $scope
+     * @BeforeStep
+     */
+    public function first_step_setup_complete(BeforeStepScope $scope) {
+        self::$initprocessesfinished = true;
+    }
+
 }
 
 /**
index 78c9262..58abfaf 100644 (file)
@@ -533,6 +533,196 @@ class behat_navigation extends behat_base {
         $USER = $globuser;
     }
 
+    /**
+     * Open a given page, belonging to a plugin or core component.
+     *
+     * The page-type are interpreted by each plugin to work out the
+     * corresponding URL. See the resolve_url method in each class like
+     * behat_mod_forum. That method should document which page types are
+     * recognised, and how the name identifies them.
+     *
+     * For pages belonging to core, the 'core > ' bit is omitted.
+     *
+     * @When I am on the :page page
+     * @param string $page the component and page name.
+     *      E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page(string $page) {
+        $this->getSession()->visit($this->locate_path(
+                $this->resolve_page_helper($page)->out_as_local_url()));
+    }
+
+    /**
+     * Open a given page logged in as a given user.
+     *
+     * This is like the combination
+     *   When I log in as "..."
+     *   And I am on the "..." page
+     * but with the advantage that you go straight to the desired page, without
+     * having to wait for the Dashboard to load.
+     *
+     * @When I am on the :page page logged in as :username
+     * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @param string $username the name of the user to log in as. E.g. 'admin'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_logged_in_as(string $page, string $username) {
+        self::execute('behat_auth::i_log_in_as', [$username, $this->resolve_page_helper($page)]);
+    }
+
+    /**
+     * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
+     *
+     * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @return moodle_url the corresponding URL.
+     */
+    protected function resolve_page_helper(string $page): moodle_url {
+        list($component, $name) = $this->parse_page_name($page);
+        if ($component === 'core') {
+            return $this->resolve_core_page_url($name);
+        } else {
+            $context = behat_context_helper::get('behat_' . $component);
+            return $context->resolve_page_url($name);
+        }
+    }
+
+    /**
+     * Parse a full page name like 'Admin notifications' or 'core_user > Preferences'.
+     *
+     * E.g. parsing 'mod_quiz > View' gives ['mod_quiz', 'View'].
+     *
+     * @param string $page the full page name
+     * @return array with two elements, component and page name.
+     */
+    protected function parse_page_name(string $page): array {
+        $dividercount = substr_count($page, ' > ');
+        if ($dividercount === 0) {
+            return ['core', $page];
+        } else if ($dividercount === 1) {
+            list($component, $name) = explode(' > ', $page);
+            if ($component === 'core') {
+                throw new coding_exception('Do not specify the component "core > ..." for core pages.');
+            }
+            return [$component, $name];
+        } else {
+            throw new coding_exception('The page name most be in the form ' .
+                    '"{page-name}" for core pages, or "{component} > {page-name}" ' .
+                    'for pages belonging to other components. ' .
+                    'For example "Admin notifications" or "mod_quiz > View".');
+        }
+    }
+
+    /**
+     * Open a given instance of a page, belonging to a plugin or core component.
+     *
+     * The instance identifier and page-type are interpreted by each plugin to
+     * work out the corresponding URL. See the resolve_page_instance_url method
+     * in each class like behat_mod_forum. That method should document which page
+     * types are recognised, and how the name identifies them.
+     *
+     * For pages belonging to core, the 'core > ' bit is omitted.
+     *
+     * @When I am on the :identifier :type page
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $type the component and page type. E.g. 'mod_quiz > View'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_instance(string $identifier, string $type) {
+        $this->getSession()->visit($this->locate_path(
+                $this->resolve_page_instance_helper($identifier, $type)->out_as_local_url()));
+    }
+
+    /**
+     * Open a given page logged in as a given user.
+     *
+     * This is like the combination
+     *   When I log in as "..."
+     *   And I am on the "..." "..." page
+     * but with the advantage that you go straight to the desired page, without
+     * having to wait for the Dashboard to load.
+     *
+     * @When I am on the :identifier :type page logged in as :username
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $type the component and page type. E.g. 'mod_quiz > View'.
+     * @param string $username the name of the user to log in as. E.g. 'student'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_instance_logged_in_as(string $identifier,
+            string $type, string $username) {
+        self::execute('behat_auth::i_log_in_as',
+                [$username, $this->resolve_page_instance_helper($identifier, $type)]);
+    }
+
+    /**
+     * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
+     *
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $pagetype the component and page type. E.g. 'mod_quiz > View'.
+     * @return moodle_url the corresponding URL.
+     */
+    protected function resolve_page_instance_helper(string $identifier, string $pagetype): moodle_url {
+        list($component, $type) = $this->parse_page_name($pagetype);
+        if ($component === 'core') {
+            return $this->resolve_core_page_instance_url($type, $identifier);
+        } else {
+            $context = behat_context_helper::get('behat_' . $component);
+            return $context->resolve_page_instance_url($type, $identifier);
+        }
+    }
+
+    /**
+     * Convert core page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * Recognised page names are:
+     * | Homepage            | Homepage (normally dashboard).                                 |
+     * | Admin notifications | Admin notification screen.                                     |
+     *
+     * @param string $name identifies which identifies this page, e.g. 'Homepage', 'Admin notifications'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_core_page_url(string $name): moodle_url {
+        switch ($name) {
+            case 'Homepage':
+                return new moodle_url('/');
+
+            case 'Admin notifications':
+                return new moodle_url('/admin/');
+
+            default:
+                throw new Exception('Unrecognised core page type "' . $name . '."');
+        }
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * Recognised page names are:
+     * | Page type     | Identifier meaning | description                          |
+     * | Category page | category idnumber  | List of courses in that category.    |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Category page'.
+     * @param string $identifier identifies the particular page, e.g. 'test-cat'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_core_page_instance_url(string $type, string $identifier): moodle_url {
+        global $DB;
+
+        switch ($type) {
+            case 'Category page':
+                $categoryid = $DB->get_field('course_categories', 'id', ['idnumber' => $identifier]);
+                if (!$categoryid) {
+                    throw new Exception('The specified category with idnumber "' . $identifier . '" does not exist');
+                }
+                return new moodle_url('/course/category.php', ['id' => $categoryid]);
+
+            default:
+                throw new Exception('Unrecognised core page type "' . $type . '."');
+        }
+    }
+
     /**
      * Opens the course homepage.
      *
diff --git a/lib/tests/core_media_player_native.php b/lib/tests/core_media_player_native.php
new file mode 100644 (file)
index 0000000..c7e925b
--- /dev/null
@@ -0,0 +1,163 @@
+<?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 for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player_native.php');
+
+/**
+ * Test for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @covers    core_media_player_native
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_media_player_native_testcase extends advanced_testcase {
+
+    /**
+     * Pre-test setup.
+     */
+    public function setUp() {
+        parent::setUp();
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Test method get_supported_extensions
+     */
+    public function test_get_supported_extensions() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Make sure that the list of extensions from the setting is exactly the same.
+        $player = new media_test_native_plugin();
+        $this->assertEmpty(array_diff($player->get_supported_extensions(), $nativeextensions));
+        $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
+
+    }
+
+    /**
+     * Test method list_supported_urls
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_test_native_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
+    /**
+     * Test method get_attribute
+     */
+    public function test_get_attribute() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertEquals($title, media_test_native_plugin::get_attribute($content, 'title'));
+    }
+
+    /**
+     * Test methods add_attributes and remove_attributes
+     */
+    public function test_add_remove_attributes() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Add attributes.
+        $content = media_test_native_plugin::add_attributes($content, ['preload' => 'none', 'controls' => 'true']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="true"~', $content);
+
+        // Change existing attribute.
+        $content = media_test_native_plugin::add_attributes($content, ['controls' => 'false']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove attributes.
+        $content = media_test_native_plugin::remove_attributes($content, ['title']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove another one.
+        $content = media_test_native_plugin::remove_attributes($content, ['preload']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertNotRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+    }
+
+    /**
+     * Test method replace_sources
+     */
+    public function test_replace_sources() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Test sources present.
+        $this->assertContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+
+        // Change sources.
+        $newsource = '<source src="http://example.org/new_filename.mp4" />';
+        $content = media_test_native_plugin::replace_sources($content, $newsource);
+        $this->assertContains($newsource, $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player.php b/lib/tests/fixtures/testable_core_media_player.php
new file mode 100644 (file)
index 0000000..f36b5c9
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Media player stub for testing purposes.
+ *
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_plugin extends core_media_player {
+    /** @var array Array of supported extensions */
+    public $ext;
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     * @param array $ext Array of supported extensions
+     */
+    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
+        $this->ext = $ext;
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        self::pick_video_size($width, $height);
+        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
+            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
+        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
+    }
+
+    /**
+     * Gets the list of file extensions supported by this media player.
+     *
+     * @return array Array of strings (extension not including dot e.g. '.mp3')
+     */
+    public function get_supported_extensions() {
+        return $this->ext;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player_native.php b/lib/tests/fixtures/testable_core_media_player_native.php
new file mode 100644 (file)
index 0000000..f484c77
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player_native.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Native media player stub for testing purposes.
+ *
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_native_plugin extends core_media_player_native {
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     */
+    public function __construct($num = 1, $rank = 13) {
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        $sources = array();
+        foreach ($urls as $url) {
+            $params = ['src' => $url];
+            $sources[] = html_writer::empty_tag('source', $params);
+        }
+
+        $sources = implode("\n", $sources);
+        $title = $this->get_name($name, $urls);
+        // Escape title but prevent double escaping.
+        $title = s(preg_replace(['/&amp;/', '/&gt;/', '/&lt;/'], ['&', '>', '<'], $title));
+
+        return <<<OET
+<video class="mediaplugin mediaplugin_test" title="$title">
+    $sources
+</video>
+OET;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
index 194cc19..8b230c9 100644 (file)
 /**
  * Test classes for handling embedded media (audio/video).
  *
- * @package core_media
- * @category phpunit
+ * @package   core
+ * @category  test
  * @copyright 2012 The Open University
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player.php');
 
 /**
  * Test script for media embedding.
@@ -495,42 +496,4 @@ class core_medialib_testcase extends advanced_testcase {
         }
         return $out;
     }
-}
-
-/**
- * Media player stub for testing purposes.
- */
-class media_test_plugin extends core_media_player {
-    /** @var array Array of supported extensions */
-    public $ext;
-    /** @var int Player rank */
-    public $rank;
-    /** @var int Arbitrary number */
-    public $num;
-
-    /**
-     * @param int $num Number (used in output)
-     * @param int $rank Player rank
-     * @param array $ext Array of supported extensions
-     */
-    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
-        $this->ext = $ext;
-        $this->rank = $rank;
-        $this->num = $num;
-    }
-
-    public function embed($urls, $name, $width, $height, $options) {
-        self::pick_video_size($width, $height);
-        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
-            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
-        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
-    }
-
-    public function get_supported_extensions() {
-        return $this->ext;
-    }
-
-    public function get_rank() {
-        return 10;
-    }
-}
+}
\ No newline at end of file
index 6314669..25bf48a 100644 (file)
@@ -37,7 +37,7 @@ require_once($CFG->libdir . '/tests/fixtures/testable_flexible_table.php');
  * @copyright  2013 Damyon Wiese <damyon@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_tablelib_testcase extends basic_testcase {
+class core_tablelib_testcase extends advanced_testcase {
     protected function generate_columns($cols) {
         $columns = array();
         foreach (range(0, $cols - 1) as $j) {
@@ -352,6 +352,67 @@ class core_tablelib_testcase extends basic_testcase {
         );
     }
 
+    /**
+     * Data provider for test_fullname_column
+     *
+     * @return array
+     */
+    public function fullname_column_provider() {
+        return [
+            ['language'],
+            ['alternatename lastname'],
+            ['firstname lastnamephonetic'],
+        ];
+    }
+
+    /**
+     * Test fullname column observes configured alternate fullname format configuration
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column(string $format) {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        set_config('alternativefullnameformat', $format);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, true), $table->format_row($user)['fullname']);
+    }
+
+    /**
+     * Test fullname column ignores fullname format configuration for a user with viewfullnames capability prohibited
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column_prohibit_viewfullnames(string $format) {
+        global $DB, $CFG;
+
+        $this->resetAfterTest();
+
+        set_config('alternativefullnameformat', $format);
+
+        $currentuser = $this->getDataGenerator()->create_user();
+        $this->setUser($currentuser);
+
+        // Prohibit the viewfullnames from the default user role.
+        $userrole = $DB->get_record('role', ['id' => $CFG->defaultuserroleid]);
+        role_change_permission($userrole->id, context_system::instance(), 'moodle/site:viewfullnames', CAP_PROHIBIT);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, false), $table->format_row($user)['fullname']);
+    }
+
     public function test_get_row_html() {
         $data = $this->generate_data(1, 5);
         $columns = $this->generate_columns(5);
index 88d411a..2f785c6 100644 (file)
@@ -2,7 +2,7 @@ This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
 === 3.8 ===
-
+* The rotate_image function has been added to the stored_file class (MDL-63349)
 * The yui checknet module is removed. Call \core\session\manager::keepalive instead.
 * The generate_uuid() function has been deprecated. Please use \core\uuid::generate() instead.
 * Remove lib/pear/auth/RADIUS.php (MDL-65746)
@@ -22,6 +22,8 @@ information provided here is intended especially for developers.
     at least a single checkbox item is selected or not.
 * Final deprecation (removal) of the core/modal_confirm dialogue.
 * Upgrade scssphp to v1.0.2, This involves renaming classes from Leafo => ScssPhp as the repo has changed.
+* Implement supports_xsendfile() method and allow support for xsendfile in alternative_file_system_class
+  independently of local files (MDL-66304).
 * The methods get_local_path_from_storedfile and get_remote_path_from_storedfile in lib/filestore/file_system.php
   are now public. If you are overriding these then you will need to change your methods to public in your class.
 * $CFG->httpswwwroot has been removed. It is no longer necessary as loginhttps has already been removed and it's no longer being
index f5ce3e9..66840eb 100644 (file)
@@ -29,6 +29,7 @@ require_once($CFG->dirroot.'/user/lib.php');
 require_once('change_password_form.php');
 require_once($CFG->libdir.'/authlib.php');
 require_once($CFG->dirroot.'/webservice/lib.php');
+require_once('lib.php');
 
 $id     = optional_param('id', SITEID, PARAM_INT); // current course
 $return = optional_param('return', 0, PARAM_BOOL); // redirect after password change
@@ -133,6 +134,9 @@ if ($mform->is_cancelled()) {
 
     $strpasswordchanged = get_string('passwordchanged');
 
+    // Plugins can perform post password change actions once data has been validated.
+    core_login_post_change_password_requests($data);
+
     $fullname = fullname($USER, true);
 
     $PAGE->set_title($strpasswordchanged);
index 9f2b77d..d1c978a 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 class login_change_password_form extends moodleform {
 
@@ -75,6 +76,9 @@ class login_change_password_form extends moodleform {
         $mform->addElement('hidden', 'id', 0);
         $mform->setType('id', PARAM_INT);
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_change_password_form($mform, $USER);
+
         // buttons
         if (get_user_preferences('auth_forcepasswordchange')) {
             $this->add_action_buttons(false);
@@ -89,6 +93,9 @@ class login_change_password_form extends moodleform {
         $errors = parent::validation($data, $files);
         $reason = null;
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_change_password_form($data, $USER));
+
         // ignore submitted username
         if (!$user = authenticate_user_login($USER->username, $data['password'], true, $reason, false)) {
             $errors['password'] = get_string('invalidlogin');
index 67a4d9f..4ad2447 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 /**
  * Reset forgotten password form definition.
@@ -46,6 +47,9 @@ class login_forgot_password_form extends moodleform {
         $mform    = $this->_form;
         $mform->setDisableShortforms(true);
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_forgot_password_form($mform);
+
         $mform->addElement('header', 'searchbyusername', get_string('searchbyusername'), '');
 
         $purpose = user_edit_map_field_purpose($USER->id, 'username');
@@ -74,6 +78,10 @@ class login_forgot_password_form extends moodleform {
     function validation($data, $files) {
 
         $errors = parent::validation($data, $files);
+
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_forgot_password_form($data));
+
         $errors += core_login_validate_forgot_password_data($data);
 
         return $errors;
index a04970a..bbf0e70 100644 (file)
@@ -30,10 +30,21 @@ require_once('lib.php');
 redirect_if_major_upgrade_required();
 
 $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly
-$anchor      = optional_param('anchor', '', PARAM_RAW);      // Used to restore hash anchor to wantsurl.
+$anchor      = optional_param('anchor', '', PARAM_RAW);     // Used to restore hash anchor to wantsurl.
 
 $resendconfirmemail = optional_param('resendconfirmemail', false, PARAM_BOOL);
 
+// It might be safe to do this for non-Behat sites, or there might
+// be a security risk. For now we only allow it on Behat sites.
+// If you wants to do the analysis, you may be able to remove the
+// if (BEHAT_SITE_RUNNING).
+if (defined('BEHAT_SITE_RUNNING') && BEHAT_SITE_RUNNING) {
+    $wantsurl    = optional_param('wantsurl', '', PARAM_LOCALURL);   // Overrides $SESSION->wantsurl if given.
+    if ($wantsurl !== '') {
+        $SESSION->wantsurl = (new moodle_url($wantsurl))->out(false);
+    }
+}
+
 $context = context_system::instance();
 $PAGE->set_url("$CFG->wwwroot/login/index.php");
 $PAGE->set_context($context);
index 4264333..b391ea7 100644 (file)
@@ -51,6 +51,9 @@ function core_login_process_password_reset_request() {
         }
         list($status, $notice, $url) = core_login_process_password_reset($username, $email);
 
+        // Plugins can perform post forgot password actions once data has been validated.
+        core_login_post_forgot_password_requests($data);
+
         // Any email has now been sent.
         // Next display results to requesting user if settings permit.
         echo $OUTPUT->header();
@@ -283,6 +286,10 @@ function core_login_process_password_set($token) {
 
         $urltogo = core_login_get_return_url();
         unset($SESSION->wantsurl);
+
+        // Plugins can perform post set password actions once data has been validated.
+        core_login_post_set_password_requests($data, $user);
+
         redirect($urltogo, get_string('passwordset'), 1);
     }
 }
@@ -399,3 +406,181 @@ function core_login_pre_signup_requests() {
         }
     }
 }
+
+/**
+ * Plugins can extend forms.
+ */
+
+ /** Inject form elements into change_password_form.
+  * @param mform $mform the form to inject elements into.
+  * @param stdClass $user the user object to use for context.
+  */
+function core_login_extend_change_password_form($mform, $user) {
+    $callbacks = get_plugins_with_function('extend_change_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform, $user);
+        }
+    }
+}
+
+ /** Inject form elements into set_password_form.
+  * @param mform $mform the form to inject elements into.
+  * @param stdClass $user the user object to use for context.
+  */
+function core_login_extend_set_password_form($mform, $user) {
+    $callbacks = get_plugins_with_function('extend_set_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform, $user);
+        }
+    }
+}
+
+ /** Inject form elements into forgot_password_form.
+  * @param mform $mform the form to inject elements into.
+  */
+function core_login_extend_forgot_password_form($mform) {
+    $callbacks = get_plugins_with_function('extend_forgot_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform);
+        }
+    }
+}
+
+ /** Inject form elements into signup_form.
+  * @param mform $mform the form to inject elements into.
+  */
+function core_login_extend_signup_form($mform) {
+    $callbacks = get_plugins_with_function('extend_signup_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform);
+        }
+    }
+}
+
+/**
+ * Plugins can add additional validation to forms.
+ */
+
+/** Inject validation into change_password_form.
+ * @param array $data the data array from submitted form values.
+ * @param stdClass $user the user object to use for context.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_change_password_form($data, $user) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_change_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data, $user);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into set_password_form.
+ * @param array $data the data array from submitted form values.
+ * @param stdClass $user the user object to use for context.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_set_password_form($data, $user) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_set_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data, $user);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into forgot_password_form.
+ * @param array $data the data array from submitted form values.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_forgot_password_form($data) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_forgot_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into signup_form.
+ * @param array $data the data array from submitted form values.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_signup_form($data) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_signup_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/**
+ * Plugins can perform post submission actions.
+ */
+
+/** Post change_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_change_password_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_change_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
+/** Post set_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ * @param stdClass $user the user object for set_password context.
+ */
+function core_login_post_set_password_requests($data, $user) {
+    $pluginsfunction = get_plugins_with_function('post_set_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data, $user);
+        }
+    }
+}
+
+/** Post forgot_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_forgot_password_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_forgot_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
+/** Post signup_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_signup_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_signup_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
index 6cee4e9..1b29fb5 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 /**
  * Set forgotten password form definition.
@@ -78,6 +79,10 @@ class login_set_password_form extends moodleform {
         $mform->addRule('password2', get_string('required'), 'required', null, 'client');
         $mform->setType('password2', PARAM_RAW);
 
+        // Hook for plugins to extend form definition.
+        $user = $this->_customdata;
+        core_login_extend_set_password_form($mform, $user);
+
         $this->add_action_buttons(true);
     }
 
@@ -92,6 +97,9 @@ class login_set_password_form extends moodleform {
 
         $errors = parent::validation($data, $files);
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_set_password_form($data, $user));
+
         // Ignore submitted username.
         if ($data['password'] !== $data['password2']) {
             $errors['password'] = get_string('passwordsdiffer');
index dd2e2ce..d46f453 100644 (file)
@@ -86,6 +86,9 @@ if ($mform_signup->is_cancelled()) {
     // Add missing required fields.
     $user = signup_setup_new_user($user);
 
+    // Plugins can perform post sign up actions once data has been validated.
+    core_login_post_signup_requests($user);
+
     $authplugin->user_signup($user, true); // prints notice and link to login/index.php
     exit; //never reached
 }
index ca975aa..8dc5354 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/profile/lib.php');
 require_once($CFG->dirroot . '/user/editlib.php');
+require_once('lib.php');
 
 class login_signup_form extends moodleform implements renderable, templatable {
     function definition() {
@@ -97,6 +98,9 @@ class login_signup_form extends moodleform implements renderable, templatable {
             $mform->closeHeaderBefore('recaptcha_element');
         }
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_signup_form($mform);
+
         // Add "Agree to sitepolicy" controls. By default it is a link to the policy text and a checkbox but
         // it can be implemented differently in custom sitepolicy handlers.
         $manager = new \core_privacy\local\sitepolicy\manager();
@@ -128,6 +132,9 @@ class login_signup_form extends moodleform implements renderable, templatable {
     public function validation($data, $files) {
         $errors = parent::validation($data, $files);
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_signup_form($data));
+
         if (signup_captcha_enabled()) {
             $recaptchaelement = $this->_form->getElement('recaptcha_element');
             if (!empty($this->_form->_submitValues['g-recaptcha-response'])) {
index 0c243c2..7dfe8e4 100644 (file)
@@ -60,7 +60,7 @@ class media_html5audio_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         global $CFG;
         require_once($CFG->libdir . '/filelib.php');
 
@@ -72,6 +72,25 @@ class media_html5audio_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_audio');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/audio.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5audio_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index 7caf405..3def9af 100644 (file)
@@ -60,7 +60,7 @@ class media_html5video_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         $nativeextensions = file_get_typegroup('extension', 'html_video');
 
         // Make sure that the list of extensions from the setting is exactly the same as html_video group.
@@ -69,6 +69,25 @@ class media_html5video_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_video');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5video_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index 5a801a3..f95afe3 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index d60dd27..9f43297 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js.map and b/message/amd/build/message_drawer_view_overview_section.min.js.map differ
index a675ab6..e708a44 100644 (file)
@@ -27,6 +27,7 @@ define(
     'core/notification',
     'core/pubsub',
     'core/str',
+    'core/pending',
     'core/templates',
     'core/user_date',
     'core_message/message_repository',
@@ -42,6 +43,7 @@ function(
     Notification,
     PubSub,
     Str,
+    Pending,
     Templates,
     UserDate,
     MessageRepository,
@@ -200,62 +202,120 @@ function(
      * @return {Object} jQuery promise.
      */
     var render = function(conversations, userId) {
-        var formattedConversations = conversations.map(function(conversation) {
 
-            var lastMessage = conversation.messages.length ? conversation.messages[conversation.messages.length - 1] : null;
+        // Helper to format the last message for rendering.
+        // Returns a promise which resolves to either a string, or null
+        // (such as in the event of an empty personal space).
+        var pending = new Pending();
 
-            var formattedConversation = {
-                id: conversation.id,
-                imageurl: conversation.imageurl,
-                name: conversation.name,
-                subname: conversation.subname,
-                unreadcount: conversation.unreadcount,
-                ismuted: conversation.ismuted,
-                lastmessagedate: lastMessage ? lastMessage.timecreated : null,
-                sentfromcurrentuser: lastMessage ? lastMessage.useridfrom == userId : null,
-                lastmessage: lastMessage ? $(lastMessage.text).text() || lastMessage.text : null
-            };
-
-            var otherUser = null;
-            if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.SELF) {
-                // Self-conversations have only one member.
-                otherUser = conversation.members[0];
-            } else if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PRIVATE) {
-                // For private conversations, remove the current userId from the members to get the other user.
-                otherUser = conversation.members.reduce(function(carry, member) {
-                    if (!carry && member.id != userId) {
-                        carry = member;
-                    }
-                    return carry;
-                }, null);
+        var formatMessagePreview = async function(lastMessage) {
+            if (!lastMessage) {
+                return null;
+            }
+            var isMedia = lastMessage.text.includes('src');
+
+            if (!isMedia) {
+                // Try to get the text value of the content.
+                // If that's not possible, we'll report it under the catch-all 'other media'.
+                var messagePreview = $(lastMessage.text).text();
+                if (messagePreview) {
+                    return messagePreview;
+                }
             }
 
-            if (otherUser !== null) {
-                formattedConversation.userid = otherUser.id;
-                formattedConversation.showonlinestatus = otherUser.showonlinestatus;
-                formattedConversation.isonline = otherUser.isonline;
-                formattedConversation.isblocked = otherUser.isblocked;
+            // As a fallback, report unknowns as 'other media' type content.
+            var pix = 'i/messagecontentmultimediageneral';
+            var label = 'messagecontentmultimediageneral';
+
+            if (lastMessage.text.includes('<img')) {
+                pix = 'i/messagecontentimage';
+                label = 'messagecontentimage';
+            } else if (lastMessage.text.includes('<video')) {
+                pix = 'i/messagecontentvideo';
+                label = 'messagecontentvideo';
+            } else if (lastMessage.text.includes('<audio')) {
+                pix = 'i/messagecontentaudio';
+                label = 'messagecontentaudio';
             }
 
-            if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PUBLIC) {
-                formattedConversation.lastsendername = conversation.members.reduce(function(carry, member) {
-                    if (!carry && lastMessage && member.id == lastMessage.useridfrom) {
-                        carry = member.fullname;
-                    }
-                    return carry;
-                }, null);
+            try {
+                var labelString = await Str.get_string(label, 'core_message');
+                var icon = await Templates.renderPix(pix, 'core', labelString);
+                return icon + ' ' + labelString;
+            } catch (error) {
+                Notification.exception(error);
+                return null;
             }
+        };
 
-            return formattedConversation;
-        });
+        var mapPromises = conversations.map(function(conversation) {
 
-        formattedConversations.forEach(function(conversation) {
-            if (new Date().toDateString() == new Date(conversation.lastmessagedate * 1000).toDateString()) {
-                conversation.istoday = true;
-            }
+            var lastMessage = conversation.messages.length ? conversation.messages[conversation.messages.length - 1] : null;
+
+            return formatMessagePreview(lastMessage)
+                .then(function(messagePreview) {
+                    var formattedConversation = {
+                        id: conversation.id,
+                        imageurl: conversation.imageurl,
+                        name: conversation.name,
+                        subname: conversation.subname,
+                        unreadcount: conversation.unreadcount,
+                        ismuted: conversation.ismuted,
+                        lastmessagedate: lastMessage ? lastMessage.timecreated : null,
+                        sentfromcurrentuser: lastMessage ? lastMessage.useridfrom == userId : null,
+                        lastmessage: messagePreview
+                    };
+
+                    var otherUser = null;
+                    if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.SELF) {
+                        // Self-conversations have only one member.
+                        otherUser = conversation.members[0];
+                    } else if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PRIVATE) {
+                        // For private conversations, remove the current userId from the members to get the other user.
+                        otherUser = conversation.members.reduce(function(carry, member) {
+                            if (!carry && member.id != userId) {
+                                carry = member;
+                            }
+                            return carry;
+                        }, null);
+                    }
+
+                    if (otherUser !== null) {
+                        formattedConversation.userid = otherUser.id;
+                        formattedConversation.showonlinestatus = otherUser.showonlinestatus;
+                        formattedConversation.isonline = otherUser.isonline;
+                        formattedConversation.isblocked = otherUser.isblocked;
+                    }
+
+                    if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PUBLIC) {
+                        formattedConversation.lastsendername = conversation.members.reduce(function(carry, member) {
+                            if (!carry && lastMessage && member.id == lastMessage.useridfrom) {
+                                carry = member.fullname;
+                            }
+                            return carry;
+                        }, null);
+                    }
+
+                    return formattedConversation;
+                }).catch(Notification.exception);
         });
 
-        return Templates.render(TEMPLATES.CONVERSATIONS_LIST, {conversations: formattedConversations});
+        return Promise.all(mapPromises)
+            .then(function(formattedConversations) {
+                formattedConversations.forEach(function(conversation) {
+                    if (new Date().toDateString() == new Date(conversation.lastmessagedate * 1000).toDateString()) {
+                        conversation.istoday = true;
+                    }
+                });
+
+                return Templates.render(TEMPLATES.CONVERSATIONS_LIST, {conversations: formattedConversations});
+            }).then(function(html, js) {
+                pending.resolve();
+                return $.Deferred().resolve(html, js);
+            }).catch(function(error) {
+                pending.resolve();
+                Notification.exception(error);
+            });
     };
 
     /**
index d3ebc9c..6b68239 100644 (file)
@@ -78,7 +78,7 @@
                         {{#str}} sender, core_message, {{.}} {{/str}}
                     {{/lastsendername}}
                 {{/sentfromcurrentuser}}
-                <span class="text-muted">{{lastmessage}}</span>
+                <span class="text-muted">{{{lastmessage}}}</span>
             </p>
         </div>
         <div class="d-flex align-self-stretch">
index cd8bc43..28731d9 100644 (file)
@@ -37,6 +37,69 @@ require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
  */
 class behat_message extends behat_base {
 
+    /**
+     * Return the list of partial named selectors.
+     *
+     * @return array
+     */
+    public static function get_partial_named_selectors(): array {
+        return [
+            new behat_component_named_selector('Message', [".//*[@data-conversation-id]//img[%altMatch%]/.."]),
+            new behat_component_named_selector('Message conversation', [
+                <<<XPATH
+    .//*[@data-region='message-drawer' and contains(., %locator%)]//div[@data-region='content-message-container']
+XPATH
+            ], false),
+            new behat_component_named_selector('Message header', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='header-content' and contains(., %locator%)]
+XPATH
+            ]),
+            new behat_component_named_selector('Message member', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@class='list-group' and not(contains(@class, 'hidden'))]//*[%core_message/textMatch%]
+XPATH
+                , <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@data-region='empty-message-container' and not(contains(@class, 'hidden')) and contains(., %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message tab', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//button[@data-toggle='collapse' and contains(string(), %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message list area', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[contains(@data-region, concat('view-overview-', %locator%))]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message content', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[@data-region='message' and @data-message-id and contains(., %locator%)]
+XPATH
+            ], false),
+        ];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [
+            new behat_component_named_replacement('textMatch', 'text()[contains(., %locator%)]'),
+        ];
+    }
+
     /**
      * Open the messaging UI.
      *
@@ -57,7 +120,7 @@ class behat_message extends behat_base {
     public function i_open_the_conversations_list(string $tab) {
         $this->execute('behat_general::i_click_on', [
             $this->escape($tab),
-            'group_message_tab'
+            'core_message > Message tab'
         ]);
     }
 
@@ -213,7 +276,7 @@ class behat_message extends behat_base {
         $this->execute('behat_general::i_click_on',
             array(
                 $this->escape($conversationname),
-                'group_message',
+                'core_message > Message',
             )
         );
     }
index 9de5bc3..1504d8e 100644 (file)
@@ -40,15 +40,15 @@ Feature: Delete messages from conversations
   Scenario: Delete a message sent by the user from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "3" in the "[data-region='message-selected-court']" "css_element"
 #   Clicking to unselect
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -57,19 +57,19 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a group conversation; one sent by another user.
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -78,9 +78,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should not see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should not see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for other users
     And I log out
@@ -94,10 +94,10 @@ Feature: Delete messages from conversations
   Scenario: Cancel deleting two messages from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -105,7 +105,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a private conversation
@@ -115,7 +115,7 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -124,9 +124,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a private conversation; one sent by another user
@@ -136,9 +136,9 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -147,9 +147,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for the other user
     And I log out
@@ -168,8 +168,8 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -177,7 +177,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a favorite conversation
@@ -188,7 +188,7 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -197,8 +197,8 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a favourite conversation; one sent by another user
@@ -209,9 +209,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -220,9 +220,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Cancel deleting two messages from a favourite conversation
@@ -233,8 +233,8 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -242,7 +242,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Check an empty favourite conversation is still favourite
@@ -253,9 +253,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in the "favourites" conversations list
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
-    And I click on "Are you free?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
+    And I click on "Are you free?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
     And I should see "Delete"
index 842b55e..51ca228 100644 (file)
@@ -31,35 +31,35 @@ Feature: Star and unstar conversations
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should not see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Unstar a group conversation
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Star a private conversation
     Given the following "private messages" exist:
@@ -68,15 +68,15 @@ Feature: Star and unstar conversations
     Then I log in as "student1"
     And I open messaging
     And I open the "Private" conversations list
-    And "Student 2" "group_message" should exist
+    And "Student 2" "core_message > Message" should exist
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 2" in the "messages" "group_message_list_area"
+    And I should not see "Student 2" in the "messages" "core_message > Message list area"
 
   Scenario: Unstar a private conversation
     Given the following "private messages" exist:
@@ -87,12 +87,12 @@ Feature: Star and unstar conversations
       | student1 | student2 |
     Then I log in as "student1"
     And I open messaging
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 2" in the "messages" "group_message_list_area"
\ No newline at end of file
+    And I should see "Student 2" in the "messages" "core_message > Message list area"
index e990727..bde9a02 100644 (file)
@@ -47,26 +47,26 @@ Feature: Create conversations for course's groups
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should exist
+    And "Group 3" "core_message > Message" should not exist
     And I log out
     And I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should not exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should not exist
+    And "Group 3" "core_message > Message" should not exist
 
   Scenario: View group conversation's participants numbers
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "5 participants" in the "Group 1" "group_message_header"
+    And I should see "5 participants" in the "Group 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "1 participants" in the "Group 2" "group_message_header"
+    And I should see "1 participants" in the "Group 2" "core_message > Message header"
 
   Scenario: View group conversation's participants list
     Given I log in as "teacher1"
@@ -75,20 +75,20 @@ Feature: Create conversations for course's groups
     # Check Group 1 participants list.
     And I select "Group 1" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "Student 0" "group_message_member" should exist
-    And "Student 1" "group_message_member" should exist
-    And "Student 2" "group_message_member" should exist
-    And "Student 3" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "Student 0" "core_message > Message member" should exist
+    And "Student 1" "core_message > Message member" should exist
+    And "Student 2" "core_message > Message member" should exist
+    And "Student 3" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     # Check Group 2 participants list.
     And I select "Group 2" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "No participants" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "No participants" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
 
   Scenario: Check group conversation members are synced when a new group member is added
     Given I log in as "teacher1"
@@ -99,13 +99,13 @@ Feature: Create conversations for course's groups
     And I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "6 participants" in the "Group 1" "group_message_header"
+    And I should see "6 participants" in the "Group 1" "core_message > Message header"
     And I open messaging information
-    And "Student 4" "group_message_member" should exist
+    And "Student 4" "core_message > Message member" should exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "2 participants" in the "Group 2" "group_message_header"
+    And I should see "2 participants" in the "Group 2" "core_message > Message header"
     And I open messaging information
-    And "No participants" "group_message_member" should not exist
-    And "Student 4" "group_message_member" should exist
+    And "No participants" "core_message > Message member" should not exist
+    And "Student 4" "core_message > Message member" should exist
index 183c07a..5d7d65a 100644 (file)
@@ -35,17 +35,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Cancel deleting a private conversation
     Given I log in as "student1"
@@ -57,8 +57,8 @@ Feature: Message delete conversations
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Delete a starred conversation
     Given the following "favourite conversations" exist:
@@ -73,17 +73,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Cancel deleting a starred conversation
     Given the following "favourite conversations" exist:
@@ -92,16 +92,16 @@ Feature: Message delete conversations
     When I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "favourites" conversations list
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I open contact menu
     And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
 #   Cancel deletion, so conversation should be there
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Check a deleted starred conversation is still starred
     Given the following "favourite conversations" exist:
@@ -115,10 +115,10 @@ Feature: Message delete conversations
     Then I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
-    And I should not see "Student 2" in the "favourites" "group_message_list_area"
+    And I should not see "Student 2" in the "favourites" "core_message > Message list area"
     And I send "Hi!" message to "Student 2" user
     And I go back in "view-conversation" message drawer
     And I go back in "view-search" message drawer
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
index 45f1a93..519cee1 100644 (file)
@@ -31,23 +31,23 @@ Feature: Message send messages
     Given I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    Then I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
 
   Scenario: Send a message to a starred conversation
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
+    Then "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in the "group-messages" conversations list
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
@@ -56,11 +56,11 @@ Feature: Message send messages
     And I should see "Group 1"
     And I select "Group 1" conversation in the "favourites" conversations list
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "Group" "group_message_tab"
+    And I should not see "Group 1" in the "Group" "core_message > Message tab"
 
   Scenario: Send a message to a private conversation via contact tab
     Given the following "message contacts" exist:
@@ -71,17 +71,17 @@ Feature: Message send messages
     And I click on "Contacts" "link"
     And I click on "Student 2" "link" in the "//*[@data-section='contacts']" "xpath_element"
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Try to send a message to a private conversation is not contact but you are allowed to send a message
     Given I log in as "student1"
     And I open messaging
     When I send "Hi!" message to "Student 2" user
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in messaging
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
\ No newline at end of file
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
index 4a40e60..ca30488 100644 (file)
@@ -33,15 +33,15 @@ Feature: Mute and unmute conversations
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
 
   Scenario: Mute a private conversation
     When I log in as "student1"
@@ -49,14 +49,14 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
 
   Scenario: Unmute a group conversation
     Given the following "muted group conversations" exist:
@@ -65,15 +65,15 @@ Feature: Mute and unmute conversations
     When I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
 
   Scenario: Unmute a private conversation
     Given the following "muted private conversations" exist:
@@ -84,11 +84,11 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
index 5063b3c..ea7a669 100644 (file)
@@ -15,47 +15,47 @@ Feature: Self conversation
   Scenario: Self conversation exists
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I should see "Personal space"
 
   Scenario: Self conversation can be unstarred
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I open contact menu
-    And I click on "Unstar" "link" in the "Student 1" "group_message_header"
+    And I click on "Unstar" "link" in the "Student 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 1" in the "messages" "group_message_list_area"
+    And I should see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Self conversation can be deleted
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I open contact menu
-    And I click on "Delete conversation" "link" in the "Student 1" "group_message_header"
+    And I click on "Delete conversation" "link" in the "Student 1" "core_message > Message header"
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 1" in the "messages" "group_message_list_area"
+    And I should not see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Send a message to a self-conversation via message drawer
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Send a message to a self-conversation via user profile
     Given I log in as "student1"
@@ -63,5 +63,5 @@ Feature: Self conversation
     Then I should see "Message"
     And I click on "Message" "icon"
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
index 97f5ffa..bf6095e 100644 (file)
@@ -31,23 +31,23 @@ Feature: Unread messages
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "New group" "group_message" should exist
+    Then "New group" "core_message > Message" should exist
     And I select "New group" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "New group" "group_message_conversation"
-    And I should see "##today##j F##" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "New group" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Group" "group_message_tab"
-    And "New group" "group_message" should exist
-    And I should see "1" in the "New group" "group_message"
+    And I should see "1" in the "Group" "core_message > Message tab"
+    And "New group" "core_message > Message" should exist
+    And I should see "1" in the "New group" "core_message > Message"
     And I select "New group" conversation in messaging
-    And I should see "Hi!" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Group" "group_message_tab"
-    And I should not see "1" in the "New group" "group_message"
+    And I should not see "1" in the "Group" "core_message > Message tab"
+    And I should not see "1" in the "New group" "core_message > Message"
 
   Scenario: Unread messages for private conversation
     Given the following "private messages" exist:
@@ -57,14 +57,14 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Private" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Private" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Private" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Private" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
 
   Scenario: Unread messages for starred conversation
     Given the following "private messages" exist:
@@ -77,11 +77,11 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Starred" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Starred" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Starred" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Starred" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
index 6060236..9c02a10 100644 (file)
@@ -56,6 +56,10 @@ class document_services {
     const STAMPS_FILEAREA = 'stamps';
     /** Filename for combined pdf */
     const COMBINED_PDF_FILENAME = 'combined.pdf';
+    /**  Temporary place to save JPG Image to PDF file */
+    const TMP_JPG_TO_PDF_FILEAREA = 'tmp_jpg_to_pdf';
+    /**  Temporary place to save (Automatically) Rotated JPG FILE */
+    const TMP_ROTATED_JPG_FILEAREA = 'tmp_rotated_jpg';
     /** Hash of blank pdf */
     const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
 
@@ -187,9 +191,28 @@ EOD;
                 $pluginfiles = $plugin->get_files($submission, $user);
                 foreach ($pluginfiles as $filename => $file) {
                     if ($file instanceof \stored_file) {
-                        if ($file->get_mimetype() === 'application/pdf') {
+                        $mimetype = $file->get_mimetype();
+                        // PDF File, no conversion required.
+                        if ($mimetype === 'application/pdf') {
                             $files[$filename] = $file;
-                        } else if ($convertedfile = $converter->start_conversion($file, 'pdf')) {
+                        } else if ($plugin->allow_image_conversion() && $mimetype === "image/jpeg") {
+                            // Rotates image based on the EXIF value.
+                            list ($rotateddata, $size) = $file->rotate_image();
+                            if ($rotateddata) {
+                                $file = self::save_rotated_image_file($assignment, $userid, $attemptnumber,
+                                    $rotateddata, $filename);
+                            }
+                            // Save as PDF file if there is no available converter.
+                            if (!$converter->can_convert_format_to('jpg', 'pdf')) {
+                                $pdffile = self::save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size);
+                                if ($pdffile) {
+                                    $files[$filename] = $pdffile;
+                                }
+                            }
+                        }
+                        // The file has not been converted to PDF, try to convert it to PDF.
+                        if (!isset($files[$filename])
+                            && $convertedfile = $converter->start_conversion($file, 'pdf')) {
                             $files[$filename] = $convertedfile;
                         }
                     } else if ($converter->can_convert_format_to('html', 'pdf')) {
@@ -967,4 +990,83 @@ EOD;
         }
         return null;
     }
+
+    /**
+     * Convert jpg file to pdf file
+     * @param int|\assign $assignment Assignment
+     * @param int $userid User ID
+     * @param int $attemptnumber Attempt Number
+     * @param \stored_file $file file to save
+     * @param null|array $size size of image
+     * @return \stored_file
+     * @throws \file_exception
+     * @throws \stored_file_creation_exception
+     */
+    private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
+        // Temporary file.
+        $filename = $file->get_filename();
+        $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
+            . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
+            . self::hash($assignment, $userid, $attemptnumber));
+        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
+        // Determine orientation.
+        $orientation = 'P';
+        if (!empty($size['width']) && !empty($size['height'])) {
+            if ($size['width'] > $size['height']) {
+                $orientation = 'L';
+            }
+        }
+        // Save JPG image to PDF file.
+        $pdf = new pdf();
+        $pdf->SetHeaderMargin(0);
+        $pdf->SetFooterMargin(0);
+        $pdf->SetMargins(0, 0, 0, true);
+        $pdf->setPrintFooter(false);
+        $pdf->setPrintHeader(false);
+        $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
+        $pdf->AddPage($orientation);
+        $pdf->SetAutoPageBreak(false);
+        // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
+        if ($orientation == 'P') {
+            $pdf->Image('@' . $file->get_content(), 0, 0, 210);
+        } else {
+            $pdf->Image('@' . $file->get_content(), 0, 0, 297);
+        }
+        $pdf->setPageMark();
+        $pdf->save_pdf($tempfile);
+        $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
+        $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
+        if (file_exists($tempfile)) {
+            unlink($tempfile);
+            rmdir($tmpdir);
+        }
+        return $pdffile;
+    }
+
+    /**
+     * Save rotated image data to file.
+     * @param int|\assign $assignment Assignment
+     * @param int $userid User ID
+     * @param int $attemptnumber Attempt Number
+     * @param resource $rotateddata image data to save
+     * @param string $filename name of the image file
+     * @return \stored_file
+     * @throws \file_exception
+     * @throws \stored_file_creation_exception
+     */
+    private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
+        $filearea = self::TMP_ROTATED_JPG_FILEAREA;
+        $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
+            . $filearea . DIRECTORY_SEPARATOR
+            . self::hash($assignment, $userid, $attemptnumber));
+        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
+        imagejpeg($rotateddata, $tempfile);
+        $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
+        if (file_exists($tempfile)) {
+            unlink($tempfile);
+            rmdir($tmpdir);
+        }
+        return $newfile;
+    }
+
 }
index 7ad838a..e0e0539 100644 (file)
@@ -637,4 +637,12 @@ class assign_submission_file extends assign_submission_plugin {
 
         return $sets;
     }
+
+    /**
+     * Determine if the plugin allows image file conversion
+     * @return bool
+     */
+    public function allow_image_conversion() {
+        return true;
+    }
 }
index 8bbcb9a..2280cd0 100644 (file)
@@ -146,4 +146,12 @@ abstract class assign_submission_plugin extends assign_plugin {
     public function submission_is_empty(stdClass $data) {
         return false;
     }
+
+    /**
+     * Determine if the plugin allows image file conversion
+     * @return bool
+     */
+    public function allow_image_conversion() {
+        return false;
+    }