Merge branch 'MDL-65056-master' of git://github.com/abias/moodle
authorSara Arjona <sara@moodle.com>
Wed, 24 Apr 2019 07:18:25 +0000 (09:18 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 24 Apr 2019 07:18:25 +0000 (09:18 +0200)
190 files changed:
admin/renderer.php
admin/tool/analytics/classes/output/form/import_model.php
admin/tool/analytics/cli/guess_course_start_and_end.php
admin/tool/dataprivacy/classes/output/data_requests_table.php
admin/tool/log/backup/moodle2/restore_tool_log_logstore_subplugin.class.php
admin/tool/log/store/database/backup/moodle2/restore_logstore_database_subplugin.class.php
admin/tool/log/store/standard/backup/moodle2/restore_logstore_standard_subplugin.class.php
admin/tool/log/store/standard/tests/fixtures/event.php
admin/tool/log/store/standard/tests/store_test.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/xmldb/tests/behat/mandatory_persistent_fields.feature [new file with mode: 0644]
analytics/tests/fixtures/db_analytics_php/no_teaching.php
analytics/tests/manager_test.php
analytics/tests/model_test.php
analytics/tests/stats_test.php
backup/util/helper/restore_log_rule.class.php
backup/util/helper/tests/restore_log_rule_test.php
calendar/externallib.php
calendar/tests/externallib_test.php
course/classes/analytics/target/course_competencies.php [moved from lib/classes/analytics/target/course_competencies.php with 92% similarity]
course/classes/analytics/target/course_completion.php [moved from lib/classes/analytics/target/course_completion.php with 90% similarity]
course/classes/analytics/target/course_dropout.php [moved from lib/classes/analytics/target/course_dropout.php with 92% similarity]
course/classes/analytics/target/course_enrolments.php [moved from lib/classes/analytics/target/course_enrolments.php with 90% similarity]
course/classes/analytics/target/course_gradetopass.php [moved from lib/classes/analytics/target/course_gradetopass.php with 94% similarity]
course/classes/analytics/target/no_teaching.php [moved from lib/classes/analytics/target/no_teaching.php with 95% similarity]
course/classes/category.php
course/classes/search/customfield.php
course/tests/behat/course_browsing.feature
course/tests/search_test.php
enrol/externallib.php
enrol/tests/externallib_test.php
enrol/upgrade.txt
lang/en/analytics.php
lang/en/course.php
lang/en/message.php
lang/en/moodle.php
lang/en/role.php
lang/en/user.php
lib/amd/build/form-autocomplete.min.js
lib/amd/src/form-autocomplete.js
lib/classes/message/manager.php
lib/classes/message/message.php
lib/db/access.php
lib/db/analytics.php
lib/db/install.xml
lib/db/renamedclasses.php
lib/db/services.php
lib/db/upgrade.php
lib/ddl/database_manager.php
lib/form/autocomplete.php
lib/form/select.php
lib/form/submit.php
lib/form/tags.php
lib/form/templates/element-autocomplete-inline.mustache
lib/form/templates/element-autocomplete.mustache
lib/form/templates/element-select-inline.mustache
lib/form/templates/element-select.mustache
lib/form/templates/element-submit-inline.mustache
lib/form/templates/element-submit.mustache
lib/form/tests/autocomplete_test.php
lib/messagelib.php
lib/moodlelib.php
lib/outputrenderers.php
lib/tests/messagelib_test.php
lib/tests/moodlelib_test.php
lib/tests/targets_test.php
lib/upgrade.txt
login/lib.php
login/tests/lib_test.php
media/player/vimeo/wsplayer.php
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer_router.min.js
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_conversation_constants.min.js
message/amd/build/message_drawer_view_conversation_patcher.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_overview.min.js
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_search.min.js
message/amd/build/message_repository.min.js
message/amd/src/message_drawer.js
message/amd/src/message_drawer_router.js
message/amd/src/message_drawer_view_conversation.js
message/amd/src/message_drawer_view_conversation_constants.js
message/amd/src/message_drawer_view_conversation_patcher.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/amd/src/message_drawer_view_overview.js
message/amd/src/message_drawer_view_overview_section.js
message/amd/src/message_drawer_view_search.js
message/amd/src/message_repository.js
message/classes/api.php
message/classes/helper.php
message/classes/privacy/provider.php
message/classes/task/migrate_message_data.php
message/externallib.php
message/index.php
message/output/email/classes/output/email/renderer.php [new file with mode: 0644]
message/output/email/classes/output/email/renderer_textemail.php [new file with mode: 0644]
message/output/email/classes/output/email_digest.php [new file with mode: 0644]
message/output/email/classes/output/renderer.php [new file with mode: 0644]
message/output/email/classes/privacy/provider.php
message/output/email/classes/task/send_email_task.php [new file with mode: 0644]
message/output/email/db/install.xml [new file with mode: 0644]
message/output/email/db/tasks.php [new file with mode: 0644]
message/output/email/db/upgrade.php
message/output/email/lang/en/message_email.php
message/output/email/message_output_email.php
message/output/email/templates/email_digest_html.mustache [new file with mode: 0644]
message/output/email/templates/email_digest_text.mustache [new file with mode: 0644]
message/output/email/tests/send_email_task_test.php [new file with mode: 0644]
message/output/email/version.php
message/templates/message_drawer_contacts_list.mustache
message/templates/message_drawer_conversations_list.mustache
message/templates/message_drawer_icon_back.mustache
message/templates/message_drawer_messages_list.mustache
message/templates/message_drawer_non_contacts_list.mustache
message/templates/message_drawer_view_contacts_header.mustache
message/templates/message_drawer_view_conversation_body.mustache
message/templates/message_drawer_view_conversation_body_message.mustache
message/templates/message_drawer_view_conversation_header_content_type_private.mustache
message/templates/message_drawer_view_conversation_header_content_type_private_no_controls.mustache
message/templates/message_drawer_view_conversation_header_content_type_public.mustache
message/templates/message_drawer_view_conversation_header_content_type_self.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_placeholder.mustache
message/templates/message_drawer_view_group_info_participants_list.mustache
message/templates/message_drawer_view_overview_body.mustache
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_search_body.mustache
message/templates/message_drawer_view_search_header.mustache
message/templates/message_drawer_view_settings_header.mustache
message/templates/message_index.mustache
message/tests/api_test.php
message/tests/behat/behat_message.php
message/tests/behat/group_conversation.feature
message/tests/externallib_test.php
message/tests/messagelib_test.php
message/upgrade.txt
mod/forum/amd/build/inpage_reply.min.js [new file with mode: 0644]
mod/forum/amd/build/posts_list.min.js [new file with mode: 0644]
mod/forum/amd/build/repository.min.js
mod/forum/amd/build/selectors.min.js
mod/forum/amd/src/inpage_reply.js [new file with mode: 0644]
mod/forum/amd/src/posts_list.js [new file with mode: 0644]
mod/forum/amd/src/repository.js
mod/forum/amd/src/selectors.js
mod/forum/classes/local/entities/discussion.php
mod/forum/classes/local/exporters/post.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/classes/post_form.php
mod/forum/classes/subscriptions.php
mod/forum/db/services.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/post.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/forum_discussion.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_post.mustache
mod/forum/templates/forum_discussion_threaded_posts.mustache
mod/forum/templates/inpage_reply.mustache [new file with mode: 0644]
mod/forum/tests/behat/add_forum_inline.feature [new file with mode: 0644]
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_display.feature
mod/forum/tests/behat/edit_tags.feature
mod/forum/tests/behat/forum_subscriptions_default.feature
mod/forum/tests/behat/inpage_reply.feature [new file with mode: 0644]
mod/forum/tests/behat/posts_ordering_blog.feature
mod/forum/tests/behat/posts_ordering_general.feature
mod/forum/tests/behat/separate_group_discussions.feature
mod/forum/tests/behat/separate_group_single_group_discussions.feature
mod/forum/tests/behat/visible_group_discussions.feature
mod/forum/tests/entities_discussion_test.php
mod/forum/tests/subscriptions_test.php
mod/lesson/pagetypes/shortanswer.php
mod/lti/templates/tool_proxy_card.mustache
privacy/tests/provider_test.php
question/engine/tests/helpers.php
theme/boost/scss/moodle/message.scss
theme/boost/style/moodle.css
theme/boost/tests/behat/group_conversation.feature [deleted file]
theme/classic/style/moodle.css
theme/classic/templates/core/footer.mustache [deleted file]
user/classes/analytics/target/upcoming_activities_due.php
user/lib.php
user/tests/behat/delete_users.feature
user/tests/behat/view_full_profile.feature
user/tests/userlib_test.php
version.php

index 3bc74ec..922617a 100644 (file)
@@ -1062,7 +1062,7 @@ class core_admin_renderer extends plugin_renderer_base {
 
                 if ($isstandard = $plugin->is_standard()) {
                     $row->attributes['class'] .= ' standard';
-                    $sourcelabel = html_writer::span(get_string('sourcestd', 'core_plugin'), 'sourcetext label');
+                    $sourcelabel = html_writer::span(get_string('sourcestd', 'core_plugin'), 'sourcetext badge badge-secondary');
                 } else {
                     $row->attributes['class'] .= ' extension';
                     $sourcelabel = html_writer::span(get_string('sourceext', 'core_plugin'), 'sourcetext badge badge-info');
@@ -1074,7 +1074,7 @@ class core_admin_renderer extends plugin_renderer_base {
 
                 $statuscode = $plugin->get_status();
                 $row->attributes['class'] .= ' status-' . $statuscode;
-                $statusclass = 'statustext label ';
+                $statusclass = 'statustext badge ';
                 switch ($statuscode) {
                     case core_plugin_manager::PLUGIN_STATUS_NEW:
                         $statusclass .= $dependenciesok ? 'badge-success' : 'badge-warning';
@@ -2025,7 +2025,7 @@ class core_admin_renderer extends plugin_renderer_base {
                     $messagetype = 'ok';
                     $statusclass = 'badge-success';
                 }
-                $status = html_writer::span($status, 'label ' . $statusclass);
+                $status = html_writer::span($status, 'badge ' . $statusclass);
                 // Here we'll store all the feedback found
                 $feedbacktext = '';
                 // Append the feedback if there is some
index 1b4c375..7130246 100644 (file)
@@ -26,6 +26,8 @@ namespace tool_analytics\output\form;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->libdir.'/formslib.php');
+
 /**
  * Model upload form.
  *
index e0c8974..f8a8aa5 100644 (file)
@@ -204,9 +204,9 @@ function tool_analytics_calculate_course_dates($course, $options) {
 
                 $updateit = false;
                 if ($course->enddate < $course->startdate) {
-                    $notification .= PHP_EOL . '  ' . get_string('errorendbeforestart', 'analytics', userdate($course->enddate));
+                    $notification .= PHP_EOL . '  ' . get_string('errorendbeforestart', 'course', userdate($course->enddate));
                 } else if ($course->startdate + (YEARSECS + (WEEKSECS * 4)) > $course->enddate) {
-                    $notification .= PHP_EOL . '  ' . get_string('coursetoolong', 'analytics');
+                    $notification .= PHP_EOL . '  ' . get_string('coursetoolong', 'course');
                 } else {
                     $notification .= PHP_EOL . '  ' . get_string('enddate') . ': ' . userdate($course->enddate);
                     $updateit = true;
index 303c774..af33f98 100644 (file)
@@ -190,7 +190,7 @@ class data_requests_table extends table_sql {
      * @return mixed
      */
     public function col_status($data) {
-        return html_writer::span($data->statuslabel, 'label ' . $data->statuslabelclass);
+        return html_writer::span($data->statuslabel, 'badge ' . $data->statuslabelclass);
     }
 
     /**
index c48c2c8..1e2ceb8 100644 (file)
@@ -42,10 +42,11 @@ abstract class restore_tool_log_logstore_subplugin extends restore_subplugin {
      * discard or save every log entry.
      *
      * @param array $data log entry.
+     * @param bool $jsonformat If true, uses JSON format for the 'other' field
      * @return object|null $dataobject A data object with values for one or more fields in the record,
      *  or null if we are not going to process the log.
      */
-    protected function process_log($data) {
+    protected function process_log($data, bool $jsonformat = false) {
         $data = (object) $data;
 
         // Complete the information that does not come from backup.
@@ -87,7 +88,7 @@ abstract class restore_tool_log_logstore_subplugin extends restore_subplugin {
         // There is no need to roll dates. Logs are supposed to be immutable. See MDL-44961.
 
         // Revert other to its original php way.
-        $data->other = unserialize(base64_decode($data->other));
+        $data->other = \tool_log\helper\reader::decode_other(base64_decode($data->other));
 
         // Arrived here, we have both 'objectid' and 'other' to be converted. This is the tricky part.
         // Both are pointing to other records id, but the sources are not identified in the
@@ -137,8 +138,6 @@ abstract class restore_tool_log_logstore_subplugin extends restore_subplugin {
                         }
                     }
                 }
-                // Now we want to serialize it so we can store it in the DB.
-                $data->other = serialize($data->other);
             } else {
                 $message = "Event class not found: \"$eventclass\". Skipping log record.";
                 $this->log($message, backup::LOG_DEBUG);
@@ -146,6 +145,13 @@ abstract class restore_tool_log_logstore_subplugin extends restore_subplugin {
             }
         }
 
+        // Serialize 'other' field so we can store it in the DB.
+        if ($jsonformat) {
+            $data->other = json_encode($data->other);
+        } else {
+            $data->other = serialize($data->other);
+        }
+
         return $data;
     }
 }
index 9eca1bb..06c6fe2 100644 (file)
@@ -93,7 +93,7 @@ class restore_logstore_database_subplugin extends restore_tool_log_logstore_subp
             return;
         }
 
-        $data = $this->process_log($data);
+        $data = $this->process_log($data, get_config('logstore_database', 'jsonformat'));
 
         if ($data) {
             self::$extdb->insert_record(self::$extdbtablename, $data);
index ac041e4..beeec88 100644 (file)
@@ -60,7 +60,7 @@ class restore_logstore_standard_subplugin extends restore_tool_log_logstore_subp
     public function process_logstore_standard_log($data) {
         global $DB;
 
-        $data = $this->process_log($data);
+        $data = $this->process_log($data, get_config('logstore_standard', 'jsonformat'));
 
         if ($data) {
             $DB->insert_record('logstore_standard_log', $data);
index efeac2e..3f5490e 100644 (file)
@@ -44,4 +44,14 @@ class unittest_executed extends \core\event\base {
     public function get_url() {
         return new \moodle_url('/somepath/somefile.php', array('id' => $this->data['other']['sample']));
     }
+
+    /**
+     * The 'other' fields for this event do not need to mapped during backup and restore as they
+     * only contain test values, not IDs for anything on the course.
+     *
+     * @return array Empty array
+     */
+    public static function get_other_mapping(): array {
+        return [];
+    }
 }
index 25963af..1492718 100644 (file)
@@ -395,6 +395,138 @@ class logstore_standard_store_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Checks that backup and restore of log data works correctly.
+     *
+     * @param bool $jsonformat True to test with JSON format
+     * @dataProvider test_log_writing_provider
+     * @throws moodle_exception
+     */
+    public function test_backup_restore(bool $jsonformat) {
+        global $DB;
+        $this->resetAfterTest();
+        $this->preventResetByRollback();
+
+        // Enable logging plugin.
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+        set_config('buffersize', 0, 'logstore_standard');
+        $manager = get_log_manager(true);
+
+        // User must be enrolled in course.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+        $this->setUser($user);
+
+        // Apply JSON format system setting.
+        set_config('jsonformat', $jsonformat ? 1 : 0, 'logstore_standard');
+
+        // Create some log data in a course - one with other data, one without.
+        \logstore_standard\event\unittest_executed::create([
+                'context' => context_course::instance($course->id),
+                'other' => ['sample' => 5, 'xx' => 10]])->trigger();
+        $this->waitForSecond();
+        \logstore_standard\event\unittest_executed::create([
+                'context' => context_course::instance($course->id)])->trigger();
+
+        $records = array_values($DB->get_records('logstore_standard_log',
+                ['courseid' => $course->id, 'target' => 'unittest'], 'timecreated'));
+        $this->assertCount(2, $records);
+
+        // Work out expected 'other' values based on JSON format.
+        $expected0 = [
+            false => 'a:2:{s:6:"sample";i:5;s:2:"xx";i:10;}',
+            true => '{"sample":5,"xx":10}'
+        ];
+        $expected1 = [
+            false => 'N;',
+            true => 'null'
+        ];
+
+        // Backup the course twice, including log data.
+        $this->setAdminUser();
+        $backupid1 = $this->backup($course);
+        $backupid2 = $this->backup($course);
+
+        // Restore it with same jsonformat.
+        $newcourseid = $this->restore($backupid1, $course, '_A');
+
+        // Check entries are correctly encoded.
+        $records = array_values($DB->get_records('logstore_standard_log',
+                ['courseid' => $newcourseid, 'target' => 'unittest'], 'timecreated'));
+        $this->assertCount(2, $records);
+        $this->assertEquals($expected0[$jsonformat], $records[0]->other);
+        $this->assertEquals($expected1[$jsonformat], $records[1]->other);
+
+        // Change JSON format to opposite value and restore again.
+        set_config('jsonformat', $jsonformat ? 0 : 1, 'logstore_standard');
+        $newcourseid = $this->restore($backupid2, $course, '_B');
+
+        // Check entries are correctly encoded in other format.
+        $records = array_values($DB->get_records('logstore_standard_log',
+                ['courseid' => $newcourseid, 'target' => 'unittest'], 'timecreated'));
+        $this->assertEquals($expected0[!$jsonformat], $records[0]->other);
+        $this->assertEquals($expected1[!$jsonformat], $records[1]->other);
+    }
+
+    /**
+     * Backs a course up to temp directory.
+     *
+     * @param stdClass $course Course object to backup
+     * @return string ID of backup
+     */
+    protected function backup($course): string {
+        global $USER, $CFG;
+        require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+
+        // Turn off file logging, otherwise it can't delete the file (Windows).
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+
+        // Do backup with default settings. MODE_IMPORT means it will just
+        // create the directory and not zip it.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course->id,
+                backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
+                $USER->id);
+        $bc->get_plan()->get_setting('users')->set_status(backup_setting::NOT_LOCKED);
+        $bc->get_plan()->get_setting('users')->set_value(true);
+        $bc->get_plan()->get_setting('logs')->set_value(true);
+        $backupid = $bc->get_backupid();
+
+        $bc->execute_plan();
+        $bc->destroy();
+        return $backupid;
+    }
+
+    /**
+     * Restores a course from temp directory.
+     *
+     * @param string $backupid Backup id
+     * @param \stdClass $course Original course object
+     * @param string $suffix Suffix to add after original course shortname and fullname
+     * @return int New course id
+     * @throws restore_controller_exception
+     */
+    protected function restore(string $backupid, $course, string $suffix): int {
+        global $USER, $CFG;
+        require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+        // Do restore to new course with default settings.
+        $newcourseid = restore_dbops::create_new_course(
+                $course->fullname . $suffix, $course->shortname . $suffix, $course->category);
+        $rc = new restore_controller($backupid, $newcourseid,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id,
+                backup::TARGET_NEW_COURSE);
+        $rc->get_plan()->get_setting('logs')->set_value(true);
+        $rc->get_plan()->get_setting('users')->set_value(true);
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        return $newcourseid;
+    }
+
     /**
      * Disable the garbage collector if it's enabled to ensure we don't adjust memory statistics.
      */
index bd49699..1e1bd2b 100644 (file)
@@ -280,6 +280,15 @@ class api {
             $settings->tool_mobile_apppolicy = get_config('tool_mobile', 'apppolicy');
         }
 
+        if (empty($section) or $section == 'calendar') {
+            $settings->calendartype = $CFG->calendartype;
+            $settings->calendar_site_timeformat = $CFG->calendar_site_timeformat;
+            $settings->calendar_startwday = $CFG->calendar_startwday;
+            $settings->calendar_adminseesall = $CFG->calendar_adminseesall;
+            $settings->calendar_lookahead = $CFG->calendar_lookahead;
+            $settings->calendar_maxevents = $CFG->calendar_maxevents;
+        }
+
         return $settings;
     }
 
@@ -351,6 +360,7 @@ class api {
                 $coursemodules['$mmCourseDelegate_mmaMod' . ucfirst($mod->name)] = $mod->displayname;
             }
         }
+        asort($coursemodules);
 
         $remoteaddonslist = array();
         $mobileplugins = self::get_plugins_supporting_mobile();
@@ -371,6 +381,22 @@ class api {
             'recentlyaccessedcourses' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedCourses',
             'starredcourses' => 'CoreBlockDelegate_AddonBlockStarredCourses',
             'recentlyaccesseditems' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedItems',
+            'badges' => 'CoreBlockDelegate_AddonBlockBadges',
+            'blog_menu' => 'CoreBlockDelegate_AddonBlockBlogMenu',
+            'blog_recent' => 'CoreBlockDelegate_AddonBlockBlogRecent',
+            'blog_tags' => 'CoreBlockDelegate_AddonBlockBlogTags',
+            'calendar_month' => 'CoreBlockDelegate_AddonBlockCalendarMonth',
+            'calendar_upcoming' => 'CoreBlockDelegate_AddonBlockCalendarUpcoming',
+            'comments' => 'CoreBlockDelegate_AddonBlockComments',
+            'completionstatus' => 'CoreBlockDelegate_AddonBlockCompletionStatus',
+            'feedback' => 'CoreBlockDelegate_AddonBlockFeedback',
+            'glossary_random' => 'CoreBlockDelegate_AddonBlockGlossaryRandom',
+            'html' => 'CoreBlockDelegate_AddonBlockHtml',
+            'lp' => 'CoreBlockDelegate_AddonBlockLp',
+            'news_items' => 'CoreBlockDelegate_AddonBlockNewsItems',
+            'online_users' => 'CoreBlockDelegate_AddonBlockOnlineUsers',
+            'selfcompletion' => 'CoreBlockDelegate_AddonBlockSelfCompletion',
+            'tags' => 'CoreBlockDelegate_AddonBlockTags',
         );
 
         foreach ($availableblocks as $block) {
@@ -378,23 +404,35 @@ class api {
                 $courseblocks[$appsupportedblocks[$block->name]] = $block->displayname;
             }
         }
+        asort($courseblocks);
 
         $features = array(
-            'NoDelegate_CoreOffline' => new lang_string('offlineuse', 'tool_mobile'),
-            '$mmLoginEmailSignup' => new lang_string('startsignup'),
+            "$general" => array(
+                'NoDelegate_CoreOffline' => new lang_string('offlineuse', 'tool_mobile'),
+                'NoDelegate_SiteBlocks' => new lang_string('blocks'),
+                'NoDelegate_CoreComments' => new lang_string('comments'),
+                'NoDelegate_CoreRating' => new lang_string('ratings', 'rating'),
+                'NoDelegate_CoreTag' => new lang_string('tags'),
+                '$mmLoginEmailSignup' => new lang_string('startsignup'),
+                'NoDelegate_ResponsiveMainMenuItems' => new lang_string('responsivemainmenuitems', 'tool_mobile'),
+            ),
             "$mainmenu" => array(
-                '$mmSideMenuDelegate_mmCourses' => new lang_string('mycourses'),
                 '$mmSideMenuDelegate_mmaFrontpage' => new lang_string('sitehome'),
-                '$mmSideMenuDelegate_mmaGrades' => new lang_string('grades', 'grades'),
-                '$mmSideMenuDelegate_mmaCompetency' => new lang_string('myplans', 'tool_lp'),
+                '$mmSideMenuDelegate_mmCourses' => new lang_string('mycourses'),
+                'CoreMainMenuDelegate_CoreCoursesDashboard' => new lang_string('myhome'),
+                '$mmSideMenuDelegate_mmaCalendar' => new lang_string('calendar', 'calendar'),
                 '$mmSideMenuDelegate_mmaNotifications' => new lang_string('notifications', 'message'),
                 '$mmSideMenuDelegate_mmaMessages' => new lang_string('messages', 'message'),
-                '$mmSideMenuDelegate_mmaCalendar' => new lang_string('calendar', 'calendar'),
+                '$mmSideMenuDelegate_mmaGrades' => new lang_string('grades', 'grades'),
+                '$mmSideMenuDelegate_mmaCompetency' => new lang_string('myplans', 'tool_lp'),
+                'CoreMainMenuDelegate_AddonBlog' => new lang_string('blog', 'blog'),
                 '$mmSideMenuDelegate_mmaFiles' => new lang_string('files'),
                 '$mmSideMenuDelegate_website' => new lang_string('webpage'),
                 '$mmSideMenuDelegate_help' => new lang_string('help'),
             ),
             "$course" => array(
+                'NoDelegate_CourseBlocks' => new lang_string('blocks'),
+                'CoreCourseOptionsDelegate_AddonBlog' => new lang_string('blog', 'blog'),
                 '$mmCoursesDelegate_search' => new lang_string('search'),
                 '$mmCoursesDelegate_mmaCompetency' => new lang_string('competencies', 'competency'),
                 '$mmCoursesDelegate_mmaParticipants' => new lang_string('participants'),
@@ -405,6 +443,7 @@ class api {
                 'NoDelegate_CoreCoursesDownload' => new lang_string('downloadcourses', 'tool_mobile'),
             ),
             "$user" => array(
+                'CoreCourseOptionsDelegate_AddonBlog' => new lang_string('blog', 'blog'),
                 '$mmUserDelegate_mmaBadges' => new lang_string('badges', 'badges'),
                 '$mmUserDelegate_mmaCompetency:learningPlan' => new lang_string('competencies', 'competency'),
                 '$mmUserDelegate_mmaCourseCompletion:viewCompletion' => new lang_string('coursecompletion', 'completion'),
index 0b01cd0..1ce5887 100644 (file)
@@ -98,3 +98,4 @@ $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, the
 $string['getmoodleonyourmobile'] = 'Get the mobile app';
 $string['privacy:metadata:preference:tool_mobile_autologin_request_last'] = 'The date of the last auto-login key request. Between each request 6 minutes are required.';
 $string['privacy:metadata:core_userkey'] = 'User\'s keys used to create auto-login key for the current user.';
+$string['responsivemainmenuitems'] = 'Responsive menu items';
index 1fee731..f3d1815 100644 (file)
@@ -177,6 +177,12 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
             array('name' => 'tool_mobile_disabledfeatures', 'value' => ''),
             array('name' => 'tool_mobile_custommenuitems', 'value' => ''),
             array('name' => 'tool_mobile_apppolicy', 'value' => ''),
+            array('name' => 'calendartype', 'value' => $CFG->calendartype),
+            array('name' => 'calendar_site_timeformat', 'value' => $CFG->calendar_site_timeformat),
+            array('name' => 'calendar_startwday', 'value' => $CFG->calendar_startwday),
+            array('name' => 'calendar_adminseesall', 'value' => $CFG->calendar_adminseesall),
+            array('name' => 'calendar_lookahead', 'value' => $CFG->calendar_lookahead),
+            array('name' => 'calendar_maxevents', 'value' => $CFG->calendar_maxevents),
         );
         $this->assertCount(0, $result['warnings']);
         $this->assertEquals($expected, $result['settings']);
diff --git a/admin/tool/xmldb/tests/behat/mandatory_persistent_fields.feature b/admin/tool/xmldb/tests/behat/mandatory_persistent_fields.feature
new file mode 100644 (file)
index 0000000..006663e
--- /dev/null
@@ -0,0 +1,71 @@
+@tool @tool_xmldb
+Feature: Adding mandatory persistent fields to tables
+  In order for me to be able to create database tables for a persistent class
+  As a developer
+  I need to be able to add fields that are mandatory for the persistent class that I am developing
+
+  Background:
+    Given I log in as "admin"
+    And I navigate to "Development > XMLDB editor" in site administration
+    And I click on "Load" "link" in the "admin/tool/cohortroles/db" "table_row"
+    And I follow "admin/tool/cohortroles/db"
+    And I follow "New table"
+
+  Scenario: Cancel adding of mandatory persistent fields
+    Given I follow "Add mandatory persistent fields"
+    And I should see "usermodified"
+    And I should see "timecreated"
+    And I should see "timemodified"
+    When I press "Cancel"
+    Then I should see "Edit table"
+    And I should not see "usermodified"
+    And I should not see "timecreated"
+    And I should not see "timemodified"
+
+  Scenario: Creating mandatory persistent fields
+    Given I follow "Add mandatory persistent fields"
+    And I should see "usermodified"
+    And I should see "timecreated"
+    And I should see "timemodified"
+    When I press "Continue"
+    Then I should see "The following fields have been added:"
+    And I should see "usermodified" in the ".alert ul" "css_element"
+    And I should see "timecreated" in the ".alert ul" "css_element"
+    And I should see "timemodified" in the ".alert ul" "css_element"
+    And I follow "Back"
+    And I should see "usermodified" in the "listfields" "table"
+    And I should see "timecreated" in the "listfields" "table"
+    And I should see "timemodified" in the "listfields" "table"
+    And I should see "usermodified" in the "listkeys" "table"
+
+  Scenario: Partial creation of mandatory persistent fields
+    Given I follow "Add mandatory persistent fields"
+    And I press "Continue"
+    And I follow "Back"
+    And I click on "Delete" "link" in the "timecreated" "table_row"
+    And I press "Yes"
+    When I follow "Add mandatory persistent fields"
+    Then I should see "The following fields already exist:"
+    And I should see "usermodified" in the ".alert ul" "css_element"
+    And I should see "timemodified" in the ".alert ul" "css_element"
+    But I should not see "timecreated" in the ".alert ul" "css_element"
+    And I should see "Do you want to add the following fields:"
+    And I should see "timecreated" in the ".modal ul" "css_element"
+    And I press "Continue"
+    And I should see "The following fields have been added:"
+    And I should see "timecreated" in the ".alert ul" "css_element"
+    And I should not see "timemodified" in the ".alert ul" "css_element"
+    And I should not see "usermodified" in the ".alert ul" "css_element"
+    And I follow "Back"
+    And I should see "timecreated" in the "listfields" "table"
+
+  Scenario: Trying to create mandatory persistent fields that have all been added
+    Given I follow "Add mandatory persistent fields"
+    And I press "Continue"
+    And I follow "Back"
+    When I follow "Add mandatory persistent fields"
+    Then I should see "The following fields already exist:"
+    And I should see "usermodified" in the ".alert ul" "css_element"
+    And I should see "timecreated" in the ".alert ul" "css_element"
+    And I should see "timemodified" in the ".alert ul" "css_element"
+    And I should not see "Do you want to add the following fields:"
index ac43c14..23f6164 100644 (file)
@@ -27,7 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 $models = [
     [
-        'target' => '\core\analytics\target\no_teaching',
+        'target' => '\core_course\analytics\target\no_teaching',
         'indicators' => [
             '\core_course\analytics\indicator\no_teacher',
             '\core_course\analytics\indicator\no_student',
index ceb5796..167d41d 100644 (file)
@@ -360,8 +360,8 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->resetAfterTest();
         $this->setAdminuser();
 
-        $noteaching = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
-        $dropout = \core_analytics\manager::get_target('\core\analytics\target\course_dropout');
+        $noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
+        $dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
         $upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due');
 
         $this->assertTrue(\core_analytics\model::exists($noteaching));
index e2e0468..18a4fac 100644 (file)
@@ -71,7 +71,7 @@ class analytics_model_testcase extends advanced_testcase {
     public function test_create() {
         $this->resetAfterTest(true);
 
-        $target = \core_analytics\manager::get_target('\core\analytics\target\course_dropout');
+        $target = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
         $indicators = array(
             \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
             \core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions')
@@ -272,7 +272,7 @@ class analytics_model_testcase extends advanced_testcase {
     public function test_exists() {
         $this->resetAfterTest(true);
 
-        $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
+        $target = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
         $this->assertTrue(\core_analytics\model::exists($target));
 
         foreach (\core_analytics\manager::get_all_models() as $model) {
index d92f403..65aaf6e 100644 (file)
@@ -51,11 +51,11 @@ class analytics_stats_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        // By default, sites have {@link \core\analytics\target\no_teaching} enabled.
+        // By default, sites have {@link \core_course\analytics\target\no_teaching} enabled.
         $this->assertEquals(1, \core_analytics\stats::enabled_models());
 
         $model = \core_analytics\model::create(
-            \core_analytics\manager::get_target('\core\analytics\target\course_dropout'),
+            \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'),
             [
                 \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
             ]
index 6e182c0..9d3f613 100644 (file)
@@ -238,12 +238,12 @@ class restore_log_rule implements processable {
     protected function build_regexp($expression, $tokens) {
         // Replace to temp (and preg_quote() safe) placeholders
         foreach ($tokens as $token) {
-            $expression = preg_replace('~' . preg_quote($token, '~') . '~', '#@@#@@#', $expression, 1);
+            $expression = preg_replace('~' . preg_quote($token, '~') . '~', '%@@%@@%', $expression, 1);
         }
         // quote the expression
         $expression = preg_quote($expression, '~');
         // Replace all the placeholders
-        $expression = preg_replace('~#@@#@@#~', '(.*)', $expression);
+        $expression = preg_replace('~%@@%@@%~', '(.*)', $expression);
         return '~' . $expression . '~';
     }
 }
index 03d016b..24d549b 100644 (file)
@@ -49,4 +49,20 @@ class backup_restore_log_rule_testcase extends basic_testcase {
         // But the original log has been kept unmodified by the process() call.
         $this->assertEquals($originallog, $log);
     }
+
+    public function test_build_regexp() {
+        $original = 'Any (string) with [placeholders] like {this} and {this}. [end].';
+        $expectation = '~Any \(string\) with (.*) like (.*) and (.*)\. (.*)\.~';
+
+        $lr = new restore_log_rule('this', 'doesnt', 'matter', 'here');
+        $class = new ReflectionClass('restore_log_rule');
+
+        $method = $class->getMethod('extract_tokens');
+        $method->setAccessible(true);
+        $tokens = $method->invoke($lr, $original);
+
+        $method = $class->getMethod('build_regexp');
+        $method->setAccessible(true);
+        $this->assertSame($expectation, $method->invoke($lr, $original, $tokens));
+    }
 }
index a942122..8d4a3a9 100644 (file)
@@ -28,6 +28,7 @@
 defined('MOODLE_INTERNAL') || die;
 
 require_once("$CFG->libdir/externallib.php");
+require_once($CFG->dirroot . '/calendar/lib.php');
 
 use \core_calendar\local\api as local_api;
 use \core_calendar\local\event\container as event_container;
@@ -79,8 +80,7 @@ class core_calendar_external extends external_api {
      * @since Moodle 2.5
      */
     public static function delete_calendar_events($events) {
-        global $CFG, $DB;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $DB;
 
         // Parameter validation.
         $params = self::validate_parameters(self:: delete_calendar_events_parameters(), array('events' => $events));
@@ -173,8 +173,7 @@ class core_calendar_external extends external_api {
      * @since Moodle 2.5
      */
     public static function get_calendar_events($events = array(), $options = array()) {
-        global $SITE, $DB, $USER, $CFG;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $SITE, $DB, $USER;
 
         // Parameter validation.
         $params = self::validate_parameters(self::get_calendar_events_parameters(), array('events' => $events, 'options' => $options));
@@ -426,9 +425,7 @@ class core_calendar_external extends external_api {
     public static function get_calendar_action_events_by_timesort($timesortfrom = 0, $timesortto = null,
                                                        $aftereventid = 0, $limitnum = 20, $limittononsuspendedevents = false,
                                                        $userid = null) {
-        global $CFG, $PAGE, $USER;
-
-        require_once($CFG->dirroot . '/calendar/lib.php');
+        global $PAGE, $USER;
 
         $params = self::validate_parameters(
             self::get_calendar_action_events_by_timesort_parameters(),
@@ -511,9 +508,7 @@ class core_calendar_external extends external_api {
     public static function get_calendar_action_events_by_course(
         $courseid, $timesortfrom = null, $timesortto = null, $aftereventid = 0, $limitnum = 20) {
 
-        global $CFG, $PAGE, $USER;
-
-        require_once($CFG->dirroot . '/calendar/lib.php');
+        global $PAGE, $USER;
 
         $user = null;
         $params = self::validate_parameters(
@@ -596,9 +591,7 @@ class core_calendar_external extends external_api {
     public static function get_calendar_action_events_by_courses(
         array $courseids, $timesortfrom = null, $timesortto = null, $limitnum = 10) {
 
-        global $CFG, $PAGE, $USER;
-
-        require_once($CFG->dirroot . '/calendar/lib.php');
+        global $PAGE, $USER;
 
         $user = null;
         $params = self::validate_parameters(
@@ -691,8 +684,7 @@ class core_calendar_external extends external_api {
      * @throws moodle_exception if user doesnt have the permission to create events.
      */
     public static function create_calendar_events($events) {
-        global $CFG, $DB, $USER;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $DB, $USER;
 
         // Parameter validation.
         $params = self::validate_parameters(self::create_calendar_events_parameters(), array('events' => $events));
@@ -797,8 +789,7 @@ class core_calendar_external extends external_api {
      * @return array Array of event details
      */
     public static function get_calendar_event_by_id($eventid) {
-        global $CFG, $PAGE, $USER;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $PAGE, $USER;
 
         $params = self::validate_parameters(self::get_calendar_event_by_id_parameters(), ['eventid' => $eventid]);
         $context = \context_user::instance($USER->id);
@@ -868,8 +859,7 @@ class core_calendar_external extends external_api {
      * @throws moodle_exception
      */
     public static function submit_create_update_form($formdata) {
-        global $CFG, $USER, $PAGE;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $USER, $PAGE, $CFG;
         require_once($CFG->libdir."/filelib.php");
 
         // Parameter validation.
@@ -995,8 +985,7 @@ class core_calendar_external extends external_api {
      * @return  array
      */
     public static function get_calendar_monthly_view($year, $month, $courseid, $categoryid, $includenavigation, $mini) {
-        global $CFG, $DB, $USER, $PAGE;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $DB, $USER, $PAGE;
 
         // Parameter validation.
         $params = self::validate_parameters(self::get_calendar_monthly_view_parameters(), [
@@ -1073,8 +1062,7 @@ class core_calendar_external extends external_api {
      * @return  array
      */
     public static function get_calendar_day_view($year, $month, $day, $courseid, $categoryid) {
-        global $CFG, $DB, $USER, $PAGE;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $DB, $USER, $PAGE;
 
         // Parameter validation.
         $params = self::validate_parameters(self::get_calendar_day_view_parameters(), [
@@ -1214,8 +1202,7 @@ class core_calendar_external extends external_api {
      * @return  array
      */
     public static function get_calendar_upcoming_view($courseid, $categoryid) {
-        global $CFG, $DB, $USER, $PAGE;
-        require_once($CFG->dirroot."/calendar/lib.php");
+        global $DB, $USER, $PAGE;
 
         // Parameter validation.
         $params = self::validate_parameters(self::get_calendar_upcoming_view_parameters(), [
@@ -1257,4 +1244,125 @@ class core_calendar_external extends external_api {
     public static function get_calendar_upcoming_view_returns() {
         return \core_calendar\external\calendar_upcoming_exporter::get_read_structure();
     }
+
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters.
+     * @since  Moodle 3.7
+     */
+    public static function get_calendar_access_information_parameters() {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'Course to check, empty for site calendar events.', VALUE_DEFAULT, 0),
+            ]
+        );
+    }
+
+    /**
+     * Convenience function to retrieve some permissions information for the given course calendar.
+     *
+     * @param int $courseid Course to check, empty for site.
+     * @return array The access information
+     * @throws moodle_exception
+     * @since  Moodle 3.7
+     */
+    public static function get_calendar_access_information($courseid = 0) {
+
+        $params = self::validate_parameters(self::get_calendar_access_information_parameters(), ['courseid' => $courseid]);
+
+        if (empty($params['courseid']) || $params['courseid'] == SITEID) {
+            $context = \context_system::instance();
+        } else {
+            $context = \context_course::instance($params['courseid']);
+        }
+
+        self::validate_context($context);
+
+        return [
+            'canmanageentries' => has_capability('moodle/calendar:manageentries', $context),
+            'canmanageownentries' => has_capability('moodle/calendar:manageownentries', $context),
+            'canmanagegroupentries' => has_capability('moodle/calendar:managegroupentries', $context),
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description.
+     * @since  Moodle 3.7
+     */
+    public static function  get_calendar_access_information_returns() {
+
+        return new external_single_structure(
+            [
+                'canmanageentries' => new external_value(PARAM_BOOL, 'Whether the user can manage entries.'),
+                'canmanageownentries' => new external_value(PARAM_BOOL, 'Whether the user can manage its own entries.'),
+                'canmanagegroupentries' => new external_value(PARAM_BOOL, 'Whether the user can manage group entries.'),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters.
+     * @since  Moodle 3.7
+     */
+    public static function get_allowed_event_types_parameters() {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'Course to check, empty for site.', VALUE_DEFAULT, 0),
+            ]
+        );
+    }
+
+    /**
+     * Get the type of events a user can create in the given course.
+     *
+     * @param int $courseid Course to check, empty for site.
+     * @return array The types allowed
+     * @throws moodle_exception
+     * @since  Moodle 3.7
+     */
+    public static function get_allowed_event_types($courseid = 0) {
+
+        $params = self::validate_parameters(self::get_allowed_event_types_parameters(), ['courseid' => $courseid]);
+
+        if (empty($params['courseid']) || $params['courseid'] == SITEID) {
+            $context = \context_system::instance();
+        } else {
+            $context = \context_course::instance($params['courseid']);
+        }
+
+        self::validate_context($context);
+
+        $allowedeventtypes = array_filter(calendar_get_allowed_event_types($params['courseid']));
+
+        return [
+            'allowedeventtypes' => array_keys($allowedeventtypes),
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description.
+     * @since  Moodle 3.7
+     */
+    public static function  get_allowed_event_types_returns() {
+
+        return new external_single_structure(
+            [
+                'allowedeventtypes' => new external_multiple_structure(
+                    new external_value(PARAM_NOTAGS, 'Allowed event types to be created in the given course.')
+                ),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
 }
index 4692bbd..d3a7cf9 100644 (file)
@@ -2552,4 +2552,148 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(0, $data['events']);
         $this->assertEquals('nopermissions', $data['warnings'][0]['warningcode']);
     }
+
+    /**
+     * Test get_calendar_access_information for admins.
+     */
+    public function test_get_calendar_access_information_for_admins() {
+        global $CFG;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $CFG->calendar_adminseesall = 1;
+
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_calendar_access_information_returns(),
+            core_calendar_external::get_calendar_access_information()
+        );
+        $this->assertTrue($data['canmanageownentries']);
+        $this->assertTrue($data['canmanagegroupentries']);
+        $this->assertTrue($data['canmanageentries']);
+    }
+
+    /**
+     * Test get_calendar_access_information for authenticated users.
+     */
+    public function test_get_calendar_access_information_for_authenticated_users() {
+        $this->resetAfterTest(true);
+        $this->setUser($this->getDataGenerator()->create_user());
+
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_calendar_access_information_returns(),
+            core_calendar_external::get_calendar_access_information()
+        );
+        $this->assertTrue($data['canmanageownentries']);
+        $this->assertFalse($data['canmanagegroupentries']);
+        $this->assertFalse($data['canmanageentries']);
+    }
+
+    /**
+     * Test get_calendar_access_information for student users.
+     */
+    public function test_get_calendar_access_information_for_student_users() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+
+        $this->setUser($user);
+
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_calendar_access_information_returns(),
+            core_calendar_external::get_calendar_access_information($course->id)
+        );
+        $this->assertTrue($data['canmanageownentries']);
+        $this->assertFalse($data['canmanagegroupentries']);
+        $this->assertFalse($data['canmanageentries']);
+    }
+
+    /**
+     * Test get_calendar_access_information for teacher users.
+     */
+    public function test_get_calendar_access_information_for_teacher_users() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['groupmode' => 1]);
+        $role = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+
+        $this->setUser($user);
+
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_calendar_access_information_returns(),
+            core_calendar_external::get_calendar_access_information($course->id)
+        );
+        $this->assertTrue($data['canmanageownentries']);
+        $this->assertTrue($data['canmanagegroupentries']);
+        $this->assertTrue($data['canmanageentries']);
+    }
+
+    /**
+     * Test get_allowed_event_types for admins.
+     */
+    public function test_get_allowed_event_types_for_admins() {
+        global $CFG;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $CFG->calendar_adminseesall = 1;
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_allowed_event_types_returns(),
+            core_calendar_external::get_allowed_event_types()
+        );
+        $this->assertEquals(['user', 'site', 'course', 'category'], $data['allowedeventtypes']);
+    }
+    /**
+     * Test get_allowed_event_types for authenticated users.
+     */
+    public function test_get_allowed_event_types_for_authenticated_users() {
+        $this->resetAfterTest(true);
+        $this->setUser($this->getDataGenerator()->create_user());
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_allowed_event_types_returns(),
+            core_calendar_external::get_allowed_event_types()
+        );
+        $this->assertEquals(['user'], $data['allowedeventtypes']);
+    }
+    /**
+     * Test get_allowed_event_types for student users.
+     */
+    public function test_get_allowed_event_types_for_student_users() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->setUser($user);
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_allowed_event_types_returns(),
+            core_calendar_external::get_allowed_event_types($course->id)
+        );
+        $this->assertEquals(['user'], $data['allowedeventtypes']);
+    }
+    /**
+     * Test get_allowed_event_types for teacher users.
+     */
+    public function test_get_allowed_event_types_for_teacher_users() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['groupmode' => 1]);
+        $role = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $this->setUser($user);
+        $data = external_api::clean_returnvalue(
+            core_calendar_external::get_allowed_event_types_returns(),
+            core_calendar_external::get_allowed_event_types($course->id)
+        );
+        $this->assertEquals(['user', 'course', 'group'], $data['allowedeventtypes']);
+    }
 }
 /**
  * Course competencies achievement target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
  * Course competencies achievement target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class course_competencies extends \core\analytics\target\course_enrolments {
+class course_competencies extends course_enrolments {
 
     /**
      * Number of competencies assigned per course.
@@ -69,7 +69,7 @@ class course_competencies extends \core\analytics\target\course_enrolments {
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('target:coursecompetencies');
+        return new \lang_string('target:coursecompetencies', 'course');
     }
 
     /**
@@ -79,8 +79,8 @@ class course_competencies extends \core\analytics\target\course_enrolments {
      */
     protected static function classes_description() {
         return array(
-            get_string('targetlabelstudentcompetenciesno'),
-            get_string('targetlabelstudentcompetenciesyes'),
+            get_string('targetlabelstudentcompetenciesno', 'course'),
+            get_string('targetlabelstudentcompetenciesyes', 'course'),
         );
     }
 
 /**
  * Course completion target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -33,11 +33,11 @@ require_once($CFG->dirroot . '/completion/completion_completion.php');
 /**
  * Course completion target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class course_completion extends \core\analytics\target\course_enrolments {
+class course_completion extends course_enrolments {
 
     /**
      * Returns the name.
@@ -47,7 +47,7 @@ class course_completion extends \core\analytics\target\course_enrolments {
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('target:coursecompletion');
+        return new \lang_string('target:coursecompletion', 'course');
     }
 
     /**
@@ -57,8 +57,8 @@ class course_completion extends \core\analytics\target\course_enrolments {
      */
     protected static function classes_description() {
         return array(
-            get_string('targetlabelstudentcompletionno'),
-            get_string('targetlabelstudentcompletionyes')
+            get_string('targetlabelstudentcompletionno', 'course'),
+            get_string('targetlabelstudentcompletionyes', 'course')
         );
     }
 
 /**
  * Drop out course target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -34,11 +34,11 @@ require_once($CFG->dirroot . '/completion/completion_completion.php');
 /**
  * Drop out course target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class course_dropout extends \core\analytics\target\course_enrolments {
+class course_dropout extends course_enrolments {
 
     /**
      * Returns the name.
@@ -48,7 +48,7 @@ class course_dropout extends \core\analytics\target\course_enrolments {
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('target:coursedropout');
+        return new \lang_string('target:coursedropout', 'course');
     }
 
     /**
@@ -58,8 +58,8 @@ class course_dropout extends \core\analytics\target\course_enrolments {
      */
     protected static function classes_description() {
         return array(
-            get_string('targetlabelstudentdropoutno'),
-            get_string('targetlabelstudentdropoutyes')
+            get_string('targetlabelstudentdropoutno', 'course'),
+            get_string('targetlabelstudentdropoutyes', 'course')
         );
     }
 
@@ -96,7 +96,7 @@ class course_dropout extends \core\analytics\target\course_enrolments {
             // At least a minimum of students activity.
             $nstudents = count($this->students);
             if ($nlogs / $nstudents < 10) {
-                return get_string('nocourseactivity');
+                return get_string('nocourseactivity', 'course');
             }
         }
 
 /**
  * Base class for targets whose analysable is a course using user enrolments as samples.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
  * Base class for targets whose analysable is a course using user enrolments as samples.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -58,7 +58,7 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
      * @return string
      */
     public function get_insight_subject(int $modelid, \context $context) {
-        return get_string('studentsatriskincourse', 'moodle', $context->get_context_name(false));
+        return get_string('studentsatriskincourse', 'course', $context->get_context_name(false));
     }
 
     /**
@@ -71,41 +71,41 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
     public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) {
 
         if (!$course->was_started()) {
-            return get_string('coursenotyetstarted');
+            return get_string('coursenotyetstarted', 'course');
         }
 
         if (!$this->students = $course->get_students()) {
-            return get_string('nocoursestudents');
+            return get_string('nocoursestudents', 'course');
         }
 
         if (!course_format_uses_sections($course->get_course_data()->format)) {
             // We can not split activities in time ranges.
-            return get_string('nocoursesections');
+            return get_string('nocoursesections', 'course');
         }
 
         if ($course->get_end() == 0) {
             // We require time end to be set.
-            return get_string('nocourseendtime');
+            return get_string('nocourseendtime', 'course');
         }
 
         if ($course->get_end() < $course->get_start()) {
-            return get_string('errorendbeforestart', 'analytics');
+            return get_string('errorendbeforestart', 'course');
         }
 
         // A course that lasts longer than 1 year probably have wrong start or end dates.
         if ($course->get_end() - $course->get_start() > (YEARSECS + (WEEKSECS * 4))) {
-            return get_string('coursetoolong', 'analytics');
+            return get_string('coursetoolong', 'course');
         }
 
         // Finished courses can not be used to get predictions.
         if (!$fortraining && $course->is_finished()) {
-            return get_string('coursealreadyfinished');
+            return get_string('coursealreadyfinished', 'course');
         }
 
         if ($fortraining) {
             // Ongoing courses data can not be used to train.
             if (!$course->is_finished()) {
-                return get_string('coursenotyetfinished');
+                return get_string('coursenotyetfinished', 'course');
             }
         }
 
 /**
  * Getting the minimum grade to pass target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -30,11 +30,11 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * Getting the minimum grade to pass target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class course_gradetopass extends \core\analytics\target\course_enrolments {
+class course_gradetopass extends course_enrolments {
 
     /**
      * Courses grades to pass.
@@ -114,7 +114,7 @@ class course_gradetopass extends \core\analytics\target\course_enrolments {
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('target:coursegradetopass');
+        return new \lang_string('target:coursegradetopass', 'course');
     }
 
     /**
@@ -124,8 +124,8 @@ class course_gradetopass extends \core\analytics\target\course_enrolments {
      */
     protected static function classes_description() {
         return array(
-            get_string('targetlabelstudentgradetopassno'),
-            get_string('targetlabelstudentgradetopassyes')
+            get_string('targetlabelstudentgradetopassno', 'course'),
+            get_string('targetlabelstudentgradetopassyes', 'course')
         );
     }
 
 /**
  * No teaching target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core\analytics\target;
+namespace core_course\analytics\target;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
  * No teaching target.
  *
- * @package   core
+ * @package   core_course
  * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -52,7 +52,7 @@ class no_teaching extends \core_analytics\local\target\binary {
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('target:noteachingactivity');
+        return new \lang_string('target:noteachingactivity', 'course');
     }
 
     /**
@@ -111,8 +111,8 @@ class no_teaching extends \core_analytics\local\target\binary {
      */
     protected static function classes_description() {
         return array(
-            get_string('targetlabelteachingyes'),
-            get_string('targetlabelteachingno'),
+            get_string('targetlabelteachingyes', 'course'),
+            get_string('targetlabelteachingno', 'course'),
         );
     }
 
index ff6e641..251c460 100644 (file)
@@ -304,7 +304,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         }
         if (count($children) > 1) {
             // User has access to more than one category on the top level. Return the top as "user top category".
-            // In this case user actually may not have capability 'moodle/course:browse' on the top level.
+            // In this case user actually may not have capability 'moodle/category:viewcourselist' on the top level.
             return self::top();
         }
         // User can not access any categories on the top level.
@@ -653,13 +653,13 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      */
     public static function can_view_category($category, $user = null) {
         if (!$category->id) {
-            return has_capability('moodle/course:browse', context_system::instance(), $user);
+            return has_capability('moodle/category:viewcourselist', context_system::instance(), $user);
         }
         $context = context_coursecat::instance($category->id);
         if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context, $user)) {
             return false;
         }
-        return has_capability('moodle/course:browse', $context, $user);
+        return has_capability('moodle/category:viewcourselist', $context, $user);
     }
 
     /**
@@ -682,7 +682,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         }
         $categorycontext = isset($course->category) ? context_coursecat::instance($course->category) :
             context_course::instance($course->id)->get_parent_context();
-        return has_capability('moodle/course:browse', $categorycontext, $user);
+        return has_capability('moodle/category:viewcourselist', $categorycontext, $user);
     }
 
     /**
@@ -1088,7 +1088,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @param array $params
      * @param array $options may indicate that summary needs to be retrieved
      * @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked
-     *     on not visible courses and 'moodle/course:browse' on all courses
+     *     on not visible courses and 'moodle/category:viewcourselist' on all courses
      * @return array array of stdClass objects
      */
     protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) {
index 42bcf3d..1d62aac 100644 (file)
@@ -181,4 +181,16 @@ class customfield extends \core_search\base {
     public function get_doc_icon(\core_search\document $doc) : \core_search\document_icon {
         return new \core_search\document_icon('i/customfield');
     }
+
+    /**
+     * Returns a list of category names associated with the area.
+     *
+     * @return array
+     */
+    public function get_category_names() {
+        return [
+            \core_search\manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT,
+            \core_search\manager::SEARCH_AREA_CATEGORY_COURSES
+        ];
+    }
 }
index f55e58f..8fefc5b 100644 (file)
@@ -28,13 +28,13 @@ Feature: Restricting access to course lists
     Given I log in as "admin"
     And I set the following system permissions of "Authenticated user" role:
       | capability | permission |
-      | moodle/course:browse | Prevent |
+      | moodle/category:viewcourselist | Prevent |
     And I set the following system permissions of "Guest" role:
       | capability | permission |
-      | moodle/course:browse | Prevent |
+      | moodle/category:viewcourselist | Prevent |
     And I set the following system permissions of "Category viewer" role:
       | capability | permission |
-      | moodle/course:browse | Allow |
+      | moodle/category:viewcourselist | Allow |
     And I am on site homepage
     And I turn editing mode on
     And I add the "Navigation" block if not present
index c5ada25..6d03d48 100644 (file)
@@ -609,8 +609,10 @@ class course_search_testcase extends advanced_testcase {
     public function test_get_category_names() {
         $coursessearcharea = \core_search\manager::get_search_area($this->coursesareaid);
         $sectionsearcharea = \core_search\manager::get_search_area($this->sectionareaid);
+        $customfieldssearcharea = \core_search\manager::get_search_area($this->customfieldareaid);
 
         $this->assertEquals(['core-courses'], $coursessearcharea->get_category_names());
         $this->assertEquals(['core-course-content'], $sectionsearcharea->get_category_names());
+        $this->assertEquals(['core-course-content', 'core-courses'], $customfieldssearcharea->get_category_names());
     }
 }
index 71608df..593541e 100644 (file)
@@ -366,6 +366,7 @@ class core_enrol_external extends external_api {
             $progress = null;
             $completed = null;
             $completionhascriteria = false;
+            $completionusertracked = false;
 
             // Return only private information if the user should be able to see it.
             if ($sameuser || completion_can_view_data($userid, $course)) {
@@ -373,6 +374,7 @@ class core_enrol_external extends external_api {
                     $completion = new completion_info($course);
                     $completed = $completion->is_course_complete($userid);
                     $completionhascriteria = $completion->has_criteria();
+                    $completionusertracked = $completion->is_tracked_user($userid);
                     $progress = \core_completion\progress::get_course_progress_percentage($course, $userid);
                 }
             }
@@ -425,6 +427,7 @@ class core_enrol_external extends external_api {
                 'lang' => clean_param($course->lang, PARAM_LANG),
                 'enablecompletion' => $course->enablecompletion,
                 'completionhascriteria' => $completionhascriteria,
+                'completionusertracked' => $completionusertracked,
                 'category' => $course->category,
                 'progress' => $progress,
                 'completed' => $completed,
@@ -470,6 +473,7 @@ class core_enrol_external extends external_api {
                     'enablecompletion' => new external_value(PARAM_BOOL, 'true if completion is enabled, otherwise false',
                                                                 VALUE_OPTIONAL),
                     'completionhascriteria' => new external_value(PARAM_BOOL, 'If completion criteria is set.', VALUE_OPTIONAL),
+                    'completionusertracked' => new external_value(PARAM_BOOL, 'If the user is completion tracked.', VALUE_OPTIONAL),
                     'category' => new external_value(PARAM_INT, 'course category id', VALUE_OPTIONAL),
                     'progress' => new external_value(PARAM_FLOAT, 'Progress percentage', VALUE_OPTIONAL),
                     'completed' => new external_value(PARAM_BOOL, 'Whether the course is completed.', VALUE_OPTIONAL),
index 0fa3155..da07c87 100644 (file)
@@ -453,6 +453,7 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(100.0, $courseenrol['progress']);
                 $this->assertEquals(true, $courseenrol['completed']);
                 $this->assertTrue($courseenrol['completionhascriteria']);
+                $this->assertTrue($courseenrol['completionusertracked']);
                 $this->assertTrue($courseenrol['hidden']);
                 $this->assertTrue($courseenrol['isfavourite']);
                 $this->assertEquals(2, $courseenrol['enrolledusercount']);
@@ -465,6 +466,7 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(0, $courseenrol['progress']);
                 $this->assertEquals(false, $courseenrol['completed']);
                 $this->assertFalse($courseenrol['completionhascriteria']);
+                $this->assertFalse($courseenrol['completionusertracked']);
                 $this->assertFalse($courseenrol['hidden']);
                 $this->assertFalse($courseenrol['isfavourite']);
                 $this->assertEquals(1, $courseenrol['enrolledusercount']);
@@ -489,11 +491,13 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals($timenow, $courseenrol['lastaccess']);
                 $this->assertEquals(100.0, $courseenrol['progress']);
                 $this->assertTrue($courseenrol['completionhascriteria']);
+                $this->assertTrue($courseenrol['completionusertracked']);
                 $this->assertFalse($courseenrol['isfavourite']);    // This always false.
                 $this->assertFalse($courseenrol['hidden']); // This always false.
             } else {
                 $this->assertEquals(0, $courseenrol['progress']);
                 $this->assertFalse($courseenrol['completionhascriteria']);
+                $this->assertFalse($courseenrol['completionusertracked']);
                 $this->assertFalse($courseenrol['isfavourite']);    // This always false.
                 $this->assertFalse($courseenrol['hidden']); // This always false.
             }
index 34c840d..892767b 100644 (file)
@@ -9,6 +9,8 @@ information provided here is intended especially for developers.
   - totalusers: Number users matching the search. (This element only exists if the function is called with $returnexactcount param set to true).
 * enrolledusercount is now optional in the return value of get_users_courses() for performance reasons. This is controlled with the new
   optional returnusercount parameter (default true).
+* External function core_enrol_external::get_users_courses now returns a new field "completionusertracked" that indicates if the
+  given user is being tracked for completion.
 
 === 3.6 ===
 
index 2849ab6..47d14d5 100644 (file)
@@ -29,7 +29,6 @@ $string['analytics'] = 'Analytics';
 $string['analyticslogstore'] = 'Log store used for analytics';
 $string['analyticslogstore_help'] = 'The log store that will be used by the analytics API to read users\' activity';
 $string['analyticssettings'] = 'Analytics settings';
-$string['coursetoolong'] = 'The course is too long';
 $string['defaulttimesplittingmethods'] = 'Default time-splitting methods for model\'s evaluation';
 $string['defaulttimesplittingmethods_help'] = 'The time-splitting method divides the course duration into parts; the predictions engine will run at the end of these parts. The model evaluation process will iterate through these time-splitting methods unless a specific time-splitting method is specified (the ability to specify a time-splitting method is only available when evaluating models using the command line script).';
 $string['defaultpredictionsprocessor'] = 'Default predictions processor';
@@ -38,7 +37,6 @@ $string['disabledmodel'] = 'Disabled model';
 $string['erroralreadypredict'] = 'File {$a} has already been used to generate predictions.';
 $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read';
 $string['errorcannotwritedataset'] = 'Dataset file {$a} cannot be written';
-$string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.';
 $string['errorexportmodelresult'] = 'The machine learning model can not be exported.';
 $string['errorimport'] = 'Error importing the provided json file.';
 $string['errorimportmissingcomponents'] = 'The provided model requires the following plugins to be installed: {$a}. Note that the versions do not necessarily need to match with the versions installed in your system. To install the same or a newer version of the plugin should be enough in most cases.';
index 16e5080..d7930bb 100644 (file)
@@ -27,6 +27,10 @@ $string['aria:courseimage'] = 'Course image';
 $string['aria:courseshortname'] = 'Course short name';
 $string['aria:coursename'] = 'Course name';
 $string['aria:favourite'] = 'Course is starred';
+$string['coursealreadyfinished'] = 'Course already finished';
+$string['coursenotyetstarted'] = 'The course has not yet started';
+$string['coursenotyetfinished'] = 'The course has not yet finished';
+$string['coursetoolong'] = 'The course is too long';
 $string['customfield_islocked'] = 'Locked';
 $string['customfield_islocked_help'] = 'If the field is locked, only users with the capability to change locked custom fields (by default users with the default role of manager only) will be able to change it in the course settings.';
 $string['customfield_notvisible'] = 'Nobody';
@@ -35,10 +39,36 @@ $string['customfield_visibility_help'] = 'This setting determines who can view t
 $string['customfield_visibletoall'] = 'Everyone';
 $string['customfield_visibletoteachers'] = 'Teachers';
 $string['customfieldsettings'] = 'Settings for course custom fields';
+$string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.';
 $string['favourite'] = 'Starred course';
 $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).';
+$string['nocourseactivity'] = 'Not enough course activity between the start and the end of the course';
+$string['nocourseendtime'] = 'The course does not have an end time';
+$string['nocoursesections'] = 'No course sections';
+$string['nocoursestudents'] = 'No students';
 $string['privacy:perpage'] = 'The number of courses to show per page.';
 $string['privacy:completionpath'] = 'Course completion';
 $string['privacy:favouritespath'] = 'Course starred information';
 $string['privacy:metadata:completionsummary'] = 'The course contains completion information about the user.';
 $string['privacy:metadata:favouritessummary'] = 'The course contains information relating to the course being starred by the user.';
+$string['studentsatriskincourse'] = 'Students at risk in {$a} course';
+$string['target:coursecompletion'] = 'Students at risk of not meeting the course completion conditions';
+$string['target:coursecompletion_help'] = 'This target describes whether the student is considered at risk of not meeting the course completion conditions.';
+$string['target:coursecompetencies'] = 'Students at risk of not achieving the competencies assigned to a course';
+$string['target:coursecompetencies_help'] = 'This target describes whether a student is at risk of not achieving the competencies assigned to a course. This target considers that all competencies assigned to the course must be achieved by the end of the course.';
+$string['target:coursedropout'] = 'Students at risk of dropping out';
+$string['target:coursedropout_help'] = 'This target describes whether the student is considered at risk of dropping out.';
+$string['target:coursegradetopass'] = 'Students at risk of not getting the minimum grade to pass the course.';
+$string['target:coursegradetopass_help'] = 'This target describes whether the student is at risk of not getting the minimum grade to pass the course.';
+$string['target:noteachingactivity'] = 'No teaching';
+$string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.';
+$string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions';
+$string['targetlabelstudentcompletionyes'] = 'Student at risk of not meeting the course completion conditions';
+$string['targetlabelstudentcompetenciesno'] = 'Student who is likely to achieve the competencies assigned to a course';
+$string['targetlabelstudentcompetenciesyes'] = 'Student at risk of not achieving the competencies assigned to a course';
+$string['targetlabelstudentdropoutyes'] = 'Student at risk of dropping out';
+$string['targetlabelstudentdropoutno'] = 'Not at risk';
+$string['targetlabelstudentgradetopassno'] = 'Student who is likely to meet the minimum grade to pass the course.';
+$string['targetlabelstudentgradetopassyes'] = 'Student at risk of not meeting the minimum grade to pass the course.';
+$string['targetlabelteachingyes'] = 'Users with teaching capabilities have access to the course';
+$string['targetlabelteachingno'] = 'No teaching';
index 8ce4b59..e3a2fb7 100644 (file)
@@ -52,9 +52,11 @@ $string['defaultmessageoutputs'] = 'Notification settings';
 $string['defaults'] = 'Defaults';
 $string['deleteallconfirm'] = "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.";
 $string['deleteallmessages'] = "Delete all messages";
+$string['deleteallselfconfirm'] = "Are you sure you would like to delete this entire personal conversation?";
 $string['deleteconversation'] = "Delete conversation";
 $string['deleteselectedmessages'] = 'Delete selected messages';
 $string['deleteselectedmessagesconfirm'] = 'Are you sure you would like to delete the selected messages? This will not delete them for other conversation participants.';
+$string['deleteselectedmessagesconfirmselfconversation'] = 'Are you sure you would like to delete the selected personal messages?';
 $string['disableall'] = 'Disable notifications';
 $string['disabled'] = 'Messaging is disabled on this site';
 $string['disallowed'] = 'Disallowed';
@@ -211,6 +213,8 @@ $string['searchcombined'] = 'Search people and messages';
 $string['seeall'] = 'See all';
 $string['selectmessagestodelete'] = 'Select messages to delete';
 $string['selectnotificationtoview'] = 'Select from the list of notifications on the side to view more details';
+$string['selfconversation'] = 'Personal space';
+$string['selfconversationdefaultmessage'] = 'Save draft messages, links, notes etc. to access later.';
 $string['send'] = 'Send';
 $string['sender'] = '{$a}:';
 $string['sendingvia'] = 'Sending "{$a->provider}" via "{$a->processor}"';
index 46566af..3f336d7 100644 (file)
@@ -295,7 +295,6 @@ $string['counteditems'] = '{$a->count} {$a->items}';
 $string['country'] = 'Country';
 $string['course'] = 'Course';
 $string['courseadministration'] = 'Course administration';
-$string['coursealreadyfinished'] = 'Course already finished';
 $string['courseapprovedemail'] = 'Your requested course, {$a->name}, has been approved and you have been made a {$a->teacher}.  To access your new course, go to {$a->url}';
 $string['courseapprovedemail2'] = 'Your requested course, {$a->name}, has been approved.  To access your new course, go to {$a->url}';
 $string['courseapprovedfailed'] = 'Failed to save the course as approved!';
@@ -340,8 +339,6 @@ $string['coursehelpnewsitemsnumber'] = 'Number of recent announcements appearing
 $string['coursehelpnumberweeks'] = 'Number of sections in the course (applies to certain course formats only).';
 $string['coursehelpshowgrades'] = 'Enable the display of the gradebook. It does not prevent grades from being displayed within the individual activities.';
 $string['coursehidden'] = 'This course is currently unavailable to students';
-$string['coursenotyetstarted'] = 'The course has not yet started';
-$string['coursenotyetfinished'] = 'The course has not yet finished';
 $string['courseoverviewfiles'] = 'Course image';
 $string['courseoverviewfilesext'] = 'Course image file extensions';
 $string['courseoverviewfileslimit'] = 'Course image files limit';
@@ -1384,13 +1381,9 @@ $string['nextsection'] = 'Next section';
 $string['no'] = 'No';
 $string['noblockstoaddhere'] = 'There are no blocks that you can add to this page.';
 $string['nobody'] = 'Nobody';
-$string['nocourseactivity'] = 'Not enough course activity between the start and the end of the course';
-$string['nocourseendtime'] = 'The course does not have an end time';
 $string['nocourses'] = 'No courses';
-$string['nocoursesections'] = 'No course sections';
 $string['nocoursesfound'] = 'No courses were found with the words \'{$a}\'';
 $string['nocoursestarttime'] = 'The course does not have a start date.';
-$string['nocoursestudents'] = 'No students';
 $string['nocoursesyet'] = 'No courses in this category';
 $string['nocomments'] = 'No comments';
 $string['nodstpresets'] = 'The administrator has not enabled Daylight Savings Time support.';
@@ -1952,7 +1945,6 @@ $string['stringsnotset'] = 'The following strings are not defined in {$a}';
 $string['studentnotallowed'] = 'Sorry, but you can not enter this course as \'{$a}\'';
 $string['students'] = 'Students';
 $string['studentsandteachers'] = 'Students and teachers';
-$string['studentsatriskincourse'] = 'Students at risk in {$a} course';
 $string['subcategories'] = 'Subcategories';
 $string['subcategory'] = 'Subcategory';
 $string['subcategoryof'] = 'Subcategory of {$a}';
@@ -1976,28 +1968,6 @@ $string['tag'] = 'Tag';
 $string['tagalready'] = 'This tag already exists';
 $string['tagmanagement'] = 'Add/delete tags ...';
 $string['tags'] = 'Tags';
-$string['target:coursecompletion'] = 'Students at risk of not meeting the course completion conditions';
-$string['target:coursecompletion_help'] = 'This target describes whether the student is considered at risk of not meeting the course completion conditions.';
-$string['target:coursecompetencies'] = 'Students at risk of not achieving the competencies assigned to a course';
-$string['target:coursecompetencies_help'] = 'This target describes whether a student is at risk of not achieving the competencies assigned to a course. This target considers that all competencies assigned to the course must be achieved by the end of the course.';
-$string['target:coursedropout'] = 'Students at risk of dropping out';
-$string['target:coursedropout_help'] = 'This target describes whether the student is considered at risk of dropping out.';
-$string['target:coursegradetopass'] = 'Students at risk of not getting the minimum grade to pass the course.';
-$string['target:coursegradetopass_help'] = 'This target describes whether the student is at risk of not getting the minimum grade to pass the course.';
-$string['target:noteachingactivity'] = 'No teaching';
-$string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.';
-$string['target:upcomingactivitiesdue'] = 'Upcoming activities due';
-$string['target:upcomingactivitiesdue_help'] = 'This target generates reminders for upcoming activities due.';
-$string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions';
-$string['targetlabelstudentcompletionyes'] = 'Student at risk of not meeting the course completion conditions';
-$string['targetlabelstudentcompetenciesno'] = 'Student who is likely to achieve the competencies assigned to a course';
-$string['targetlabelstudentcompetenciesyes'] = 'Student at risk of not achieving the competencies assigned to a course';
-$string['targetlabelstudentdropoutyes'] = 'Student at risk of dropping out';
-$string['targetlabelstudentdropoutno'] = 'Not at risk';
-$string['targetlabelstudentgradetopassno'] = 'Student who is likely to meet the minimum grade to pass the course.';
-$string['targetlabelstudentgradetopassyes'] = 'Student at risk of not meeting the minimum grade to pass the course.';
-$string['targetlabelteachingyes'] = 'Users with teaching capabilities have access to the course';
-$string['targetlabelteachingno'] = 'No teaching';
 $string['targetrole'] = 'Target role';
 $string['teacheronly'] = 'for the {$a} only';
 $string['teacherroles'] = '{$a} roles';
index 13b4c1b..a01ff27 100644 (file)
@@ -98,6 +98,7 @@ $string['calendar:managegroupentries'] = 'Manage group calendar entries';
 $string['calendar:manageownentries'] = 'Manage own calendar entries';
 $string['capabilities'] = 'Capabilities';
 $string['capability'] = 'Capability';
+$string['category:viewcourselist'] = 'View list of courses you are not enrolled in';
 $string['category:create'] = 'Create categories';
 $string['category:delete'] = 'Delete categories';
 $string['category:manage'] = 'Manage categories';
@@ -154,7 +155,6 @@ $string['confirmunassignyes'] = 'Remove';
 $string['confirmunassignno'] = 'Cancel';
 $string['context'] = 'Context';
 $string['course:activityvisibility'] = 'Hide/show activities';
-$string['course:browse'] = 'View list of courses where user is not enrolled';
 $string['course:bulkmessaging'] = 'Send a message to many people';
 $string['course:create'] = 'Create courses';
 $string['course:creategroupconversations'] = 'Create group conversations';
index 5692065..56777bb 100644 (file)
@@ -125,3 +125,5 @@ $string['privacy:passwordresetpath'] = 'Password resets';
 $string['privacy:profileimagespath'] = 'Profile images';
 $string['privacy:privatefilespath'] = 'Private files';
 $string['privacy:sessionpath'] = 'Session data';
+$string['target:upcomingactivitiesdue'] = 'Upcoming activities due';
+$string['target:upcomingactivitiesdue_help'] = 'This target generates reminders for upcoming activities due.';
index ccd55f0..0ad553d 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 754f9a6..f46a3c0 100644 (file)
@@ -702,14 +702,27 @@ function($, log, str, templates, notification, LoadingIcon) {
             }
             return true;
         });
+        // Support submitting the form without leaving the autocomplete element,
+        // or submitting too quick before the blur handler action is completed.
+        inputElement.closest('form').on('submit', function() {
+            if (options.tags) {
+                // If tags are enabled, create a tag.
+                addPendingJSPromise('form-autocomplete-submit')
+                .resolve(createItem(options, state, originalSelect));
+            }
+
+            return true;
+        });
         inputElement.on('blur', function() {
             var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
             window.setTimeout(function() {
                 // Get the current element with focus.
                 var focusElement = $(document.activeElement);
 
-                // Only close the menu if the input hasn't regained focus.
-                if (focusElement.attr('id') != inputElement.attr('id')) {
+                // Only close the menu if the input hasn't regained focus, and if the element still exists.
+                // Due to the half a second delay, it is possible that the input element no longer exist
+                // by the time this code is being executed.
+                if (focusElement.attr('id') != inputElement.attr('id') && $('#' + state.inputId).length) {
                     if (options.tags) {
                         pendingPromise.then(function() {
                             return createItem(options, state, originalSelect);
index b35c1ed..8dce568 100644 (file)
@@ -109,6 +109,13 @@ class manager {
         // Get conversation type and name. We'll use this to determine which message subject to generate, depending on type.
         $conv = $DB->get_record('message_conversations', ['id' => $eventdata->convid], 'id, type, name');
 
+        // For now Self conversations are not processed because users are aware of the messages sent by themselves, so we
+        // can return early.
+        if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF) {
+            return $savemessage->id;
+        }
+        $localisedeventdata->conversationtype = $conv->type;
+
         // We treat individual conversations the same as any direct message with 'userfrom' and 'userto' specified.
         // We know the other user, so set the 'userto' field so that the event code will get access to this field.
         // If this was a legacy caller (eventdata->userto is set), then use that instead, as we want to use the fields specified
@@ -200,10 +207,9 @@ class manager {
             }
 
             // Fill in the array of processors to be used based on default and user preferences.
-            // This applies only to individual conversations. Messages to group conversations ignore processors.
             // Do not process muted conversations.
             $processorlist = [];
-            if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL && !$recipient->ismuted) {
+            if (!$recipient->ismuted) {
                 foreach ($processors as $processor) {
                     // Skip adding processors for internal user, if processor doesn't support sending message to internal user.
                     if (!$usertoisrealuser && !$processor->object->can_send_to_any_users()) {
index ee2cd60..9ad4a40 100644 (file)
@@ -77,6 +77,9 @@ class message {
     /** @var int The conversation id where userfrom is sending this message. */
     private $convid;
 
+    /** @var int The conversation type, eg. \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL */
+    private $conversationtype;
+
     /** @var object|int The user who is receiving from which is sending this message. */
     private $userto;
 
@@ -133,6 +136,7 @@ class message {
         'name',
         'userfrom',
         'convid',
+        'conversationtype',
         'userto',
         'subject',
         'fullmessage',
index bfb9dd5..87d472e 100644 (file)
@@ -733,10 +733,10 @@ $capabilities = array(
         'clonepermissionsfrom' => 'moodle/category:update'
     ),
 
-    'moodle/course:browse' => array(
+    'moodle/category:viewcourselist' => array(
 
         'captype' => 'read',
-        'contextlevel' => CONTEXT_COURSE,
+        'contextlevel' => CONTEXT_COURSECAT,
         'archetypes' => array(
             'guest' => CAP_ALLOW,
             'user' => CAP_ALLOW,
index e5dd619..a8e445b 100644 (file)
@@ -27,7 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 $models = [
     [
-        'target' => '\core\analytics\target\course_dropout',
+        'target' => '\core_course\analytics\target\course_dropout',
         'indicators' => [
             '\core\analytics\indicator\any_access_after_end',
             '\core\analytics\indicator\any_access_before_start',
@@ -81,7 +81,7 @@ $models = [
         ],
     ],
     [
-        'target' => '\core\analytics\target\no_teaching',
+        'target' => '\core_course\analytics\target\no_teaching',
         'indicators' => [
             '\core_course\analytics\indicator\no_teacher',
             '\core_course\analytics\indicator\no_student',
index 9631290..c924dba 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20190402" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20190403" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="roleid-contextid" UNIQUE="true" FIELDS="roleid, contextid"/>
       </INDEXES>
     </TABLE>
-    <TABLE NAME="role_sortorder" COMMENT="sort order of course managers in a course">
-      <FIELDS>
-        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
-        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
-        <FIELD NAME="roleid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
-        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
-        <FIELD NAME="sortoder" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
-      </FIELDS>
-      <KEYS>
-        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
-        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
-        <KEY NAME="roleid" TYPE="foreign" FIELDS="roleid" REFTABLE="role" REFFIELDS="id"/>
-        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
-      </KEYS>
-      <INDEXES>
-        <INDEX NAME="userid-roleid-contextid" UNIQUE="true" FIELDS="userid, roleid, contextid"/>
-      </INDEXES>
-    </TABLE>
     <TABLE NAME="role_context_levels" COMMENT="Lists which roles can be assigned at which context levels. The assignment is allowed in the corresponding row is present in this table.">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index 6e02063..720e2ae 100644 (file)
@@ -38,4 +38,9 @@ defined('MOODLE_INTERNAL') || die();
 $renamedclasses = array(
     'course_in_list' => 'core_course_list_element',
     'coursecat' => 'core_course_category',
+    'core\\analytics\\target\\course_dropout' => 'core_course\\analytics\\target\\course_dropout',
+    'core\\analytics\\target\\course_competencies' => 'core_course\\analytics\\target\\course_competencies',
+    'core\\analytics\\target\\course_completion' => 'core_course\\analytics\\target\\course_completion',
+    'core\\analytics\\target\\course_gradetopass' => 'core_course\\analytics\\target\\course_gradetopass',
+    'core\\analytics\\target\\no_teaching' => 'core_course\\analytics\\target\\no_teaching',
 );
index 95a17be..869a22f 100644 (file)
@@ -165,6 +165,7 @@ $functions = array(
         'type' => 'write',
         'capabilities' => 'moodle/calendar:manageentries, moodle/calendar:manageownentries, moodle/calendar:managegroupentries',
         'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_calendar_create_calendar_events' => array(
         'classname' => 'core_calendar_external',
@@ -242,6 +243,23 @@ $functions = array(
         'type' => 'write',
         'capabilities' => 'moodle/calendar:manageentries, moodle/calendar:manageownentries, moodle/calendar:managegroupentries',
         'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_calendar_get_calendar_access_information' => array(
+        'classname' => 'core_calendar_external',
+        'methodname' => 'get_calendar_access_information',
+        'description' => 'Convenience function to retrieve some permissions/access information for the given course calendar.',
+        'classpath' => 'calendar/externallib.php',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_calendar_get_allowed_event_types' => array(
+        'classname' => 'core_calendar_external',
+        'methodname' => 'get_allowed_event_types',
+        'description' => 'Get the type of events a user can create in the given course.',
+        'classpath' => 'calendar/externallib.php',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_cohort_add_cohort_members' => array(
         'classname' => 'core_cohort_external',
@@ -1170,6 +1188,15 @@ $functions = array(
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
         'ajax' => true
     ),
+    'core_message_get_self_conversation' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'get_self_conversation',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Retrieve a self-conversation for a user',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+        'ajax' => true
+    ),
     'core_message_get_messages' => array(
         'classname' => 'core_message_external',
         'methodname' => 'get_messages',
index 76dc143..5395c24 100644 (file)
@@ -2996,9 +2996,242 @@ function xmldb_main_upgrade($oldversion) {
         if (!$dbman->field_exists($table, $field)) {
             $dbman->add_field($table, $field);
         }
-
+        // Main savepoint reached.
         upgrade_main_savepoint(true, 2019041300.01);
     }
 
+    if ($oldversion < 2019041800.01) {
+        // STEP 1. For the existing and migrated self-conversations, set the type to the new MESSAGE_CONVERSATION_TYPE_SELF, update
+        // the convhash and star them.
+        $sql = "SELECT mcm.conversationid, mcm.userid, MAX(mcm.id) as maxid
+                  FROM {message_conversation_members} mcm
+              GROUP BY mcm.conversationid, mcm.userid
+                HAVING COUNT(*) > 1";
+        $selfconversationsrs = $DB->get_recordset_sql($sql);
+        $maxids = [];
+        foreach ($selfconversationsrs as $selfconversation) {
+            $DB->update_record('message_conversations',
+                ['id' => $selfconversation->conversationid,
+                 'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
+                 'convhash' => \core_message\helper::get_conversation_hash([$selfconversation->userid])
+                ]
+            );
+
+            // Star the existing self-conversation.
+            $favouriterecord = new \stdClass();
+            $favouriterecord->component = 'core_message';
+            $favouriterecord->itemtype = 'message_conversations';
+            $favouriterecord->itemid = $selfconversation->conversationid;
+            $userctx = \context_user::instance($selfconversation->userid);
+            $favouriterecord->contextid = $userctx->id;
+            $favouriterecord->userid = $selfconversation->userid;
+            $favouriterecord->timecreated = time();
+            $favouriterecord->timemodified = $favouriterecord->timecreated;
+
+            $DB->insert_record('favourite', $favouriterecord);
+
+            // Set the self-conversation member with maxid to remove it later.
+            $maxids[] = $selfconversation->maxid;
+        }
+        $selfconversationsrs->close();
+
+        // Remove the repeated member with the higher id for all the existing self-conversations.
+        if (!empty($maxids)) {
+            list($insql, $inparams) = $DB->get_in_or_equal($maxids);
+            $DB->delete_records_select('message_conversation_members', "id $insql", $inparams);
+        }
+
+        // STEP 2. Migrate existing self-conversation relying on old message tables, setting the type to the new
+        // MESSAGE_CONVERSATION_TYPE_SELF and the convhash to the proper one. Star them also.
+
+        // On the messaging legacy tables, self-conversations are only present in the 'message_read' table, so we don't need to
+        // check the content in the 'message' table.
+        $select = 'useridfrom = useridto AND notification = 0';
+        $legacyselfmessagesrs = $DB->get_recordset_select('message_read', $select);
+        foreach ($legacyselfmessagesrs as $message) {
+            // Get the self-conversation or create and star it if doesn't exist.
+            $conditions = [
+                'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
+                'convhash' => \core_message\helper::get_conversation_hash([$message->useridfrom])
+            ];
+            $selfconversation = $DB->get_record('message_conversations', $conditions);
+            if (empty($selfconversation)) {
+                // Create the self-conversation.
+                $selfconversation = new \stdClass();
+                $selfconversation->type = \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF;
+                $selfconversation->convhash = \core_message\helper::get_conversation_hash([$message->useridfrom]);
+                $selfconversation->enabled = 1;
+                $selfconversation->timecreated = time();
+                $selfconversation->timemodified = $selfconversation->timecreated;
+
+                $selfconversation->id = $DB->insert_record('message_conversations', $selfconversation);
+
+                // Add user to this self-conversation.
+                $member = new \stdClass();
+                $member->conversationid = $selfconversation->id;
+                $member->userid = $message->useridfrom;
+                $member->timecreated = time();
+
+                $member->id = $DB->insert_record('message_conversation_members', $member);
+
+                // Star the self-conversation.
+                $favouriterecord = new \stdClass();
+                $favouriterecord->component = 'core_message';
+                $favouriterecord->itemtype = 'message_conversations';
+                $favouriterecord->itemid = $selfconversation->id;
+                $userctx = \context_user::instance($message->useridfrom);
+                $favouriterecord->contextid = $userctx->id;
+                $favouriterecord->userid = $message->useridfrom;
+                $favouriterecord->timecreated = time();
+                $favouriterecord->timemodified = $favouriterecord->timecreated;
+
+                $DB->insert_record('favourite', $favouriterecord);
+            }
+
+            // Create the object we will be inserting into the database.
+            $tabledata = new \stdClass();
+            $tabledata->useridfrom = $message->useridfrom;
+            $tabledata->conversationid = $selfconversation->id;
+            $tabledata->subject = $message->subject;
+            $tabledata->fullmessage = $message->fullmessage;
+            $tabledata->fullmessageformat = $message->fullmessageformat ?? FORMAT_MOODLE;
+            $tabledata->fullmessagehtml = $message->fullmessagehtml;
+            $tabledata->smallmessage = $message->smallmessage;
+            $tabledata->timecreated = $message->timecreated;
+
+            $messageid = $DB->insert_record('messages', $tabledata);
+
+            // Check if we need to mark this message as deleted (self-conversations add this information on the
+            // timeuserfromdeleted field.
+            if ($message->timeuserfromdeleted) {
+                $mua = new \stdClass();
+                $mua->userid = $message->useridfrom;
+                $mua->messageid = $messageid;
+                $mua->action = \core_message\api::MESSAGE_ACTION_DELETED;
+                $mua->timecreated = $message->timeuserfromdeleted;
+
+                $DB->insert_record('message_user_actions', $mua);
+            }
+
+            // Mark this message as read.
+            $mua = new \stdClass();
+            $mua->userid = $message->useridto;
+            $mua->messageid = $messageid;
+            $mua->action = \core_message\api::MESSAGE_ACTION_READ;
+            $mua->timecreated = $message->timeread;
+
+            $DB->insert_record('message_user_actions', $mua);
+        }
+        $legacyselfmessagesrs->close();
+
+        // We can now delete the records from legacy table because the self-conversations have been migrated from the legacy tables.
+        $DB->delete_records_select('message_read', $select);
+
+        // STEP 3. For existing users without self-conversations, create and star it.
+
+        // Get all the users without a self-conversation.
+        $sql = "SELECT u.id
+                  FROM {user} u
+                  WHERE u.id NOT IN (SELECT mcm.userid
+                                     FROM {message_conversation_members} mcm
+                                     INNER JOIN {message_conversations} mc
+                                             ON mc.id = mcm.conversationid AND mc.type = ?
+                                    )";
+        $useridsrs = $DB->get_recordset_sql($sql, [\core_message\api::MESSAGE_CONVERSATION_TYPE_SELF]);
+        // Create the self-conversation for all these users.
+        foreach ($useridsrs as $user) {
+            $conditions = [
+                'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
+                'convhash' => \core_message\helper::get_conversation_hash([$user->id])
+            ];
+            $selfconversation = $DB->get_record('message_conversations', $conditions);
+            if (empty($selfconversation)) {
+                // Create the self-conversation.
+                $selfconversation = new \stdClass();
+                $selfconversation->type = \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF;
+                $selfconversation->convhash = \core_message\helper::get_conversation_hash([$user->id]);
+                $selfconversation->enabled = 1;
+                $selfconversation->timecreated = time();
+                $selfconversation->timemodified = $selfconversation->timecreated;
+
+                $selfconversation->id = $DB->insert_record('message_conversations', $selfconversation);
+
+                // Add user to this self-conversation.
+                $member = new \stdClass();
+                $member->conversationid = $selfconversation->id;
+                $member->userid = $user->id;
+                $member->timecreated = time();
+
+                $member->id = $DB->insert_record('message_conversation_members', $member);
+
+                // Star the self-conversation.
+                $favouriterecord = new \stdClass();
+                $favouriterecord->component = 'core_message';
+                $favouriterecord->itemtype = 'message_conversations';
+                $favouriterecord->itemid = $selfconversation->id;
+                $userctx = \context_user::instance($user->id);
+                $favouriterecord->contextid = $userctx->id;
+                $favouriterecord->userid = $user->id;
+                $favouriterecord->timecreated = time();
+                $favouriterecord->timemodified = $favouriterecord->timecreated;
+
+                $DB->insert_record('favourite', $favouriterecord);
+            }
+        }
+        $useridsrs->close();
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019041800.01);
+    }
+
+    if ($oldversion < 2019042200.01) {
+
+        // Define table role_sortorder to be dropped.
+        $table = new xmldb_table('role_sortorder');
+
+        // Conditionally launch drop table for role_sortorder.
+        if ($dbman->table_exists($table)) {
+            $dbman->drop_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019042200.01);
+    }
+
+    if ($oldversion < 2019042200.02) {
+
+        // Let's update all (old core) targets to their new (core_course) locations.
+        $targets = [
+            '\core\analytics\target\course_competencies' => '\core_course\analytics\target\course_competencies',
+            '\core\analytics\target\course_completion' => '\core_course\analytics\target\course_completion',
+            '\core\analytics\target\course_dropout' => '\core_course\analytics\target\course_dropout',
+            '\core\analytics\target\course_gradetopass' => '\core_course\analytics\target\course_gradetopass',
+            '\core\analytics\target\no_teaching' => '\core_course\analytics\target\no_teaching',
+        ];
+
+        foreach ($targets as $oldclass => $newclass) {
+            $DB->set_field('analytics_models', 'target', $newclass, ['target' => $oldclass]);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019042200.02);
+    }
+
+    if ($oldversion < 2019042300.01) {
+        $sql = "UPDATE {capabilities}
+                   SET name = ?,
+                       contextlevel = ?
+                 WHERE name = ?";
+        $DB->execute($sql, ['moodle/category:viewcourselist', CONTEXT_COURSECAT, 'moodle/course:browse']);
+
+        $sql = "UPDATE {role_capabilities}
+                   SET capability = ?
+                 WHERE capability = ?";
+        $DB->execute($sql, ['moodle/category:viewcourselist', 'moodle/course:browse']);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019042300.01);
+    }
+
     return true;
 }
index 24feb65..7ddf633 100644 (file)
@@ -931,7 +931,7 @@ class database_manager {
         $schema = new xmldb_structure('export');
         $schema->setVersion($CFG->version);
 
-        foreach ($this->get_install_xml_file_list() as $filename)  {
+        foreach ($this->get_install_xml_files() as $filename) {
             $xmldb_file = new xmldb_file($filename);
             if (!$xmldb_file->loadXMLStructure()) {
                 continue;
index 2be3c40..3d05905 100644 (file)
@@ -204,7 +204,7 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
             // Normally this is cleaned as a side effect of it not being a valid option,
             // but in this case we need to detect and skip it manually.
             if ($value === '_qf__force_multiselect_submission' || $value === null) {
-                $value = '';
+                $value = $this->getMultiple() ? [] : '';
             }
             return $this->_prepareValue($value, $assoc);
         } else {
index 9e052da..b84b37a 100644 (file)
@@ -166,8 +166,9 @@ class MoodleQuickForm_select extends HTML_QuickForm_select implements templatabl
     */
     function exportValue(&$submitValues, $assoc = false)
     {
+        $emptyvalue = $this->getMultiple() ? [] : '';
         if (empty($this->_options)) {
-            return $this->_prepareValue(null, $assoc);
+            return $this->_prepareValue($emptyvalue, $assoc);
         }
 
         $value = $this->_findValue($submitValues);
@@ -187,7 +188,7 @@ class MoodleQuickForm_select extends HTML_QuickForm_select implements templatabl
         }
 
         if (empty($cleaned)) {
-            return $this->_prepareValue(null, $assoc);
+            return $this->_prepareValue($emptyvalue, $assoc);
         }
         if ($this->getMultiple()) {
             return $this->_prepareValue($cleaned, $assoc);
@@ -223,6 +224,7 @@ class MoodleQuickForm_select extends HTML_QuickForm_select implements templatabl
             $options[] = $o;
         }
         $context['options'] = $options;
+        $context['nameraw'] = $this->getName();
 
         return $context;
     }
index e40f457..d44b1a2 100644 (file)
@@ -48,6 +48,17 @@ class MoodleQuickForm_submit extends HTML_QuickForm_submit implements templatabl
      */
     protected $primary;
 
+    /**
+     * Any class apart from 'btn' would be overridden with this content.
+     *
+     * By default, submit buttons will utilize the btn-primary OR btn-secondary classes. However there are cases where we
+     * require a submit button with different stylings (e.g. btn-link). In these cases, $customclassoverride will override
+     * the defaults mentioned previously and utilize the provided class(es).
+     *
+     * @var string $customclassoverride Custom class override for the input element
+     */
+    protected $customclassoverride;
+
     /**
      * constructor
      *
@@ -55,8 +66,11 @@ class MoodleQuickForm_submit extends HTML_QuickForm_submit implements templatabl
      * @param string $value (optional) field label
      * @param string $attributes (optional) Either a typical HTML attribute string or an associative array
      * @param bool|null $primary Is this button a primary button?
+     * @param array $options Options to further customise the submit button. Currently accepted options are:
+     *                  customclassoverride String The CSS class to use for the button instead of the standard
+     *                                             btn-primary and btn-secondary classes.
      */
-    public function __construct($elementName=null, $value=null, $attributes=null, $primary = null) {
+    public function __construct($elementName=null, $value=null, $attributes=null, $primary = null, $options = []) {
         parent::__construct($elementName, $value, $attributes);
 
         // Fallback to legacy behaviour if no value specified.
@@ -65,6 +79,8 @@ class MoodleQuickForm_submit extends HTML_QuickForm_submit implements templatabl
         } else {
             $this->primary = $primary;
         }
+
+        $this->customclassoverride = $options['customclassoverride'] ?? false;
     }
 
     /**
@@ -131,6 +147,10 @@ class MoodleQuickForm_submit extends HTML_QuickForm_submit implements templatabl
         if (!$this->primary) {
             $context['secondary'] = true;
         }
+
+        if ($this->customclassoverride) {
+            $context['customclassoverride'] = $this->customclassoverride;
+        }
         return $context;
     }
 }
index ef3719f..2846813 100644 (file)
@@ -261,7 +261,6 @@ class MoodleQuickForm_tags extends MoodleQuickForm_autocomplete {
             $url = new moodle_url('/tag/manage.php', array('tc' => $this->get_tag_collection()));
             $context['managestandardtagsurl'] = $url->out(false);
         }
-        $context['nameraw'] = $this->getName();
 
         return $context;
     }
index bc7434a..175280e 100644 (file)
@@ -1,6 +1,9 @@
 {{< core_form/element-template-inline }}
     {{$element}}
         {{^element.frozen}}
+        {{#element.multiple}}
+            <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
+        {{/element.multiple}}
         <select class="custom-select {{#error}}is-invalid{{/error}}" name="{{element.name}}"
             id="{{element.id}}"
             {{#element.multiple}}multiple{{/element.multiple}}
index a7fea2c..8736f0a 100644 (file)
@@ -1,6 +1,9 @@
 {{< core_form/element-template }}
     {{$element}}
         {{^element.frozen}}
+        {{#element.multiple}}
+            <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
+        {{/element.multiple}}
         <select class="custom-select {{#error}}is-invalid{{/error}}" name="{{element.name}}"
             id="{{element.id}}"
             {{#element.multiple}}multiple{{/element.multiple}}
index 5af5684..bd5ae84 100644 (file)
@@ -1,6 +1,9 @@
 {{< core_form/element-template-inline }}
     {{$element}}
         {{^element.frozen}}
+        {{#element.multiple}}
+            <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
+        {{/element.multiple}}
     <select class="{{^element.multiple}}custom-select{{/element.multiple}}
                    {{#element.multiple}}form-control{{/element.multiple}}
                    {{#error}}is-invalid{{/error}}"
index 66c6db5..4966408 100644 (file)
@@ -21,6 +21,7 @@
 
     Context variables required for this template:
     * id - Element id,
+    * nameraw - Raw Element name without '[]'
     * name - Element name,
     * label -  Element label,
     * multiple - multi select?,
@@ -49,6 +50,7 @@
     {
         "element": {
             "id": "id_maildisplay",
+            "nameraw": "maildisplay",
             "name": "maildisplay",
             "label": null,
             "multiple": null,
@@ -92,6 +94,9 @@
 {{< core_form/element-template }}
     {{$element}}
         {{^element.frozen}}
+        {{#element.multiple}}
+            <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
+        {{/element.multiple}}
         <select class="{{^element.multiple}}custom-select{{/element.multiple}}
                        {{#element.multiple}}form-control{{/element.multiple}}
                        {{#error}}is-invalid{{/error}}"
index 7115850..8c2c047 100644 (file)
@@ -3,9 +3,12 @@
         {{^element.frozen}}
         <input type="submit"
                 class="btn
-                    {{^element.secondary}}btn-primary{{/element.secondary}}
-                    {{#element.secondary}}btn-secondary{{/element.secondary}}
-                    {{#error}} btn-danger {{/error}}"
+                    {{^element.customclassoverride}}
+                        {{^element.secondary}}btn-primary{{/element.secondary}}
+                        {{#element.secondary}}btn-secondary{{/element.secondary}}
+                    {{/element.customclassoverride}}
+                    {{#error}} btn-danger {{/error}}
+                    {{#element.customclassoverride}}{{element.customclassoverride}}{{/element.customclassoverride}}"
                 name="{{element.name}}"
                 id="{{element.id}}"
                 value="{{element.value}}"
index eca4f2b..cd8b8fa 100644 (file)
@@ -3,9 +3,13 @@
         {{^element.frozen}}
             <input type="submit"
                 class="btn
-                    {{^element.secondary}}btn-primary{{/element.secondary}}
-                    {{#element.secondary}}btn-secondary{{/element.secondary}}
-                    {{#error}} btn-danger {{/error}}"
+                    {{^element.customclassoverride}}
+                        {{^element.secondary}}btn-primary{{/element.secondary}}
+                        {{#element.secondary}}btn-secondary{{/element.secondary}}
+                    {{/element.customclassoverride}}
+                    {{#error}} btn-danger {{/error}}
+                    {{element.extraclasses}}
+                    {{#element.customclassoverride}}{{element.customclassoverride}}{{/element.customclassoverride}}"
                 name="{{element.name}}"
                 id="{{element.id}}"
                 {{#error}}
index 17b9c61..a3b3cf8 100644 (file)
@@ -49,7 +49,7 @@ class core_form_autocomplete_testcase extends basic_testcase {
         $submission = array('testel' => 2);
         $this->assertEquals($element->exportValue($submission), 2);
         $submission = array('testel' => 3);
-        $this->assertNull($element->exportValue($submission));
+        $this->assertEquals('', $element->exportValue($submission));
 
         // A select with multiple values validates the data.
         $options = array('1' => 'One', 2 => 'Two');
@@ -61,6 +61,18 @@ class core_form_autocomplete_testcase extends basic_testcase {
         $element = new MoodleQuickForm_autocomplete('testel', null, array(), array('multiple'=>'multiple', 'ajax'=>'anything'));
         $submission = array('testel' => array(2, 3));
         $this->assertEquals($element->exportValue($submission), array(2, 3));
+
+        // A select with single value without anything selected.
+        $options = array('1' => 'One', 2 => 'Two');
+        $element = new MoodleQuickForm_autocomplete('testel', null, $options);
+        $submission = array();
+        $this->assertEquals('', $element->exportValue($submission));
+
+        // A select with multiple values without anything selected.
+        $options = array('1' => 'One', 2 => 'Two');
+        $element = new MoodleQuickForm_autocomplete('testel', null, $options, array('multiple' => 'multiple'));
+        $submission = array();
+        $this->assertEquals([], $element->exportValue($submission));
     }
 
 }
index 2f3b417..1dcc437 100644 (file)
@@ -118,18 +118,30 @@ function message_send(\core\message\message $eventdata) {
             return false;
         }
 
-        if (!$conversationid = \core_message\api::get_conversation_between_users([$eventdata->userfrom->id,
-                                                                                  $eventdata->userto->id])) {
-            $conversation = \core_message\api::create_conversation(
-                \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
-                [
-                    $eventdata->userfrom->id,
-                    $eventdata->userto->id
-                ]
-            );
+        if ($eventdata->userfrom->id == $eventdata->userto->id) {
+            // It's a self conversation.
+            $conversation = \core_message\api::get_self_conversation($eventdata->userfrom->id);
+            if (empty($conversation)) {
+                $conversation = \core_message\api::create_conversation(
+                    \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
+                    [$eventdata->userfrom->id]
+                );
+            }
+        } else {
+            if (!$conversationid = \core_message\api::get_conversation_between_users([$eventdata->userfrom->id,
+                                                                                      $eventdata->userto->id])) {
+                // It's a private conversation between users.
+                $conversation = \core_message\api::create_conversation(
+                    \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+                    [
+                        $eventdata->userfrom->id,
+                        $eventdata->userto->id
+                    ]
+                );
+            }
         }
         // We either have found a conversation, or created one.
-        $conversationid = $conversationid ? $conversationid : $conversation->id;
+        $conversationid = !empty($conversationid) ? $conversationid : $conversation->id;
         $eventdata->convid = $conversationid;
     }
 
index ad2c738..321560d 100644 (file)
@@ -4791,9 +4791,11 @@ function update_internal_user_password($user, $password, $fasthash = false) {
  * @param string $field The user field to be checked for a given value.
  * @param string $value The value to match for $field.
  * @param int $mnethostid
+ * @param bool $throwexception If true, it will throw an exception when there's no record found or when there are multiple records
+ *                              found. Otherwise, it will just return false.
  * @return mixed False, or A {@link $USER} object.
  */
-function get_complete_user_data($field, $value, $mnethostid = null) {
+function get_complete_user_data($field, $value, $mnethostid = null, $throwexception = false) {
     global $CFG, $DB;
 
     if (!$field || !$value) {
@@ -4804,7 +4806,7 @@ function get_complete_user_data($field, $value, $mnethostid = null) {
     $field = core_text::strtolower($field);
 
     // List of case insensitive fields.
-    $caseinsensitivefields = ['username'];
+    $caseinsensitivefields = ['username', 'email'];
 
     // Build the WHERE clause for an SQL query.
     $params = array('fieldval' => $value);
@@ -4831,8 +4833,18 @@ function get_complete_user_data($field, $value, $mnethostid = null) {
     }
 
     // Get all the basic user data.
-    if (! $user = $DB->get_record_select('user', $constraints, $params)) {
-        return false;
+    try {
+        // Make sure that there's only a single record that matches our query.
+        // For example, when fetching by email, multiple records might match the query as there's no guarantee that email addresses
+        // are unique. Therefore we can't reliably tell whether the user profile data that we're fetching is the correct one.
+        $user = $DB->get_record_select('user', $constraints, $params, '*', MUST_EXIST);
+    } catch (dml_exception $exception) {
+        if ($throwexception) {
+            throw $exception;
+        } else {
+            // Return false when no records or multiple records were found.
+            return false;
+        }
     }
 
     // Get various settings and preferences.
index 27ee12d..4845832 100644 (file)
@@ -4090,11 +4090,7 @@ EOD;
                 $imagedata = $this->user_picture($user, array('size' => 100));
 
                 // Check to see if we should be displaying a message button.
-                if (!empty($CFG->messaging) && $USER->id != $user->id && has_capability('moodle/site:sendmessage', $context)) {
-                    $iscontact = \core_message\api::is_contact($USER->id, $user->id);
-                    $contacttitle = $iscontact ? 'removefromyourcontacts' : 'addtoyourcontacts';
-                    $contacturlaction = $iscontact ? 'removecontact' : 'addcontact';
-                    $contactimage = $iscontact ? 'removecontact' : 'addcontact';
+                if (!empty($CFG->messaging) && has_capability('moodle/site:sendmessage', $context)) {
                     $userbuttons = array(
                         'messages' => array(
                             'buttontype' => 'message',
@@ -4103,22 +4099,29 @@ EOD;
                             'image' => 'message',
                             'linkattributes' => \core_message\helper::messageuser_link_params($user->id),
                             'page' => $this->page
-                        ),
-                        'togglecontact' => array(
-                            'buttontype' => 'togglecontact',
-                            'title' => get_string($contacttitle, 'message'),
-                            'url' => new moodle_url('/message/index.php', array(
-                                    'user1' => $USER->id,
-                                    'user2' => $user->id,
-                                    $contacturlaction => $user->id,
-                                    'sesskey' => sesskey())
-                            ),
-                            'image' => $contactimage,
-                            'linkattributes' => \core_message\helper::togglecontact_link_params($user, $iscontact),
-                            'page' => $this->page
-                        ),
+                        )
                     );
 
+                    if ($USER->id != $user->id) {
+                        $iscontact = \core_message\api::is_contact($USER->id, $user->id);
+                        $contacttitle = $iscontact ? 'removefromyourcontacts' : 'addtoyourcontacts';
+                        $contacturlaction = $iscontact ? 'removecontact' : 'addcontact';
+                        $contactimage = $iscontact ? 'removecontact' : 'addcontact';
+                        $userbuttons['togglecontact'] = array(
+                                'buttontype' => 'togglecontact',
+                                'title' => get_string($contacttitle, 'message'),
+                                'url' => new moodle_url('/message/index.php', array(
+                                        'user1' => $USER->id,
+                                        'user2' => $user->id,
+                                        $contacturlaction => $user->id,
+                                        'sesskey' => sesskey())
+                                ),
+                                'image' => $contactimage,
+                                'linkattributes' => \core_message\helper::togglecontact_link_params($user, $iscontact),
+                                'page' => $this->page
+                            );
+                    }
+
                     $this->page->requires->string_for_js('changesmadereallygoaway', 'moodle');
                 }
             } else {
index 489a485..9bc529e 100644 (file)
@@ -819,6 +819,59 @@ class core_messagelib_testcase extends advanced_testcase {
         $sink->clear();
     }
 
+    /**
+     * Tests calling message_send() with $eventdata representing a message to a self-conversation.
+     *
+     * This test will verify:
+     * - that the 'messages' record is created.
+     * - that the processors is not called (for now self-conversations are not processed).
+     * - the a single event will be generated - 'message_sent'
+     *
+     * Note: We won't redirect/capture messages in this test because doing so causes message_send() to return early, before
+     * processors and events code is called. We need to test this code here, as we generally redirect messages elsewhere and we
+     * need to be sure this is covered.
+     */
+    public function test_message_send_to_self_conversation() {
+        global $DB;
+        $this->preventResetByRollback();
+        $this->resetAfterTest();
+
+        // Create some users and a conversation between them.
+        $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1));
+        set_config('allowedemaildomains', 'example.com');
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
+            [$user1->id]);
+
+        // Generate the message.
+        $message = new \core\message\message();
+        $message->courseid          = 1;
+        $message->component         = 'moodle';
+        $message->name              = 'instantmessage';
+        $message->userfrom          = $user1;
+        $message->convid            = $conversation->id;
+        $message->subject           = 'message subject 1';
+        $message->fullmessage       = 'message body';
+        $message->fullmessageformat = FORMAT_MARKDOWN;
+        $message->fullmessagehtml   = '<p>message body</p>';
+        $message->smallmessage      = 'small message';
+        $message->notification      = '0';
+
+        // Content specific to the email processor.
+        $content = array('*' => array('header' => ' test ', 'footer' => ' test '));
+        $message->set_additional_content('email', $content);
+
+        // Ensure we're going to hit the email processor for this user.
+        $DB->set_field_select('message_processors', 'enabled', 0, "name <> 'email'");
+        set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user1);
+
+        // Now, send a message and verify the message processors are empty (self-conversations are not processed for now).
+        $sink = $this->redirectEmails();
+        $messageid = message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(0, $emails);
+        $sink->clear();
+    }
+
     /**
      * Tests calling message_send() with $eventdata representing a message to an group conversation.
      *
@@ -836,13 +889,30 @@ class core_messagelib_testcase extends advanced_testcase {
         $this->preventResetByRollback();
         $this->resetAfterTest();
 
+        $course = $this->getDataGenerator()->create_course();
+
         // Create some users and a conversation between them.
         $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1));
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
         set_config('allowedemaildomains', 'example.com');
-        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
-            [$user1->id, $user2->id, $user3->id], 'Group project discussion');
+
+        // Create a group in the course.
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        groups_add_member($group1->id, $user1->id);
+        groups_add_member($group1->id, $user2->id);
+        groups_add_member($group1->id, $user3->id);
+
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id],
+            'Group project discussion',
+            \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+            'core_group',
+            'groups',
+            $group1->id,
+            context_course::instance($course->id)->id
+        );
 
         // Generate the message.
         $message = new \core\message\message();
@@ -867,11 +937,14 @@ class core_messagelib_testcase extends advanced_testcase {
         set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user2);
         set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user3);
 
-        // Now, send a message and verify the email processor is NOT hit.
-        $sink = $this->redirectEmails();
+        // Now, send a message and verify the email processor are hit.
         $messageid = message_send($message);
+
+        $sink = $this->redirectEmails();
+        $task = new \message_email\task\send_email_task();
+        $task->execute();
         $emails = $sink->get_messages();
-        $this->assertCount(0, $emails);
+        $this->assertCount(2, $emails);
 
         // Verify the record was created in 'messages'.
         $recordexists = $DB->record_exists('messages', ['id' => $messageid]);
@@ -902,14 +975,29 @@ class core_messagelib_testcase extends advanced_testcase {
         $this->preventResetByRollback();
         $this->resetAfterTest();
 
+        $course = $this->getDataGenerator()->create_course();
+
         $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1));
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
         set_config('allowedemaildomains', 'example.com');
 
-        // Create a conversation.
-        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
-            [$user1->id, $user2->id, $user3->id], 'Group project discussion');
+        // Create a group in the course.
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        groups_add_member($group1->id, $user1->id);
+        groups_add_member($group1->id, $user2->id);
+        groups_add_member($group1->id, $user3->id);
+
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id],
+            'Group project discussion',
+            \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+            'core_group',
+            'groups',
+            $group1->id,
+            context_course::instance($course->id)->id
+        );
 
         // Test basic email redirection.
         $this->assertFileExists("$CFG->dirroot/message/output/email/version.php");
@@ -939,20 +1027,20 @@ class core_messagelib_testcase extends advanced_testcase {
 
         $transaction = $DB->start_delegated_transaction();
         $sink = $this->redirectEmails();
-        $messageid = message_send($message);
+        message_send($message);
         $emails = $sink->get_messages();
         $this->assertCount(0, $emails);
-        $savedmessage = $DB->get_record('messages', array('id' => $messageid), '*', MUST_EXIST);
         $sink->clear();
         $this->assertFalse($DB->record_exists('message_user_actions', array()));
-        $DB->delete_records('messages', array());
         $events = $eventsink->get_events();
         $this->assertCount(0, $events);
         $eventsink->clear();
         $transaction->allow_commit();
         $events = $eventsink->get_events();
+        $task = new \message_email\task\send_email_task();
+        $task->execute();
         $emails = $sink->get_messages();
-        $this->assertCount(0, $emails); // Email processor is disabled for messages to group conversations.
+        $this->assertCount(2, $emails);
         $this->assertCount(1, $events);
         $this->assertInstanceOf('\core\event\group_message_sent', $events[0]);
     }
index fc742d5..db9ce6d 100644 (file)
@@ -4313,6 +4313,27 @@ class core_moodlelib_testcase extends advanced_testcase {
             'Fetch data using an invalid username' => [
                 'username', 's2', false
             ],
+            'Fetch by email' => [
+                'email', 's1@example.com', true
+            ],
+            'Fetch data using a non-existent email' => [
+                'email', 's2@example.com', false
+            ],
+            'Fetch data using a non-existent email, throw exception' => [
+                'email', 's2@example.com', false, dml_missing_record_exception::class
+            ],
+            'Multiple accounts with the same email' => [
+                'email', 's1@example.com', false, 1
+            ],
+            'Multiple accounts with the same email, throw exception' => [
+                'email', 's1@example.com', false, 1, dml_multiple_records_exception::class
+            ],
+            'Fetch data using a valid user ID' => [
+                'id', true, true
+            ],
+            'Fetch data using a non-existent user ID' => [
+                'id', false, false
+            ],
         ];
     }
 
@@ -4323,10 +4344,15 @@ class core_moodlelib_testcase extends advanced_testcase {
      * @param string $field The field to use for the query.
      * @param string|boolean $value The field value. When fetching by ID, set true to fetch valid user ID, false otherwise.
      * @param boolean $success Whether we expect for the fetch to succeed or return false.
+     * @param int $allowaccountssameemail Value for $CFG->allowaccountssameemail.
+     * @param string $expectedexception The exception to be expected.
      */
-    public function test_get_complete_user_data($field, $value, $success) {
+    public function test_get_complete_user_data($field, $value, $success, $allowaccountssameemail = 0, $expectedexception = '') {
         $this->resetAfterTest();
 
+        // Set config settings we need for our environment.
+        set_config('allowaccountssameemail', $allowaccountssameemail);
+
         // Generate the user data.
         $generator = $this->getDataGenerator();
         $userdata = [
@@ -4335,6 +4361,11 @@ class core_moodlelib_testcase extends advanced_testcase {
         ];
         $user = $generator->create_user($userdata);
 
+        if ($allowaccountssameemail) {
+            // Create another user with the same email address.
+            $generator->create_user(['email' => 's1@example.com']);
+        }
+
         // Since the data provider can't know what user ID to use, do a special handling for ID field tests.
         if ($field === 'id') {
             if ($value) {
@@ -4345,7 +4376,15 @@ class core_moodlelib_testcase extends advanced_testcase {
                 $value = $user->id + 1;
             }
         }
-        $fetcheduser = get_complete_user_data($field, $value);
+
+        // When an exception is expected.
+        $throwexception = false;
+        if ($expectedexception) {
+            $this->expectException($expectedexception);
+            $throwexception = true;
+        }
+
+        $fetcheduser = get_complete_user_data($field, $value, null, $throwexception);
         if ($success) {
             $this->assertEquals($user->id, $fetcheduser->id);
             $this->assertEquals($user->username, $fetcheduser->username);
index 4c55dc7..616ecc9 100644 (file)
@@ -60,7 +60,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enablecompletion' => 1,
                     'startdate' => mktime(0, 0, 0, 10, 24, $year + 1)
                 ],
-                'isvalid' => get_string('coursenotyetstarted')
+                'isvalid' => get_string('coursenotyetstarted', 'course')
             ],
             'coursenostudents' => [
                 'params' => [
@@ -68,7 +68,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'startdate' => mktime(0, 0, 0, 10, 24, $year - 2),
                     'enddate' => mktime(0, 0, 0, 10, 24, $year - 1)
                 ],
-                'isvalid' => get_string('nocoursestudents')
+                'isvalid' => get_string('nocoursestudents', 'course')
             ],
             'coursenosections' => [
                 'params' => [
@@ -76,7 +76,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'format' => 'social',
                     'students' => true
                 ],
-                'isvalid' => get_string('nocoursesections')
+                'isvalid' => get_string('nocoursesections', 'course')
             ],
             'coursenoendtime' => [
                 'params' => [
@@ -85,7 +85,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enddate' => 0,
                     'students' => true
                 ],
-                'isvalid' => get_string('nocourseendtime')
+                'isvalid' => get_string('nocourseendtime', 'course')
             ],
             'courseendbeforestart' => [
                 'params' => [
@@ -93,7 +93,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enddate' => mktime(0, 0, 0, 10, 23, $year - 2),
                     'students' => true
                 ],
-                'isvalid' => get_string('errorendbeforestart', 'analytics')
+                'isvalid' => get_string('errorendbeforestart', 'course')
             ],
             'coursetoolong' => [
                 'params' => [
@@ -102,7 +102,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enddate' => mktime(0, 0, 0, 10, 23, $year),
                     'students' => true
                 ],
-                'isvalid' => get_string('coursetoolong', 'analytics')
+                'isvalid' => get_string('coursetoolong', 'course')
             ],
             'coursealreadyfinished' => [
                 'params' => [
@@ -111,7 +111,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enddate' => mktime(0, 0, 0, 10, 23, $year - 1),
                     'students' => true
                 ],
-                'isvalid' => get_string('coursealreadyfinished'),
+                'isvalid' => get_string('coursealreadyfinished', 'course'),
                 'fortraining' => false
             ],
             'coursenotyetfinished' => [
@@ -121,7 +121,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
                     'enddate' => mktime(0, 0, 0, $month + 2, 23, $year),
                     'students' => true
                 ],
-                'isvalid' => get_string('coursenotyetfinished')
+                'isvalid' => get_string('coursenotyetfinished', 'course')
             ],
             'coursenocompletion' => [
                 'params' => [
@@ -207,7 +207,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
             $criterion->update_config($criteriadata);
         }
 
-        $target = new \core\analytics\target\course_completion();
+        $target = new \core_course\analytics\target\course_completion();
 
         // Test valid analysables.
 
@@ -242,7 +242,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $course = $this->getDataGenerator()->create_course($courserecord);
         $this->getDataGenerator()->enrol_user($user->id, $course->id, null, 'manual', $timestart, $timeend);
 
-        $target = new \core\analytics\target\course_completion();
+        $target = new \core_course\analytics\target\course_completion();
         $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
         $analysable = new \core_analytics\course($course);
 
@@ -303,7 +303,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $data = $this->setup_competencies_environment();
 
         $analysable = new \core_analytics\course($data['course']);
-        $target = new \core\analytics\target\course_competencies();
+        $target = new \core_course\analytics\target\course_competencies();
 
         $this->assertTrue($target->is_valid_analysable($analysable));
 
@@ -318,7 +318,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
 
         $data = $this->setup_competencies_environment();
 
-        $target = new \core\analytics\target\course_competencies();
+        $target = new \core_course\analytics\target\course_competencies();
         $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
         $analysable = new \core_analytics\course($data['course']);
 
@@ -330,7 +330,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $target->add_sample_data($samplesdata);
         $sampleid = reset($sampleids);
 
-        $class = new ReflectionClass('\core\analytics\target\course_competencies');
+        $class = new ReflectionClass('\core_course\analytics\target\course_competencies');
         $method = $class->getMethod('calculate_sample');
         $method->setAccessible(true);
 
@@ -364,7 +364,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $dg->enrol_user($student1->id, $course1->id, $studentrole->id);
 
         $analysable = new \core_analytics\course($course1);
-        $target = new \core\analytics\target\course_gradetopass();
+        $target = new \core_course\analytics\target\course_gradetopass();
         $this->assertEquals(get_string('gradetopassnotset', 'course'), $target->is_valid_analysable($analysable));
 
         // Set grade to pass.
@@ -372,7 +372,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $courseitem->gradepass = 50;
         $DB->update_record('grade_items', $courseitem);
         // Since the grade to pass value is cached in the target, a new one it is instanciated.
-        $target = new \core\analytics\target\course_gradetopass();
+        $target = new \core_course\analytics\target\course_gradetopass();
         $this->assertTrue($target->is_valid_analysable($analysable));
 
     }
@@ -416,7 +416,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $courseitem->gradepass = 50;
         $DB->update_record('grade_items', $courseitem);
 
-        $target = new \core\analytics\target\course_gradetopass();
+        $target = new \core_course\analytics\target\course_gradetopass();
         $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
         $analysable = new \core_analytics\course($course1);
 
@@ -427,7 +427,7 @@ class core_analytics_targets_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable);
         $target->add_sample_data($samplesdata);
 
-        $class = new ReflectionClass('\core\analytics\target\course_gradetopass');
+        $class = new ReflectionClass('\core_course\analytics\target\course_gradetopass');
         $method = $class->getMethod('calculate_sample');
         $method->setAccessible(true);
 
index d0baed6..1fa619a 100644 (file)
@@ -2,7 +2,6 @@ This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
 === 3.7 ===
-
 * Nodes in the navigation api can have labels for each group. See set/get_collectionlabel().
 * The method core_user::is_real_user() now returns false for userid = 0 parameter
 * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id
@@ -26,11 +25,23 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
   When the parameter is set to that constant, the function won't process file merging, keeping the original state of the file area.
 * Introduced new callback for plugin developers '<component>_pre_processor_message_send($procname, $proceventdata)':
   This will allow any plugin to manipulate messages or notifications before they are sent by a processor (email, mobile...)
-* New capability 'moodle/course:browse' in category context that controls whether user is able to browse list of courses
+* New capability 'moodle/category:viewcourselist' in category context that controls whether user is able to browse list of courses
   in this category. To work with list of courses use API methods in core_course_category and also 'course' form element.
 * It is possible to pass additional conditions to get_courses_search();
   core_course_category::search_courses() now allows to search only among courses with completion enabled.
 * Add support for a new xxx_after_require_login callback
+* A new conversation type has been created for self-conversations. During the upgrading process:
+  - Firstly, the existing self-conversations will be starred and migrated to the new type, removing the duplicated members in the
+  message_conversation_members table.
+  - Secondly, the legacy self conversations will be migrated from the legacy 'message_read' table. They will be created using the
+  new conversation type and will be favourited.
+  - Finally, the self-conversations for all remaining users without them will be created and starred.
+Besides, from now, a self-conversation will be created and starred by default to all the new users (even when $CFG->messaging
+is disabled).
+* New optional parameter $throwexception for \get_complete_user_data(). If true, an exception will be thrown when there's no
+  matching record found or when there are multiple records found for the given field value. If false, it will simply return false.
+  Defaults to false when not set.
+* Exposed submit button to allow custom styling (via customclassoverride variable) which can override btn-primary/btn-secondary classes
 
 === 3.6 ===
 
@@ -646,13 +657,13 @@ the groupid field.
       $OUTPUT->download_dataformat_selector() instead.
   when building Xpath, or pass the unescaped value when using the named selector.
 * Add new file_is_executable(), to consistently check for executables even in Windows (PHP bug #41062).
-* Introduced new callbacks for plugin developers.
+* Introduced new hooks for plugin developers.
     - <component>_pre_course_category_delete($category)
     - <component>_pre_course_delete($course)
     - <component>_pre_course_module_delete($cm)
     - <component>_pre_block_delete($instance)
     - <component>_pre_user_delete($user)
-  These callbacks allow developers to use the item in question before it is deleted by core. For example, if your plugin is
+  These hooks allow developers to use the item in question before it is deleted by core. For example, if your plugin is
   a module (plugins located in the mod folder) called 'xxx' and you wish to interact with the user object before it is
   deleted then the function to create would be mod_xxx_pre_user_delete($user) in mod/xxx/lib.php.
 * pear::Net::GeoIP has been removed.
index eb29ae1..4264333 100644 (file)
@@ -355,18 +355,21 @@ function core_login_validate_forgot_password_data($data) {
         if (!validate_email($data['email'])) {
             $errors['email'] = get_string('invalidemail');
 
-        } else if ($DB->count_records('user', array('email' => $data['email'])) > 1) {
-            $errors['email'] = get_string('forgottenduplicate');
-
         } else {
-            if ($user = get_complete_user_data('email', $data['email'])) {
+            try {
+                $user = get_complete_user_data('email', $data['email'], null, true);
                 if (empty($user->confirmed)) {
                     send_confirmation_email($user);
                     $errors['email'] = get_string('confirmednot');
                 }
-            }
-            if (!$user and empty($CFG->protectusernames)) {
-                $errors['email'] = get_string('emailnotfound');
+            } catch (dml_missing_record_exception $missingexception) {
+                // User not found. Show error when $CFG->protectusernames is turned off.
+                if (empty($CFG->protectusernames)) {
+                    $errors['email'] = get_string('emailnotfound');
+                }
+            } catch (dml_multiple_records_exception $multipleexception) {
+                // Multiple records found. Ask the user to enter a username instead.
+                $errors['email'] = get_string('forgottenduplicate');
             }
         }
 
index fccb928..160d9de 100644 (file)
@@ -271,6 +271,11 @@ class core_login_lib_testcase extends advanced_testcase {
                 ['email' => get_string('forgottenduplicate')],
                 ['allowaccountssameemail' => 1]
             ],
+            'Multiple accounts with the same email but with different case' => [
+                ['email' => 'S1@EXAMPLE.COM'],
+                ['email' => get_string('forgottenduplicate')],
+                ['allowaccountssameemail' => 1]
+            ],
             'Non-existent email, username protection on' => [
                 ['email' => 's2@example.com']
             ],
index 61a1942..89a5dc0 100644 (file)
@@ -38,13 +38,20 @@ require_once($CFG->dirroot . '/webservice/lib.php');
 
 $token = required_param('token', PARAM_ALPHANUM);
 $video = required_param('video', PARAM_ALPHANUM);   // Video ids are numeric, but it's more solid to expect things like 00001.
-$width = required_param('width', PARAM_INT);
-$height = required_param('height', PARAM_INT);
+$width = optional_param('width', 0, PARAM_INT);
+$height = optional_param('height', 0, PARAM_INT);
 
 // Authenticate the user.
 $webservicelib = new webservice();
 $webservicelib->authenticate_user($token);
 
+if (empty($width) && empty($height)) {
+    // Use the full page. The video will keep the ratio.
+    $display = 'style="position:absolute; top:0; left:0; width:100%; height:100%;"';
+} else {
+    $display = "width=\"$width\" height=\"$height\"";
+}
+
 $output = <<<OET
 <html>
     <head>
@@ -52,7 +59,7 @@ $output = <<<OET
     </head>
     <body style="margin:0; padding:0">
         <iframe src="https://player.vimeo.com/video/$video"
-            width="$width" height="$height" frameborder="0"
+            $display frameborder="0"
             webkitallowfullscreen mozallowfullscreen allowfullscreen>
         </iframe>
     </body>
index 453da2f..4c5809e 100644 (file)
Binary files a/message/amd/build/message_drawer.min.js and b/message/amd/build/message_drawer.min.js differ
index ef9875e..a135ac2 100644 (file)
Binary files a/message/amd/build/message_drawer_router.min.js and b/message/amd/build/message_drawer_router.min.js differ
index 6d5e118..21837f5 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation.min.js and b/message/amd/build/message_drawer_view_conversation.min.js differ
index a45db35..2c2761d 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_constants.min.js and b/message/amd/build/message_drawer_view_conversation_constants.min.js differ
index 7ec4cfd..1fdea1f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_patcher.min.js and b/message/amd/build/message_drawer_view_conversation_patcher.min.js differ
index b23ddc6..9646cb8 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js and b/message/amd/build/message_drawer_view_conversation_renderer.min.js differ
index 7ce1ee6..c6c52b6 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview.min.js and b/message/amd/build/message_drawer_view_overview.min.js differ
index 42e5adf..7fb488b 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 2f3881e..e5e3ac5 100644 (file)
Binary files a/message/amd/build/message_drawer_view_search.min.js and b/message/amd/build/message_drawer_view_search.min.js differ
index 999ba17..4b0e943 100644 (file)
Binary files a/message/amd/build/message_repository.min.js and b/message/amd/build/message_repository.min.js differ
index 8823ac0..d7a1bad 100644 (file)
@@ -53,6 +53,8 @@ function(
 ) {
 
     var SELECTORS = {
+        PANEL_BODY_CONTAINER: '[data-region="panel-body-container"]',
+        PANEL_HEADER_CONTAINER: '[data-region="panel-header-container"]',
         VIEW_CONTACT: '[data-region="view-contact"]',
         VIEW_CONTACTS: '[data-region="view-contacts"]',
         VIEW_CONVERSATION: '[data-region="view-conversation"]',
@@ -77,10 +79,16 @@ function(
      * @return {array} elements Found route container objects.
     */
     var getParametersForRoute = function(namespace, root, selector) {
-        var candidates = root.children();
-        var header = candidates.filter(SELECTORS.HEADER_CONTAINER).find(selector);
-        var body = candidates.filter(SELECTORS.BODY_CONTAINER).find(selector);
-        var footer = candidates.filter(SELECTORS.FOOTER_CONTAINER).find(selector);
+
+        var header = root.find(SELECTORS.HEADER_CONTAINER).find(selector);
+        if (!header.length) {
+            header = root.find(SELECTORS.PANEL_HEADER_CONTAINER).find(selector);
+        }
+        var body = root.find(SELECTORS.BODY_CONTAINER).find(selector);
+        if (!body.length) {
+            body = root.find(SELECTORS.PANEL_BODY_CONTAINER).find(selector);
+        }
+        var footer = root.find(SELECTORS.FOOTER_CONTAINER).find(selector);
 
         return [
             namespace,
@@ -193,6 +201,7 @@ function(
             var params = paramAttributes.map(function(attribute) {
                 return attribute.nodeValue;
             });
+
             var routeParams = [namespace, route].concat(params);
 
             Router.go.apply(null, routeParams);
@@ -267,12 +276,16 @@ function(
         registerEventListeners(uniqueId, root, alwaysVisible);
         if (alwaysVisible) {
             show(uniqueId, root);
+            // Are we sending to a specific user?
             if (sendToUser) {
+                // Check if a conversation already exists, if not, create one.
                 if (conversationId) {
                     Router.go(uniqueId, Routes.VIEW_CONVERSATION, conversationId);
                 } else {
                     Router.go(uniqueId, Routes.VIEW_CONVERSATION, null, 'create', sendToUser);
                 }
+            } else if (conversationId) { // We aren't sending to a specific user, but to a group conversation.
+                Router.go(uniqueId, Routes.VIEW_CONVERSATION, conversationId);
             }
         }
     };
index 5134ee6..a03b86c 100644 (file)
@@ -80,6 +80,9 @@ function(
      */
     var changeRoute = function(namespace, newRoute) {
         var newConfig;
+
+        // Check of the Route change call is made from an element in the app panel.
+        var fromPanel = [].slice.call(arguments).includes('frompanel');
         // Get the rest of the arguments, if any.
         var args = [].slice.call(arguments, 2);
         var renderPromise = $.Deferred().resolve().promise();
@@ -99,13 +102,24 @@ function(
                 }
 
                 element.removeClass('previous');
+                element.attr('data-from-panel', false);
 
                 if (isMatch) {
+                    if (fromPanel) {
+                        // Set this attribute to let the conversation renderer know not to show a back button.
+                        element.attr('data-from-panel', true);
+                    }
                     element.removeClass('hidden');
                     element.attr('aria-hidden', false);
                 } else {
-                    element.addClass('hidden');
-                    element.attr('aria-hidden', true);
+                    // For the message index page elements in the left panel should not be hidden.
+                    if (!element.attr('data-in-panel')) {
+                        element.addClass('hidden');
+                        element.attr('aria-hidden', true);
+                    } else if (newRoute == 'view-search' || newRoute == 'view-overview') {
+                        element.addClass('hidden');
+                        element.attr('aria-hidden', true);
+                    }
                 }
             });
         });
@@ -163,7 +177,8 @@ function(
      */
     var go = function(namespace) {
         var currentFocusElement = $(document.activeElement);
-        var record = changeRoute.apply(null, arguments);
+
+        var record = changeRoute.apply(namespace, arguments);
         var inHistory = false;
 
         if (!history[namespace]) {
@@ -222,7 +237,6 @@ function(
                                 if (typeof element !== 'object' || !element) {
                                     return;
                                 }
-
                                 // Update the aria label for the back button.
                                 element.find(SELECTORS.ROUTES_BACK).attr('aria-label', label);
                             });
index 8b8949b..1b4050b 100644 (file)
@@ -120,11 +120,16 @@ function(
      * @return {Number} Userid.
      */
     var getOtherUserId = function() {
-        if (!viewState || viewState.type != CONVERSATION_TYPES.PRIVATE) {
+        if (!viewState || viewState.type == CONVERSATION_TYPES.PUBLIC) {
             return null;
         }
 
         var loggedInUserId = viewState.loggedInUserId;
+        if (viewState.type == CONVERSATION_TYPES.SELF) {
+            // It's a self-conversation, so the other user is the one logged in.
+            return loggedInUserId;
+        }
+
         var otherUserIds = Object.keys(viewState.members).filter(function(userId) {
             return loggedInUserId != userId;
         });
@@ -144,7 +149,7 @@ function(
             if (!carry) {
                 var state = stateCache[id].state;
 
-                if (state.type == CONVERSATION_TYPES.PRIVATE) {
+                if (state.type != CONVERSATION_TYPES.PUBLIC) {
                     if (userId in state.members) {
                         // We've found a cached conversation for this user!
                         carry = state.id;
@@ -269,6 +274,9 @@ function(
      */
     var loadEmptyPrivateConversation = function(loggedInUserProfile, otherUserId) {
         var loggedInUserId = loggedInUserProfile.id;
+        // If the other user id is the same as the logged in user then this is a self
+        // conversation.
+        var conversationType = loggedInUserId == otherUserId ? CONVERSATION_TYPES.SELF : CONVERSATION_TYPES.PRIVATE;
         var newState = StateManager.setLoadingMembers(viewState, true);
         newState = StateManager.setLoadingMessages(newState, true);
         return render(newState)
@@ -283,13 +291,16 @@ function(
                 }
             })
             .then(function(profile) {
-                var newState = StateManager.addMembers(viewState, [profile, loggedInUserProfile]);
+                // If the conversation is a self conversation then the profile loaded is the
+                // logged in user so only add that to the members array.
+                var members = conversationType == CONVERSATION_TYPES.SELF ? [profile] : [profile, loggedInUserProfile];
+                var newState = StateManager.addMembers(viewState, members);
                 newState = StateManager.setLoadingMembers(newState, false);
                 newState = StateManager.setLoadingMessages(newState, false);
                 newState = StateManager.setName(newState, profile.fullname);
-                newState = StateManager.setType(newState, 1);
+                newState = StateManager.setType(newState, conversationType);
                 newState = StateManager.setImageUrl(newState, profile.profileimageurl);
-                newState = StateManager.setTotalMemberCount(newState, 2);
+                newState = StateManager.setTotalMemberCount(newState, members.length);
                 return render(newState)
                     .then(function() {
                         return profile;
@@ -310,14 +321,22 @@ function(
      * @return {Object} new state.
      */
     var updateStateFromConversation = function(conversation, loggedInUserId) {
-        var otherUsers = conversation.members.filter(function(member) {
-            return member.id != loggedInUserId;
-        });
-        var otherUser = otherUsers.length ? otherUsers[0] : null;
+        var otherUser = null;
+        if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
+            // For private conversations, remove current logged in user from the members list to get the other user.
+            var otherUsers = conversation.members.filter(function(member) {
+                return member.id != loggedInUserId;
+            });
+            otherUser = otherUsers.length ? otherUsers[0] : null;
+        } else if (conversation.type == CONVERSATION_TYPES.SELF) {
+            // Self-conversations have only one member.
+            otherUser = conversation.members[0];
+        }
+
         var name = conversation.name;
         var imageUrl = conversation.imageurl;
 
-        if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
+        if (conversation.type != CONVERSATION_TYPES.PUBLIC) {
             name = name || otherUser ? otherUser.fullname : '';
             imageUrl = imageUrl || otherUser ? otherUser.profileimageurl : '';
         }
@@ -921,6 +940,7 @@ function(
                 newState = StateManager.setPendingDeleteConversation(newState, false);
                 newState = StateManager.setLoadingConfirmAction(newState, false);
                 PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
+
                 return render(newState);
             });
     };
@@ -1019,7 +1039,7 @@ function(
         var newConversationId = null;
         return render(newState)
             .then(function() {
-                if (!conversationId && viewState.type == CONVERSATION_TYPES.PRIVATE) {
+                if (!conversationId && (viewState.type != CONVERSATION_TYPES.PUBLIC)) {
                     // If it's a new private conversation then we need to use the old
                     // web service function to create the conversation.
                     var otherUserId = getOtherUserId();
@@ -1471,7 +1491,7 @@ function(
     };
 
     /**
-     * Load a new empty private conversation between two users.
+     * Load a new empty private conversation between two users or self-conversation.
      *
      * @param  {Object} body Conversation body container element.
      * @param  {Object} loggedInUserProfile The logged in user's profile.
@@ -1483,7 +1503,9 @@ function(
         // state manager and patcher can work correctly.
         return resetState(body, null, loggedInUserProfile)
             .then(function() {
-                return Repository.getConversationBetweenUsers(
+                if (loggedInUserProfile.id != otherUserId) {
+                    // Private conversation between two different users.
+                    return Repository.getConversationBetweenUsers(
                         loggedInUserProfile.id,
                         otherUserId,
                         true,
@@ -1493,15 +1515,24 @@ function(
                         LOAD_MESSAGE_LIMIT,
                         0,
                         NEWEST_FIRST
-                    )
-                    .then(function(conversation) {
-                        // Looks like we have a conversation after all! Let's use that.
-                        return resetByConversation(body, conversation, loggedInUserProfile);
-                    })
-                    .catch(function() {
-                        // Can't find a conversation. Oh well. Just load up a blank one.
-                        return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
-                    });
+                    );
+                } else {
+                    // Self conversation.
+                    return Repository.getSelfConversation(
+                        loggedInUserProfile.id,
+                        LOAD_MESSAGE_LIMIT,
+                        0,
+                        NEWEST_FIRST
+                    );
+                }
+            })
+            .then(function(conversation) {
+                // Looks like we have a conversation after all! Let's use that.
+                return resetByConversation(body, conversation, loggedInUserProfile);
+            })
+            .catch(function() {
+                // Can't find a conversation. Oh well. Just load up a blank one.
+                return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
             });
     };
 
index 3bd84c8..04b6584 100644 (file)
@@ -78,6 +78,7 @@ define([], function() {
         MORE_MESSAGES_LOADING_ICON_CONTAINER: '[data-region="more-messages-loading-icon-container"]',
         MUTED_ICON_CONTAINER: '[data-region="muted-icon-container"]',
         PLACEHOLDER_CONTAINER: '[data-region="placeholder-container"]',
+        SELF_CONVERSATION_MESSAGE_CONTAINER: '[data-region="self-conversation-message-container"]',
         SEND_MESSAGE_BUTTON: '[data-action="send-message"]',
         SEND_MESSAGE_ICON_CONTAINER: '[data-region="send-icon-container"]',
         TEXT: '[data-region="text"]',
@@ -88,14 +89,17 @@ define([], function() {
         HEADER_PRIVATE: 'core_message/message_drawer_view_conversation_header_content_type_private',
         HEADER_PRIVATE_NO_CONTROLS: 'core_message/message_drawer_view_conversation_header_content_type_private_no_controls',
         HEADER_PUBLIC: 'core_message/message_drawer_view_conversation_header_content_type_public',
+        HEADER_SELF: 'core_message/message_drawer_view_conversation_header_content_type_self',
         DAY: 'core_message/message_drawer_view_conversation_body_day',
         MESSAGE: 'core_message/message_drawer_view_conversation_body_message',
         MESSAGES: 'core_message/message_drawer_view_conversation_body_messages'
     };
 
+    // Conversation types. They must have the same values defined in \core_message\api.
     var CONVERSATION_TYPES = {
         PRIVATE: 1,
-        PUBLIC: 2
+        PUBLIC: 2,
+        SELF: 3
     };
 
     return {
index 43ea13c..1d59260 100644 (file)
@@ -313,6 +313,37 @@ function(
         return null;
     };
 
+    /**
+     * Build a patch for the header of this conversation. Check if this conversation
+     * is a group conversation.
+     *
+     * @param  {Object} state The current state.
+     * @param  {Object} newState The new state.
+     * @return {Object} patch
+     */
+    var buildHeaderPatchTypeSelf = function(state, newState) {
+        var shouldRenderHeader = (state.name === null && newState.name !== null);
+
+        if (shouldRenderHeader) {
+            return {
+                type: Constants.CONVERSATION_TYPES.SELF,
+                // Don't display the controls for the self-conversations.
+                showControls: false,
+                context: {
+                    id: newState.id,
+                    name: newState.name,
+                    subname: newState.subname,
+                    imageurl: newState.imageUrl,
+                    isfavourite: newState.isFavourite,
+                    // Don't show favouriting if we don't have a conversation.
+                    showfavourite: newState.id !== null,
+                    showonlinestatus: true,
+                }
+            };
+        }
+
+        return null;
+    };
 
     /**
      * Build a patch for the header of this conversation. Check if this conversation
@@ -531,11 +562,11 @@ function(
      *
      * @param  {Object} state The current state.
      * @param  {Object} newState The new state.
-     * @return {Bool|Null}
+     * @return {int|Null} The conversation type of the messages to be deleted.
      */
     var buildConfirmDeleteSelectedMessages = function(state, newState) {
         if (newState.pendingDeleteMessageIds.length) {
-            return true;
+            return newState.type;
         } else if (state.pendingDeleteMessageIds.length) {
             return false;
         }
@@ -548,11 +579,11 @@ function(
      *
      * @param  {Object} state The current state.
      * @param  {Object} newState The new state.
-     * @return {Bool|Null}
+     * @return {int|Null} The conversation type to be deleted.
      */
     var buildConfirmDeleteConversation = function(state, newState) {
         if (!state.pendingDeleteConversation && newState.pendingDeleteConversation) {
-            return true;
+            return newState.type;
         } else if (state.pendingDeleteConversation && !newState.pendingDeleteConversation) {
             return false;
         }
@@ -948,6 +979,11 @@ function(
         var oldOtherUser = getOtherUserFromState(state);
         var newOtherUser = getOtherUserFromState(newState);
 
+        if (newState.type == Constants.CONVERSATION_TYPES.SELF) {
+            // Users always can send message themselves on self-conversations.
+            return null;
+        }
+
         if (!oldOtherUser && !newOtherUser) {
             return null;
         } else if (oldOtherUser && !newOtherUser) {
@@ -1104,6 +1140,23 @@ function(
         return null;
     };
 
+    /**
+     * We should show this message always, for all the self-conversations.
+     *
+     * The message should be hidden when it's not a self-conversation.
+     *
+     * @param  {Object} state The current state.
+     * @param  {Object} newState The new state.
+     * @return {bool}
+     */
+    var buildSelfConversationMessage = function(state, newState) {
+        if (state.type != newState.type) {
+            return (newState.type == Constants.CONVERSATION_TYPES.SELF);
+        }
+
+        return null;
+    };
+
     /**
      * We should show the contact request sent message if the user just sent
      * a contact request to the other user and there are no messages in the
@@ -1190,6 +1243,13 @@ function(
             header: buildHeaderPatchTypePublic,
             footer: buildFooterPatchTypePublic,
         };
+        // These build functions are only applicable to self-conversations.
+        config[Constants.CONVERSATION_TYPES.SELF] = {
+            header: buildHeaderPatchTypeSelf,
+            footer: buildFooterPatchTypePublic,
+            confirmDeleteConversation: buildConfirmDeleteConversation,
+            selfConversationMessage: buildSelfConversationMessage
+        };
 
         var patchConfig = $.extend({}, config.all);
         if (newState.type && newState.type in config) {
index d0b67f2..ab213b7 100644 (file)
@@ -75,6 +75,26 @@ function(
         getMessagesContainer(body).addClass('hidden');
     };
 
+    /**
+     * Get the self-conversation message container element.
+     *
+     * @param  {Object} body Conversation body container element.
+     * @return {Object} The messages container element.
+     */
+    var getSelfConversationMessageContainer = function(body) {
+        return body.find(SELECTORS.SELF_CONVERSATION_MESSAGE_CONTAINER);
+    };
+
+    /**
+     * Hide the self-conversation message container element.
+     *
+     * @param  {Object} body Conversation body container element.
+     * @return {Object} The messages container element.
+     */
+    var hideSelfConversationMessageContainer = function(body) {
+        return getSelfConversationMessageContainer(body).addClass('hidden');
+    };
+
     /**
      * Get the contact request sent container element.
      *
@@ -780,9 +800,11 @@ function(
     var renderHeader = function(header, body, footer, data) {
         var headerContainer = getHeaderContent(header);
         var template = TEMPLATES.HEADER_PUBLIC;
-
+        data.context.showrouteback = (header.attr('data-from-panel') === "false");
         if (data.type == CONVERSATION_TYPES.PRIVATE) {
             template = data.showControls ? TEMPLATES.HEADER_PRIVATE : TEMPLATES.HEADER_PRIVATE_NO_CONTROLS;
+        } else if (data.type == CONVERSATION_TYPES.SELF) {
+            template = TEMPLATES.HEADER_SELF;
         }
 
         return Templates.render(template, data.context)
@@ -1121,12 +1143,21 @@ function(
      * @param {Object} header The header container element.
      * @param {Object} body The body container element.
      * @param {Object} footer The footer container element.
-     * @param {Bool} show If the dialogue should show.
+     * @param {int|Null} type The messages conversation type to be removed.
      * @return {Object} jQuery promise
      */
-    var renderConfirmDeleteSelectedMessages = function(header, body, footer, show) {
-        if (show) {
-            return Str.get_string('deleteselectedmessagesconfirm', 'core_message')
+    var renderConfirmDeleteSelectedMessages = function(header, body, footer, type) {
+        var showmessage = null;
+        if (type == CONVERSATION_TYPES.SELF) {
+            // Message displayed to self-conversations is slighly different.
+            showmessage = 'deleteselectedmessagesconfirmselfconversation';
+        } else if (type) {
+            // This other message should be displayed.
+            showmessage = 'deleteselectedmessagesconfirm';
+        }
+
+        if (showmessage) {
+            return Str.get_string(showmessage, 'core_message')
                 .then(function(string) {
                     return showConfirmDialogue(
                         header,
@@ -1150,12 +1181,21 @@ function(
      * @param {Object} header The header container element.
      * @param {Object} body The body container element.
      * @param {Object} footer The footer container element.
-     * @param {Bool} show If the dialogue should show
+     * @param {int|Null} type The conversation type to be removed.
      * @return {Object} jQuery promise
      */
-    var renderConfirmDeleteConversation = function(header, body, footer, show) {
-        if (show) {
-            return Str.get_string('deleteallconfirm', 'core_message')
+    var renderConfirmDeleteConversation = function(header, body, footer, type) {
+        var showmessage = null;
+        if (type == CONVERSATION_TYPES.SELF) {
+            // Message displayed to self-conversations is slighly different.
+            showmessage = 'deleteallselfconfirm';
+        } else if (type) {
+            // This other message should be displayed.
+            showmessage = 'deleteallconfirm';
+        }
+
+        if (showmessage) {
+            return Str.get_string(showmessage, 'core_message')
                 .then(function(string) {
                     return showConfirmDialogue(
                         header,
@@ -1437,6 +1477,25 @@ function(
         }
     };
 
+    /**
+     * Show or hide the self-conversation message.
+     *
+     * @param {Object} header The header container element.
+     * @param {Object} body The body container element.
+     * @param {Object} footer The footer container element.
+     * @param {Object} displayMessage should the message be displayed?.
+     * @return {Object|true} jQuery promise
+     */
+    var renderSelfConversationMessage = function(header, body, footer, displayMessage) {
+        var container = getSelfConversationMessageContainer(body);
+        if (displayMessage) {
+            container.removeClass('hidden');
+        } else {
+            container.addClass('hidden');
+        }
+        return true;
+    };
+
     /**
      * Show or hide the require add contact panel.
      *
@@ -1472,6 +1531,7 @@ function(
     var renderReset = function(header, body, footer) {
         hideConfirmDialogue(header, body, footer);
         hideContactRequestSentContainer(body);
+        hideSelfConversationMessageContainer(body);
         hideAllHeaderElements(header);
         showHeaderPlaceholder(header);
         hideAllFooterElements(footer);
@@ -1499,6 +1559,7 @@ function(
                 confirmDeleteConversation: renderConfirmDeleteConversation,
                 confirmContactRequest: renderConfirmContactRequest,
                 requireAddContact: renderRequireAddContact,
+                selfConversationMessage: renderSelfConversationMessage,
                 contactRequestSent: renderContactRequestSent
             },
             {
index 74f4d71..8b6e979 100644 (file)
@@ -30,7 +30,8 @@ define(
     'core_message/message_drawer_routes',
     'core_message/message_drawer_events',
     'core_message/message_drawer_view_overview_section',
-    'core_message/message_repository'
+    'core_message/message_repository',
+    'core_message/message_drawer_view_conversation_constants'
 ],
 function(
     $,
@@ -41,7 +42,8 @@ function(
     Routes,
     MessageDrawerEvents,
     Section,
-    MessageRepository
+    MessageRepository,
+    Constants
 ) {
 
     var SELECTORS = {
@@ -53,9 +55,11 @@ function(
         SECTION_TOGGLE_BUTTON: '[data-toggle]'
     };
 
-    var CONVERSATION_TYPES = {
-        PRIVATE: 1,
-        PUBLIC: 2,
+    // Categories displayed in the message drawer. Some methods (such as filterCountsByType) are expecting their value
+    // will be the same as the defined in the CONVERSATION_TYPES, except for the favourite.
+    var OVERVIEW_SECTION_TYPES = {
+        PRIVATE: [Constants.CONVERSATION_TYPES.PRIVATE, Constants.CONVERSATION_TYPES.SELF],
+        PUBLIC: [Constants.CONVERSATION_TYPES.PUBLIC],
         FAVOURITE: null
     };
 
@@ -85,11 +89,24 @@ function(
      * This is used on the result returned by the loadAllCounts function.
      *
      * @param {Object} counts Conversation counts indexed by conversation type.
-     * @param {String|null} type The conversation type (null for favourites only).
+     * @param {Array|null} types The conversation types handlded by this section (null for all conversation types).
+     * @param {bool} includeFavourites If this section includes favourites
      * @return {Number}
      */
-    var filterCountsByType = function(counts, type) {
-        return type === CONVERSATION_TYPES.FAVOURITE ? counts.favourites : counts.types[type];
+    var filterCountsByTypes = function(counts, types, includeFavourites) {
+        var total = 0;
+
+        if (types && types.length) {
+            total = types.reduce(function(carry, type) {
+                return carry + counts.types[type];
+            }, total);
+        }
+
+        if (includeFavourites) {
+            total += counts.favourites;
+        }
+
+        return total;
     };
 
     /**
@@ -219,6 +236,7 @@ function(
             registerEventListeners(namespace, header);
             header.attr('data-init', true);
         }
+        var fromPanel = header.attr('data-in-panel') ? 'frompanel' : null;
 
         getSearchInput(header).val('');
         var loggedInUserId = getLoggedInUserId(body);
@@ -226,34 +244,35 @@ function(
 
         var sections = [
             // Favourite conversations section.
-            [body.find(SELECTORS.FAVOURITES), CONVERSATION_TYPES.FAVOURITE, true],
+            [body.find(SELECTORS.FAVOURITES), OVERVIEW_SECTION_TYPES.FAVOURITE, true],
             // Group conversations section.
-            [body.find(SELECTORS.GROUP_MESSAGES), CONVERSATION_TYPES.PUBLIC, false],
+            [body.find(SELECTORS.GROUP_MESSAGES), OVERVIEW_SECTION_TYPES.PUBLIC, false],
             // Private conversations section.
-            [body.find(SELECTORS.MESSAGES), CONVERSATION_TYPES.PRIVATE, false]
+            [body.find(SELECTORS.MESSAGES), OVERVIEW_SECTION_TYPES.PRIVATE, false]
         ];
 
         sections.forEach(function(args) {
             var sectionRoot = args[0];
-            var sectionType = args[1];
+            var sectionTypes = args[1];
             var includeFavourites = args[2];
             var totalCountPromise = allCounts.then(function(result) {
-                return filterCountsByType(result.total, sectionType);
+                return filterCountsByTypes(result.total, sectionTypes, includeFavourites);
             });
             var unreadCountPromise = allCounts.then(function(result) {
-                return filterCountsByType(result.unread, sectionType);
+                return filterCountsByTypes(result.unread, sectionTypes, includeFavourites);
             });
 
-            Section.show(namespace, null, sectionRoot, null, sectionType, includeFavourites,
-                totalCountPromise, unreadCountPromise);
+            Section.show(namespace, null, sectionRoot, null, sectionTypes, includeFavourites,
+                totalCountPromise, unreadCountPromise, fromPanel);
         });
 
         return allCounts.then(function(result) {
                 var sectionParams = sections.map(function(section) {
                     var sectionRoot = section[0];
-                    var sectionType = section[1];
-                    var totalCount = filterCountsByType(result.total, sectionType);
-                    var unreadCount = filterCountsByType(result.unread, sectionType);
+                    var sectionTypes = section[1];
+                    var includeFavourites = section[2];
+                    var totalCount = filterCountsByTypes(result.total, sectionTypes, includeFavourites);
+                    var unreadCount = filterCountsByTypes(result.unread, sectionTypes, includeFavourites);
 
                     return [sectionRoot, totalCount, unreadCount];
                 });
index 01dba3e..eafd6ca 100644 (file)
@@ -183,14 +183,21 @@ function(
                 lastmessage: lastMessage ? $(lastMessage.text).text() || lastMessage.text : null
             };
 
-            if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PRIVATE) {
-                var otherUser = conversation.members.reduce(function(carry, member) {
+            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;
@@ -226,19 +233,42 @@ function(
     /**
      * Build the callback to load conversations.
      *
-     * @param  {Number} type The conversation type.
+     * @param  {Array|null} types The conversation types for this section.
      * @param  {bool} includeFavourites Include/exclude favourites.
      * @param  {Number} offset Result offset
      * @return {Function}
      */
-    var getLoadCallback = function(type, includeFavourites, offset) {
+    var getLoadCallback = function(types, includeFavourites, offset) {
+        // Note: This function is a bit messy because we've added the concept of loading
+        // multiple conversations types (e.g. private + self) at once but haven't properly
+        // updated the web service to accept an array of types. Instead we've added a new
+        // parameter for the self type which means we can only ever load self + other type.
+        // This should be improved to make it more extensible in the future. Adding new params
+        // for each type isn't very scalable.
+        var type = null;
+        // Include self conversations in the results by default.
+        var includeSelfConversations = true;
+        if (types && types.length) {
+            // Just get the conversation types that aren't "self" for now.
+            var nonSelfConversationTypes = types.filter(function(candidate) {
+                return candidate != MessageDrawerViewConversationContants.CONVERSATION_TYPES.SELF;
+            });
+            // If we're specifically asking for a list of types that doesn't include the self
+            // conversations then we don't need to include them.
+            includeSelfConversations = types.length != nonSelfConversationTypes.length;
+            // As mentioned above the webservice is currently limited to loading one type at a
+            // time (plus self conversations) so let's hope we never change this.
+            type = nonSelfConversationTypes[0];
+        }
+
         return function(root, userId) {
             return MessageRepository.getConversations(
                     userId,
                     type,
                     LOAD_LIMIT + 1,
                     offset,
-                    includeFavourites
+                    includeFavourites,
+                    includeSelfConversations
                 )
                 .then(function(response) {
                     var conversations = response.conversations;
@@ -523,11 +553,28 @@ function(
      * @param {String} namespace Unique identifier for the Routes
      * @param {Object} root The section container element.
      * @param {Function} loadCallback The callback to load items.
-     * @param {Number} type The conversation type for this section
+     * @param {Array|null} types The conversation types for this section
      * @param {bool} includeFavourites If this section includes favourites
+     * @param {String} fromPanel Routing argument to send if the section is loaded in message index left panel.
      */
-    var registerEventListeners = function(namespace, root, loadCallback, type, includeFavourites) {
+    var registerEventListeners = function(namespace, root, loadCallback, types, includeFavourites, fromPanel) {
         var listRoot = LazyLoadList.getRoot(root);
+        var conversationBelongsToThisSection = function(conversation) {
+            // Make sure the type is an int so that the index of check matches correctly.
+            var conversationType = parseInt(conversation.type, 10);
+            if (
+                // If the conversation type isn't one this section cares about then we can ignore it.
+                (types && types.indexOf(conversationType) < 0) ||
+                // If this is the favourites section and the conversation isn't a favourite then ignore it.
+                (includeFavourites && !conversation.isFavourite) ||
+                // If this section doesn't include favourites and the conversation is a favourite then ignore it.
+                (!includeFavourites && conversation.isFavourite)
+            ) {
+                return false;
+            }
+
+            return true;
+        };
 
         // Set the minimum height of the section to the height of the toggle. This
         // smooths out the collapse animation.
@@ -575,11 +622,7 @@ function(
         });
 
         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, function(conversation) {
-            if (
-                (type && conversation.type != type) ||
-                (includeFavourites && !conversation.isFavourite) ||
-                (!includeFavourites && conversation.isFavourite)
-            ) {
+            if (!conversationBelongsToThisSection(conversation)) {
                 return;
             }
 
@@ -608,12 +651,12 @@ function(
 
         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_SET_FAVOURITE, function(conversation) {
             var conversationElement = null;
-            if (includeFavourites && (!type || type == conversation.type)) {
+            if (conversationBelongsToThisSection(conversation)) {
                 conversationElement = getConversationElement(root, conversation.id);
                 if (!conversationElement.length) {
                     createNewConversation(root, conversation);
                 }
-            } else if (type == conversation.type) {
+            } else {
                 conversationElement = getConversationElement(root, conversation.id);
                 if (conversationElement.length) {
                     deleteConversation(root, conversationElement);
@@ -623,16 +666,16 @@ function(
 
         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_UNSET_FAVOURITE, function(conversation) {
             var conversationElement = null;
-            if (includeFavourites) {
-                conversationElement = getConversationElement(root, conversation.id);
-                if (conversationElement.length) {
-                    deleteConversation(root, conversationElement);
-                }
-            } else if (type == conversation.type) {
+            if (conversationBelongsToThisSection(conversation)) {
                 conversationElement = getConversationElement(root, conversation.id);
                 if (!conversationElement.length) {
                     createNewConversation(root, conversation);
                 }
+            } else {
+                conversationElement = getConversationElement(root, conversation.id);
+                if (conversationElement.length) {
+                    deleteConversation(root, conversationElement);
+                }
             }
         });
 
@@ -641,7 +684,7 @@ function(
             var conversationElement = $(e.target).closest(SELECTORS.CONVERSATION);
             var conversationId = conversationElement.attr('data-conversation-id');
             var conversation = loadedConversationsById[conversationId];
-            MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONVERSATION, conversation);
+            MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONVERSATION, conversation, fromPanel);
 
             data.originalEvent.preventDefault();
         });
@@ -654,17 +697,19 @@ function(
      * @param {Object} header The header container element.
      * @param {Object} body The section container element.
      * @param {Object} footer The footer container element.
-     * @param {Number} type The conversation type for this section
+     * @param {Array} types The conversation types that show in this section
      * @param {bool} includeFavourites If this section includes favourites
      * @param {Object} totalCountPromise Resolves wth the total conversations count
      * @param {Object} unreadCountPromise Resolves wth the unread conversations count
+     * @param {bool} fromPanel shown in message app panel.
      */
-    var show = function(namespace, header, body, footer, type, includeFavourites, totalCountPromise, unreadCountPromise) {
+    var show = function(namespace, header, body, footer, types, includeFavourites, totalCountPromise, unreadCountPromise,
+        fromPanel) {
         var root = $(body);
 
         if (!root.attr('data-init')) {
-            var loadCallback = getLoadCallback(type, includeFavourites, 0);
-            registerEventListeners(namespace, root, loadCallback, type, includeFavourites);
+            var loadCallback = getLoadCallback(types, includeFavourites, 0);
+            registerEventListeners(namespace, root, loadCallback, types, includeFavourites, fromPanel);
 
             if (isVisible(root)) {
                 setExpanded(root);
index 8db0aa7..c35930e 100644 (file)
@@ -612,6 +612,22 @@ function(
         }
     };
 
+    /**
+     * Highlight words in search results.
+     *
+     * @param  {String} content HTML to search.
+     * @param  {String} searchText Search text.
+     * @return {String} searchText with search wrapped in matchtext span.
+     */
+    var highlightSearch = function(content, searchText) {
+        if (!content) {
+            return '';
+        }
+        var regex = new RegExp('(' + searchText + ')', 'gi');
+        return content.replace(regex, '<span class="matchtext">$1</span>');
+    };
+
+
     /**
      * Render contacts in the contacts search results.
      *
@@ -621,9 +637,10 @@ function(
      */
     var renderContacts = function(root, contacts) {
         var container = getContactsContainer(root);
+        var frompanel = root.attr('data-in-panel');
         var list = container.find(SELECTORS.LIST);
 
-        return Templates.render(TEMPLATES.CONTACTS_LIST, {contacts: contacts})
+        return Templates.render(TEMPLATES.CONTACTS_LIST, {contacts: contacts, frompanel: frompanel})
             .then(function(html) {
                 list.append(html);
                 return html;
@@ -639,9 +656,10 @@ function(
      */
     var renderNonContacts = function(root, nonContacts) {
         var container = getNonContactsContainer(root);
+        var frompanel = root.attr('data-in-panel');
         var list = container.find(SELECTORS.LIST);
 
-        return Templates.render(TEMPLATES.NON_CONTACTS_LIST, {noncontacts: nonContacts})
+        return Templates.render(TEMPLATES.NON_CONTACTS_LIST, {noncontacts: nonContacts, frompanel: frompanel})
             .then(function(html) {
                 list.append(html);
                 return html;
@@ -657,9 +675,10 @@ function(
      */
     var renderMessages = function(root, messages) {
         var container = getMessagesContainer(root);
+        var frompanel = root.attr('data-in-panel');
         var list = container.find(SELECTORS.LIST);
 
-        return Templates.render(TEMPLATES.MESSAGES_LIST, {messages: messages})
+        return Templates.render(TEMPLATES.MESSAGES_LIST, {messages: messages, frompanel: frompanel})
             .then(function(html) {
                 list.append(html);
                 return html;
@@ -702,6 +721,18 @@ function(
                 var contactsCount = results.contacts.length;
                 var nonContactsCount = results.noncontacts.length;
 
+                if (contactsCount) {
+                    results.contacts.forEach(function(contact) {
+                        contact.highlight = highlightSearch(contact.fullname, text);
+                    });
+                }
+
+                if (nonContactsCount) {
+                    results.noncontacts.forEach(function(contact) {
+                        contact.highlight = highlightSearch(contact.fullname, text);
+                    });
+                }
+
                 return $.when(
                     contactsCount ? renderContacts(root, results.contacts) : true,
                     nonContactsCount ? renderNonContacts(root, results.noncontacts) : true
@@ -756,6 +787,9 @@ function(
             })
             .then(function(messages) {
                 if (messages.length) {
+                    messages.forEach(function(message) {
+                        message.lastmessage = highlightSearch(message.lastmessage, text);
+                    });
                     return renderMessages(root, messages)
                         .then(function() {
                             return messages.length;
@@ -945,7 +979,6 @@ function(
             registerEventListeners(header, body);
             body.attr('data-init', true);
         }
-
         var searchInput = getSearchInput(header);
         searchInput.focus();
 
@@ -955,10 +988,14 @@ function(
     /**
      * String describing this page used for aria-labels.
      *
+     * @param {string} namespace The route namespace.
      * @param {Object} header Contacts header container element.
      * @return {Object} jQuery promise
      */
-    var description = function(header) {
+    var description = function(namespace, header) {
+        if (typeof header !== 'object') {
+            return Str.get_string('messagedrawerviewsearch', 'core_message');
+        }
         var searchInput = getSearchInput(header);
         var searchText = searchInput.val().trim();
         return Str.get_string('messagedrawerviewsearch', 'core_message', searchText);
index 24b2349..31c630d 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notification) {
-
-    var CONVERSATION_TYPES = {
-        PRIVATE: 1,
-        PUBLIC: 2
-    };
+define(
+[
+    'jquery',
+    'core/ajax',
+    'core/notification',
+    'core_message/message_drawer_view_conversation_constants'
+], function(
+    $,
+    Ajax,
+    Notification,
+    Constants) {
+
+    var CONVERSATION_TYPES = Constants.CONVERSATION_TYPES;
 
     /**
      * Retrieve a list of messages from the server.
@@ -771,6 +778,45 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Get a self-conversation.
+     *
+     * @param {int} loggedInUserId The logged in user
+     * @param {int} messageLimit Limit for messages
+     * @param {int} messageOffset Offset for messages
+     * @param {bool} newestMessagesFirst Order the messages by newest first
+     * @return {object} jQuery promise
+     */
+    var getSelfConversation = function(
+        loggedInUserId,
+        messageLimit,
+        messageOffset,
+        newestMessagesFirst
+    ) {
+        var args = {
+            userid: loggedInUserId
+        };
+
+        if (typeof messageLimit != 'undefined' && messageLimit !== null) {
+            args.messagelimit = messageLimit;
+        }
+
+        if (typeof messageOffset != 'undefined' && messageOffset !== null) {
+            args.messageoffset = messageOffset;
+        }
+
+        if (typeof newestMessagesFirst != 'undefined' && newestMessagesFirst !== null) {
+            args.newestmessagesfirst = newestMessagesFirst;
+        }
+
+        var request = {
+            methodname: 'core_message_get_self_conversation',
+            args: args
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     /**
      * Get the conversations for a user.
      *
@@ -786,7 +832,8 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         type,
         limit,
         offset,
-        favourites
+        favourites,
+        mergeself
     ) {
         var args = {
             userid: userId,
@@ -805,6 +852,10 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
             args.favourites = favourites;
         }
 
+        if (typeof mergeself != 'undefined' && mergeself !== null) {
+            args.mergeself = mergeself;
+        }
+
         var request = {
             methodname: 'core_message_get_conversations',
             args: args
@@ -814,7 +865,7 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
             .then(function(result) {
                 if (result.conversations.length) {
                     result.conversations = result.conversations.map(function(conversation) {
-                        if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
+                        if (conversation.type == CONVERSATION_TYPES.PRIVATE || conversation.type == CONVERSATION_TYPES.SELF) {
                             var otherUser = conversation.members.length ? conversation.members[0] : null;
 
                             if (otherUser) {
@@ -1093,6 +1144,7 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         declineContactRequest: declineContactRequest,
         getConversation: getConversation,
         getConversationBetweenUsers: getConversationBetweenUsers,
+        getSelfConversation: getSelfConversation,
         getConversations: getConversations,
         getConversationMembers: getConversationMembers,
         setFavouriteConversations: setFavouriteConversations,
index 5bcf9f1..b183404 100644 (file)
@@ -78,6 +78,11 @@ class api {
      */
     const MESSAGE_CONVERSATION_TYPE_GROUP = 2;
 
+    /**
+     * A self conversation.
+     */
+    const MESSAGE_CONVERSATION_TYPE_SELF = 3;
+
     /**
      * The state for an enabled conversation area.
      */
@@ -103,10 +108,12 @@ class api {
         // Get the user fields we want.
         $ufields = \user_picture::fields('u', array('lastaccess'), 'userfrom_id', 'userfrom_');
         $ufields2 = \user_picture::fields('u2', array('lastaccess'), 'userto_id', 'userto_');
+        // Add the uniqueid column to make each row unique and avoid SQL errors.
+        $uniqueidsql = $DB->sql_concat('m.id', "'_'", 'm.useridfrom', "'_'", 'mcm.userid');
 
-        $sql = "SELECT m.id, m.useridfrom, mcm.userid as useridto, m.subject, m.fullmessage, m.fullmessagehtml, m.fullmessageformat,
-                       m.smallmessage, m.conversationid, m.timecreated, 0 as isread, $ufields, mub.id as userfrom_blocked,
-                       $ufields2, mub2.id as userto_blocked
+        $sql = "SELECT $uniqueidsql AS uniqueid, m.id, m.useridfrom, mcm.userid as useridto, m.subject, m.fullmessage,
+                       m.fullmessagehtml, m.fullmessageformat, m.smallmessage, m.conversationid, m.timecreated, 0 as isread,
+                       $ufields, mub.id as userfrom_blocked, $ufields2, mub2.id as userto_blocked
                   FROM {messages} m
             INNER JOIN {user} u
                     ON u.id = m.useridfrom
@@ -123,14 +130,15 @@ class api {
              LEFT JOIN {message_user_actions} mua
                     ON (mua.messageid = m.id AND mua.userid = ? AND mua.action = ?)
                  WHERE (m.useridfrom = ? OR mcm.userid = ?)
-                   AND m.useridfrom != mcm.userid
+                   AND (m.useridfrom != mcm.userid OR mc.type = ?)
                    AND u.deleted = 0
                    AND u2.deleted = 0
                    AND mua.id is NULL
                    AND " . $DB->sql_like('smallmessage', '?', false) . "
               ORDER BY timecreated DESC";
 
-        $params = array($userid, $userid, $userid, self::MESSAGE_ACTION_DELETED, $userid, $userid, '%' . $search . '%');
+        $params = array($userid, $userid, $userid, self::MESSAGE_ACTION_DELETED, $userid, $userid,
+            self::MESSAGE_CONVERSATION_TYPE_SELF, '%' . $search . '%');
 
         // Convert the messages into searchable contacts with their last message being the message that was searched.
         $conversations = array();
@@ -146,8 +154,13 @@ class api {
                 $message->blocked = $message->$blockedcol ? 1 : 0;
 
                 $message->messageid = $message->id;
-                $conversations[] = helper::create_contact($message, $prefix);
+                // To avoid duplicate messages, only add the message if it hasn't been added previously.
+                if (!array_key_exists($message->messageid, $conversations)) {
+                    $conversations[$message->messageid] = helper::create_contact($message, $prefix);
+                }
             }
+            // Remove the messageid keys (to preserve the expected type).
+            $conversations = array_values($conversations);
         }
 
         return $conversations;
@@ -309,7 +322,11 @@ class api {
         $fullname = $DB->sql_fullname();
 
         // Users not to include.
-        $excludeusers = array($userid, $CFG->siteguest);
+        $excludeusers = array($CFG->siteguest);
+        if (!$selfconversation = self::get_self_conversation($userid)) {
+            // Userid should only be excluded when she hasn't a self-conversation.
+            $excludeusers[] = $userid;
+        }
         list($exclude, $excludeparams) = $DB->get_in_or_equal($excludeusers, SQL_PARAMS_NAMED, 'param', false);
 
         $params = array('search' => '%' . $DB->sql_like_escape($search) . '%', 'userid1' => $userid, 'userid2' => $userid);
@@ -420,7 +437,12 @@ class api {
         if (!empty($foundusers)) {
             $noncontacts = helper::get_member_info($userid, array_keys($foundusers));
             foreach ($noncontacts as $memberuserid => $memberinfo) {
-                $noncontacts[$memberuserid]->conversations = self::get_conversations_between_users($userid, $memberuserid, 0, 1000);
+                if ($memberuserid !== $userid) {
+                    $noncontacts[$memberuserid]->conversations = self::get_conversations_between_users($userid, $memberuserid, 0,
+                        1000);
+                } else {
+                    $noncontacts[$memberuserid]->conversations[$selfconversation->id] = $selfconversation;
+                }
             }
         }
 
@@ -507,15 +529,17 @@ class api {
      * @param int $limitnum
      * @param int $type the type of the conversation, if you wish to filter to a certain type (see api constants).
      * @param bool $favourites whether to include NO favourites (false) or ONLY favourites (true), or null to ignore this setting.
+     * @param bool $mergeself whether to include self-conversations (true) or ONLY private conversations (false)
+     *             when private conversations are requested.
      * @return array the array of conversations
      * @throws \moodle_exception
      */
     public static function get_conversations($userid, $limitfrom = 0, $limitnum = 20, int $type = null,
-            bool $favourites = null) {
+            bool $favourites = null, bool $mergeself = false) {
         global $DB;
 
         if (!is_null($type) && !in_array($type, [self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
-                self::MESSAGE_CONVERSATION_TYPE_GROUP])) {
+                self::MESSAGE_CONVERSATION_TYPE_GROUP, self::MESSAGE_CONVERSATION_TYPE_SELF])) {
             throw new \moodle_exception("Invalid value ($type) for type param, please see api constants.");
         }
 
@@ -546,7 +570,18 @@ class api {
         }
 
         // If we need to restrict type, generate the SQL snippet.
-        $typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
+        $typesql = "";
+        $typeparams = [];
+        if (!is_null($type)) {
+            if ($mergeself && $type == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
+                // When $megerself is set to true, the self-conversations are returned also with the private conversations.
+                $typesql = " AND (mc.type = :convtype1 OR mc.type = :convtype2) ";
+                $typeparams = ['convtype1' => $type, 'convtype2' => self::MESSAGE_CONVERSATION_TYPE_SELF];
+            } else {
+                $typesql = " AND mc.type = :convtype ";
+                $typeparams = ['convtype' => $type];
+            }
+        }
 
         $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
                        m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagetrust, m.fullmessagehtml, m.timecreated,
@@ -580,16 +615,16 @@ class api {
                   AND mc.enabled = 1 $typesql $favouritesql
               ORDER BY (CASE WHEN m.timecreated IS NULL THEN 0 ELSE 1 END) DESC, m.timecreated DESC, id DESC";
 
-        $params = array_merge($favouriteparams, ['userid' => $userid, 'action' => self::MESSAGE_ACTION_DELETED,
-            'userid2' => $userid, 'userid3' => $userid, 'userid4' => $userid, 'convaction' => self::CONVERSATION_ACTION_MUTED,
-            'convtype' => $type]);
+        $params = array_merge($favouriteparams, $typeparams, ['userid' => $userid, 'action' => self::MESSAGE_ACTION_DELETED,
+            'userid2' => $userid, 'userid3' => $userid, 'userid4' => $userid, 'convaction' => self::CONVERSATION_ACTION_MUTED]);
         $conversationset = $DB->get_recordset_sql($sql, $params, $limitfrom, $limitnum);
 
         $conversations = [];
-        $selfconversations = []; // Used to track legacy conversations with one's self (both conv members the same user).
+        $selfconversations = []; // Used to track conversations with one's self.
         $members = [];
         $individualmembers = [];
         $groupmembers = [];
+        $selfmembers = [];
         foreach ($conversationset as $conversation) {
             $conversations[$conversation->id] = $conversation;
             $members[$conversation->id] = [];
@@ -614,13 +649,12 @@ class api {
         //
         // For 'individual' type conversations between 2 users, regardless of who sent the last message,
         // we want the details of the other member in the conversation (i.e. not the current user).
-        // The only exception to the 'not the current user' rule is for 'self' conversations - a legacy construct in which a user
-        // can message themselves via user bulk actions. Subsequently, there are 2 records for the same user created in the members
-        // table.
         //
         // For 'group' type conversations, we want the details of the member who sent the last message, if there is one.
         // This can be the current user or another group member, but for groups without messages, this will be empty.
         //
+        // For 'self' type conversations, we want the details of the current user.
+        //
         // This also means that if type filtering is specified and only group conversations are returned, we don't need this extra
         // query to get the 'other' user as we already have that information.
 
@@ -640,6 +674,10 @@ class api {
                     $members[$conversation->id][$conversation->useridfrom] = $conversation->useridfrom;
                     $groupmembers[$conversation->useridfrom] = $conversation->useridfrom;
                 }
+            } else if ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_SELF) {
+                $selfconversations[$conversation->id] = $conversation->id;
+                $members[$conversation->id][$userid] = $userid;
+                $selfmembers[$userid] = $userid;
             }
         }
         // If we need to fetch any member information for any of the individual conversations.
@@ -658,23 +696,6 @@ class api {
                 $members[$member->conversationid][$member->userid] = $member->userid;
                 $individualmembers[$member->userid] = $member->userid;
             }
-
-            // Self conversations: If any of the individual conversations which were missing members are still missing members,
-            // we know these must be 'self' conversations. This is a legacy scenario, created via user bulk actions.
-            // In such cases, the member returned should be the current user.
-            //
-            // NOTE: Currently, these conversations are not returned by this method, however,
-            // identifying them is important for future reference.
-            foreach ($individualconversations as $indconvid) {
-                if (empty($members[$indconvid])) {
-                    // Keep track of the self conversation (for future use).
-                    $selfconversations[$indconvid] = $indconvid;
-
-                    // Set the member to the current user.
-                    $members[$indconvid][$userid] = $userid;
-                    $individualmembers[$userid] = $userid;
-                }
-            }
         }
 
         // We could fail early here if we're sure that:
@@ -685,7 +706,7 @@ class api {
         // needs to be done in a separate query to avoid doing a join on the messages tables and the user
         // tables because on large sites these tables are massive which results in extremely slow
         // performance (typically due to join buffer exhaustion).
-        if (!empty($individualmembers) || !empty($groupmembers)) {
+        if (!empty($individualmembers) || !empty($groupmembers) || !empty($selfmembers)) {
             // Now, we want to remove any duplicates from the group members array. For individual members we will
             // be doing a more extensive call as we want their contact requests as well as privacy information,
             // which is not necessary for group conversations.
@@ -693,9 +714,10 @@ class api {
 
             $individualmemberinfo = helper::get_member_info($userid, $individualmembers, true, true);
             $groupmemberinfo = helper::get_member_info($userid, $diffgroupmembers);
+            $selfmemberinfo = helper::get_member_info($userid, $selfmembers);
 
             // Don't use array_merge, as we lose array keys.
-            $memberinfo = $individualmemberinfo + $groupmemberinfo;
+            $memberinfo = $individualmemberinfo + $groupmemberinfo + $selfmemberinfo;
 
             if (empty($memberinfo)) {
                 return [];
@@ -759,6 +781,17 @@ class api {
         $unreadcounts = $DB->get_records_sql($unreadcountssql, [$userid, self::MESSAGE_ACTION_READ, self::MESSAGE_ACTION_DELETED,
             $userid, $userid]);
 
+        // For the self-conversations, get the total number of messages (to know if the conversation is new or it has been emptied).
+        $selfmessagessql = "SELECT COUNT(m.id)
+                              FROM {messages} m
+                        INNER JOIN {message_conversations} mc
+                                ON mc.id = m.conversationid
+                             WHERE mc.type = ? AND convhash = ?";
+        $selfmessagestotal = $DB->count_records_sql(
+            $selfmessagessql,
+            [self::MESSAGE_CONVERSATION_TYPE_SELF, helper::get_conversation_hash([$userid])]
+        );
+
         // Because we'll be calling format_string on each conversation name and passing contexts, we preload them here.
         // This warms the cache and saves potentially hitting the DB once for each context fetch below.
         \context_helper::preload_contexts_by_id(array_column($conversations, 'contextid'));
@@ -766,16 +799,14 @@ class api {
         // Now, create the final return structure.
         $arrconversations = [];
         foreach ($conversations as $conversation) {
-            // Do not include any individual conversations which do not contain a recent message for the user.
+            // Do not include any individual which do not contain a recent message for the user.
             // This happens if the user has deleted all messages.
+            // Exclude the self-conversations with messages but without a recent message because the user has deleted all them.
+            // Self-conversations without any message should be included, to display them first time they are created.
             // Group conversations with deleted users or no messages are always returned.
-            if ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL
-                    && (empty($conversation->messageid))) {
-                continue;
-            }
-
-            // Exclude 'self' conversations for now.
-            if (isset($selfconversations[$conversation->id])) {
+            if ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL && empty($conversation->messageid) ||
+                   ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_SELF && empty($conversation->messageid)
+                    && $selfmessagestotal > 0)) {
                 continue;
             }
 
@@ -916,10 +947,12 @@ class api {
             $memberoffset,
             $memberlimit
         );
-        // Strip out the requesting user to match what get_conversations does.
-        $members = array_filter($members, function($member) use ($userid) {
-            return $member->id != $userid;
-        });
+        if ($conversation->type != self::MESSAGE_CONVERSATION_TYPE_SELF) {
+            // Strip out the requesting user to match what get_conversations does, except for self-conversations.
+            $members = array_filter($members, function($member) use ($userid) {
+                return $member->id != $userid;
+            });
+        }
 
         $messages = self::get_conversation_messages(
             $userid,
@@ -1563,7 +1596,8 @@ class api {
         // Some restrictions we need to be aware of:
         // - Individual conversations containing soft-deleted user must be counted.
         // - Individual conversations containing only deleted messages must NOT be counted.
-        // - Individual conversations which are legacy 'self' conversations (2 members, both the same user) must NOT be counted.
+        // - Self-conversations with 0 messages must be counted.
+        // - Self-conversations containing only deleted messages must NOT be counted.
         // - Group conversations with 0 messages must be counted.
         // - Linked conversations which are disabled (enabled = 0) must NOT be counted.
         // - Any type of conversation can be included in the favourites count, however, the type counts and the favourites count
@@ -1575,21 +1609,10 @@ class api {
         $favservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
         list($favsql, $favparams) = $favservice->get_join_sql_by_type('core_message', 'message_conversations', 'fav', 'mc.id');
 
-        $sql = "SELECT mc.type, fav.itemtype, COUNT(DISTINCT mc.id) as count
+        $sql = "SELECT mc.type, fav.itemtype, COUNT(DISTINCT mc.id) as count, MAX(maxvisibleconvmessage.convid) as maxconvidmessage
                   FROM {message_conversations} mc
             INNER JOIN {message_conversation_members} mcm
                     ON mcm.conversationid = mc.id
-            INNER JOIN (
-                              SELECT mcm.conversationid, count(distinct mcm.userid) as membercount
-                                FROM {message_conversation_members} mcm
-                               WHERE mcm.conversationid IN (
-                                        SELECT DISTINCT conversationid
-                                          FROM {message_conversation_members} mcm2
-                                         WHERE userid = :userid5
-                                     )
-                            GROUP BY mcm.conversationid
-                       ) uniquemembercount
-                    ON uniquemembercount.conversationid = mc.id
              LEFT JOIN (
                               SELECT m.conversationid as convid, MAX(m.timecreated) as maxtime
                                 FROM {messages} m
@@ -1606,8 +1629,9 @@ class api {
                  WHERE mcm.userid = :userid3
                    AND mc.enabled = :enabled
                    AND (
-                          (mc.type = :individualtype AND maxvisibleconvmessage.convid IS NOT NULL AND membercount > 1) OR
-                          (mc.type = :grouptype)
+                          (mc.type = :individualtype AND maxvisibleconvmessage.convid IS NOT NULL) OR
+                          (mc.type = :grouptype) OR
+                          (mc.type = :selftype)
                        )
               GROUP BY mc.type, fav.itemtype
               ORDER BY mc.type ASC";
@@ -1622,6 +1646,7 @@ class api {
             'enabled' => self::MESSAGE_CONVERSATION_ENABLED,
             'individualtype' => self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
             'grouptype' => self::MESSAGE_CONVERSATION_TYPE_GROUP,
+            'selftype' => self::MESSAGE_CONVERSATION_TYPE_SELF,
         ] + $favparams;
 
         // Assemble the return array.
@@ -1629,12 +1654,28 @@ class api {
             'favourites' => 0,
             'types' => [
                 self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL => 0,
-                self::MESSAGE_CONVERSATION_TYPE_GROUP => 0
+                self::MESSAGE_CONVERSATION_TYPE_GROUP => 0,
+                self::MESSAGE_CONVERSATION_TYPE_SELF => 0
             ]
         ];
 
+        // For the self-conversations, get the total number of messages (to know if the conversation is new or it has been emptied).
+        $selfmessagessql = "SELECT COUNT(m.id)
+                              FROM {messages} m
+                        INNER JOIN {message_conversations} mc
+                                ON mc.id = m.conversationid
+                             WHERE mc.type = ? AND convhash = ?";
+        $selfmessagestotal = $DB->count_records_sql(
+            $selfmessagessql,
+            [self::MESSAGE_CONVERSATION_TYPE_SELF, helper::get_conversation_hash([$userid])]
+        );
+
         $countsrs = $DB->get_recordset_sql($sql, $params);
         foreach ($countsrs as $key => $val) {
+            // Empty self-conversations with deleted messages should be excluded.
+            if ($val->type == self::MESSAGE_CONVERSATION_TYPE_SELF && empty($val->maxconvidmessage) && $selfmessagestotal > 0) {
+                continue;
+            }
             if (!empty($val->itemtype)) {
                 $counts['favourites'] += $val->count;
                 continue;
@@ -1855,7 +1896,8 @@ class api {
         // User can post messages and is in the conversation, but we need to check the conversation type to
         // know whether or not to check the user privacy settings via can_contact_user().
         $conversation = $DB->get_record('message_conversations', ['id' => $conversationid], '*', MUST_EXIST);
-        if ($conversation->type == self::MESSAGE_CONVERSATION_TYPE_GROUP) {
+        if ($conversation->type == self::MESSAGE_CONVERSATION_TYPE_GROUP ||
+            $conversation->type == self::MESSAGE_CONVERSATION_TYPE_SELF) {
             return true;
         } else if ($conversation->type == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
             // Get the other user in the conversation.
@@ -2375,6 +2417,23 @@ class api {
         return $conversations;
     }
 
+    /**
+     * Returns the self conversation for a user.
+     *
+     * @param int $userid The user id to get the self-conversations
+     * @return \stdClass|false The self-conversation object or false if it doesn't exist
+     * @since Moodle 3.7
+     */
+    public static function get_self_conversation(int $userid) {
+        global $DB;
+
+        $conditions = [
+            'type' => self::MESSAGE_CONVERSATION_TYPE_SELF,
+            'convhash' => helper::get_conversation_hash([$userid])
+        ];
+        return $DB->get_record('message_conversations', $conditions);
+    }
+
     /**
      * Creates a conversation between two users.
      *
@@ -2413,7 +2472,8 @@ class api {
 
         $validtypes = [
             self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
-            self::MESSAGE_CONVERSATION_TYPE_GROUP
+            self::MESSAGE_CONVERSATION_TYPE_GROUP,
+            self::MESSAGE_CONVERSATION_TYPE_SELF
         ];
 
         if (!in_array($type, $validtypes)) {
@@ -2425,13 +2485,20 @@ class api {
             if (count($userids) > 2) {
                 throw new \moodle_exception('An individual conversation can not have more than two users.');
             }
+            if ($userids[0] == $userids[1]) {
+                throw new \moodle_exception('Trying to create an individual conversation instead of a self conversation.');
+            }
+        } else if ($type == self::MESSAGE_CONVERSATION_TYPE_SELF) {
+            if (count($userids) != 1) {
+                throw new \moodle_exception('A self conversation can not have more than one user.');
+            }
         }
 
         $conversation = new \stdClass();
         $conversation->type = $type;
         $conversation->name = $name;
         $conversation->convhash = null;
-        if ($type == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
+        if ($type == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL || $type == self::MESSAGE_CONVERSATION_TYPE_SELF) {
             $conversation->convhash = helper::get_conversation_hash($userids);
         }
         $conversation->component = $component;
@@ -2833,8 +2900,9 @@ class api {
      * @return bool true if recipient hasn't blocked sender and sender can contact to recipient, false otherwise.
      */
     protected static function can_contact_user(int $recipientid, int $senderid) : bool {
-        if (has_capability('moodle/site:messageanyuser', \context_system::instance(), $senderid)) {
-            // The sender has the ability to contact any user across the entire site.
+        if (has_capability('moodle/site:messageanyuser', \context_system::instance(), $senderid) ||
+            $recipientid == $senderid) {
+            // The sender has the abili