Merge branch 'MDL-65412-master' of http://github.com/dravek/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 25 Apr 2019 18:02:47 +0000 (20:02 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 25 Apr 2019 18:02:47 +0000 (20:02 +0200)
170 files changed:
admin/renderer.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/monitor/classes/privacy/provider.php
backup/moodle2/restore_stepslib.php
backup/util/helper/restore_log_rule.class.php
backup/util/helper/tests/backup_encode_content_test.php
backup/util/helper/tests/restore_log_rule_test.php
backup/util/ui/classes/privacy/provider.php
badges/classes/privacy/provider.php
badges/tests/badgeslib_test.php
calendar/classes/privacy/provider.php
calendar/tests/privacy_test.php
cohort/classes/privacy/provider.php
competency/lib.php
competency/tests/lib_test.php
completion/classes/privacy/provider.php
completion/tests/behat/behat_completion.php
completion/tests/behat/completion_course_page_checkboxes.feature [new file with mode: 0644]
completion/upgrade.txt
course/classes/category.php
course/renderer.php
course/tests/behat/course_browsing.feature
enrol/classes/privacy/provider.php
filter/algebra/tests/filter_test.php
group/classes/privacy/provider.php
lang/en/deprecated.txt
lang/en/message.php
lang/en/moodle.php
lang/en/role.php
lib/badgeslib.php
lib/behat/classes/partial_named_selector.php
lib/classes/message/manager.php
lib/classes/message/message.php
lib/completionlib.php
lib/db/access.php
lib/db/install.xml
lib/db/messages.php
lib/db/upgrade.php
lib/editor/tinymce/tiny_mce/3.5.11/tiny_mce_src.js
lib/filelib.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/outputcomponents.php
lib/outputrenderers.php
lib/questionlib.php
lib/tests/behat/behat_data_generators.php
lib/tests/filelib_test.php
lib/tests/messagelib_test.php
lib/tests/outputcomponents_test.php
lib/upgrade.txt
lib/weblib.php
message/classes/api.php
message/classes/privacy/provider.php
message/externallib.php
message/lib.php
message/output/airnotifier/tests/externallib_test.php
message/output/popup/classes/api.php
message/output/popup/externallib.php
message/output/popup/tests/base.php
message/output/popup/tests/externallib_test.php
message/templates/message_drawer_view_contact_body_content.mustache
message/templates/message_drawer_view_conversation_body.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_edit_mode.mustache
message/tests/api_test.php
message/tests/behat/behat_message.php
message/tests/behat/favourite_conversations.feature [new file with mode: 0644]
message/tests/behat/message_delete_conversation.feature [new file with mode: 0644]
message/tests/behat/message_manage_preferences.feature [new file with mode: 0644]
message/tests/behat/unread_messages.feature [new file with mode: 0644]
message/tests/externallib_test.php
message/tests/privacy_provider_test.php
message/upgrade.txt
mnet/service/enrol/classes/privacy/provider.php
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/feedback/lib.php
mod/feedback/tests/external_test.php
mod/forum/amd/build/lock_toggle.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/lock_toggle.js [new file with mode: 0644]
mod/forum/amd/src/repository.js
mod/forum/amd/src/selectors.js
mod/forum/backup/moodle2/backup_forum_stepslib.php
mod/forum/classes/local/container.php
mod/forum/classes/local/data_mappers/legacy/discussion.php
mod/forum/classes/local/entities/discussion.php
mod/forum/classes/local/entities/forum.php
mod/forum/classes/local/exporters/discussion.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/local/factories/legacy_data_mapper.php
mod/forum/classes/local/factories/vault.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/classes/local/vaults/db_table_vault.php
mod/forum/classes/local/vaults/discussion.php
mod/forum/classes/post_form.php
mod/forum/classes/subscriptions.php
mod/forum/classes/task/send_user_notifications.php
mod/forum/db/install.xml
mod/forum/db/messages.php
mod/forum/db/services.php
mod/forum/db/upgrade.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/post.php
mod/forum/styles.css
mod/forum/templates/discussion_list.mustache
mod/forum/templates/discussion_lock_toggle.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion.mustache
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_lock.feature [new file with mode: 0644]
mod/forum/tests/behat/edit_tags.feature
mod/forum/tests/behat/forum_subscriptions_default.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_summary_test.php
mod/forum/tests/entities_discussion_test.php
mod/forum/tests/entities_forum_test.php
mod/forum/tests/exporters_discussion_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/generator/lib.php
mod/forum/tests/mail_test.php
mod/forum/tests/subscriptions_test.php
mod/forum/version.php
mod/lesson/db/messages.php
mod/lesson/essay.php
mod/lesson/lang/en/lesson.php
mod/lesson/pagetypes/shortanswer.php
mod/lti/templates/tool_proxy_card.mustache
mod/quiz/accessrule/accessrulebase.php
mod/quiz/backup/moodle2/restore_quiz_stepslib.php
mod/quiz/db/messages.php
mod/quiz/locallib.php
mod/quiz/tests/external_test.php
mod/survey/tests/behat/survey_completion.feature
question/behaviour/interactivecountback/behaviour.php
question/engine/questionattemptstep.php
question/engine/upgrade/tests/helper.php
question/question.php
report/stats/classes/privacy/provider.php
theme/boost/scss/bootstrap/utilities/_position.scss
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
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 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 d8ca913..2ffbf66 100644 (file)
@@ -114,29 +114,16 @@ class provider implements
     public static function get_users_in_context(userlist $userlist) {
         $context = $userlist->get_context();
 
-        if (!is_a($context, \context_user::class)) {
+        if (!$context instanceof \context_user) {
             return;
         }
 
-        $params = [
-            'contextid' => $context->id,
-            'contextuser' => CONTEXT_USER,
-        ];
-
-        $sql = "SELECT mr.userid
-                  FROM {context} ctx
-                  JOIN {tool_monitor_rules} mr ON ctx.instanceid = mr.userid
-                       AND ctx.contextlevel = :contextuser
-                 WHERE ctx.id = :contextid";
+        $params = ['userid' => $context->instanceid];
 
+        $sql = "SELECT userid FROM {tool_monitor_rules} WHERE userid = :userid";
         $userlist->add_from_sql('userid', $sql, $params);
 
-        $sql = "SELECT ms.userid
-                  FROM {context} ctx
-             LEFT JOIN {tool_monitor_subscriptions} ms ON ctx.instanceid = ms.userid
-                       AND ctx.contextlevel = :contextuser
-                 WHERE ctx.id = :contextid";
-
+        $sql = "SELECT userid FROM {tool_monitor_subscriptions} WHERE userid = :userid";
         $userlist->add_from_sql('userid', $sql, $params);
     }
 
index 149f606..ff9e937 100644 (file)
@@ -5269,7 +5269,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     }
 
     /**
-     * This method does the acutal work for process_question_usage or
+     * This method does the actual work for process_question_usage or
      * process_{nameprefix}_question_usage.
      * @param array $data the data from the XML file.
      * @param string $nameprefix the element name prefix.
@@ -5304,7 +5304,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     abstract protected function inform_new_usage_id($newusageid);
 
     /**
-     * This method does the acutal work for process_question_attempt or
+     * This method does the actual work for process_question_attempt or
      * process_{nameprefix}_question_attempt.
      * @param array $data the data from the XML file.
      * @param string $nameprefix the element name prefix.
@@ -5334,7 +5334,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     }
 
     /**
-     * This method does the acutal work for process_question_attempt_step or
+     * This method does the actual work for process_question_attempt_step or
      * process_{nameprefix}_question_attempt_step.
      * @param array $data the data from the XML file.
      * @param string $nameprefix the element name prefix.
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 63554d3..5406712 100644 (file)
@@ -33,7 +33,7 @@ require_once($CFG->dirroot . '/backup/moodle2/backup_course_task.class.php');
 /**
  * Tests for encoding content links in backup_course_task.
  *
- * The code that this tests is acutally in backup/moodle2/backup_course_task.class.php,
+ * The code that this tests is actually in backup/moodle2/backup_course_task.class.php,
  * but there is no place for unit tests near there, and perhaps one day it will
  * be refactored so it becomes more generic.
  */
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 8c36b45..e4893e6 100644 (file)
@@ -142,18 +142,11 @@ class provider implements
         $context = $userlist->get_context();
 
         if ($context instanceof \context_course) {
-            $params = [
-                'contextcourse' => CONTEXT_COURSE,
-                'contextid' => $context->id,
-
-            ];
+            $params = ['courseid' => $context->instanceid];
 
             $sql = "SELECT bc.userid
                       FROM {backup_controllers} bc
-                      JOIN {context} ctx
-                           ON ctx.instanceid = bc.itemid
-                           AND ctx.contextlevel = :contextcourse
-                     WHERE ctx.id = :contextid
+                     WHERE bc.itemid = :courseid
                            AND bc.type = :typecourse";
 
             $courseparams = ['typecourse' => 'course'] + $params;
@@ -164,10 +157,7 @@ class provider implements
                       FROM {backup_controllers} bc
                       JOIN {course_sections} c
                            ON bc.itemid = c.id
-                      JOIN {context} ctx
-                           ON ctx.instanceid = c.course
-                           AND ctx.contextlevel = :contextcourse
-                     WHERE ctx.id = :contextid
+                     WHERE c.course = :courseid
                            AND bc.type = :typesection";
 
             $sectionparams = ['typesection' => 'section'] + $params;
@@ -177,17 +167,13 @@ class provider implements
 
         if ($context instanceof \context_module) {
             $params = [
-                'contextmodule' => CONTEXT_MODULE,
-                'contextid' => $context->id,
+                'cmid' => $context->instanceid,
                 'typeactivity' => 'activity'
             ];
 
             $sql = "SELECT bc.userid
                       FROM {backup_controllers} bc
-                      JOIN {context} ctx
-                           ON ctx.instanceid = bc.itemid
-                           AND ctx.contextlevel = :contextmodule
-                     WHERE ctx.id = :contextid
+                     WHERE bc.itemid = :cmid
                            AND bc.type = :typeactivity";
 
             $userlist->add_from_sql('userid', $sql, $params);
index ab3c83d..7bb97b0 100644 (file)
@@ -197,20 +197,21 @@ class provider implements
 
         if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_SYSTEM) {
             // Find the modifications we made on badges (course & system).
-            $params = [
-                'courselevel' => CONTEXT_COURSE,
-                'syscontextid' => SYSCONTEXTID,
-                'typecourse' => BADGE_TYPE_COURSE,
-                'typesite' => BADGE_TYPE_SITE,
-                'contextid' => $context->id,
-            ];
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                $extrawhere = 'AND b.courseid = :courseid';
+                $params = [
+                    'badgetype' => BADGE_TYPE_COURSE,
+                    'courseid'  => $context->instanceid
+                ];
+            } else {
+                $extrawhere = '';
+                $params = ['badgetype' => BADGE_TYPE_SITE];
+            }
 
             $sql = "SELECT b.usermodified, b.usercreated
                       FROM {badge} b
-                      JOIN {context} ctx
-                           ON (b.type = :typecourse AND b.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel)
-                           OR (b.type = :typesite AND ctx.id = :syscontextid)
-                     WHERE ctx.id = :contextid";
+                     WHERE b.type = :badgetype
+                           $extrawhere";
 
             $userlist->add_from_sql('usermodified', $sql, $params);
             $userlist->add_from_sql('usercreated', $sql, $params);
index d37e417..4d9604e 100644 (file)
@@ -290,13 +290,29 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
     }
 
     public function test_badge_awards() {
+        global $DB;
         $this->preventResetByRollback(); // Messaging is not compatible with transactions.
         $badge = new badge($this->badgeid);
         $user1 = $this->getDataGenerator()->create_user();
 
-        $badge->issue($user1->id, true);
+        $sink = $this->redirectMessages();
+
+        $DB->set_field_select('message_processors', 'enabled', 0, "name <> 'email'");
+        set_user_preference('message_provider_moodle_badgerecipientnotice_loggedoff', 'email', $user1);
+
+        $badge->issue($user1->id, false);
+        $this->assertDebuggingCalled(); // Expect debugging while baking a badge via phpunit.
         $this->assertTrue($badge->is_issued($user1->id));
 
+        $messages = $sink->get_messages();
+        $sink->close();
+        $this->assertCount(1, $messages);
+        $message = array_pop($messages);
+        // Check we have the expected data.
+        $customdata = json_decode($message->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
+        $this->assertObjectHasAttribute('hash', $customdata);
+
         $user2 = $this->getDataGenerator()->create_user();
         $badge->issue($user2->id, true);
         $this->assertTrue($badge->is_issued($user2->id));
index 8ed10c2..369b601 100644 (file)
@@ -160,93 +160,61 @@ class provider implements
      * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
      */
     public static function get_users_in_context(userlist $userlist) {
+        global $DB;
+
         $context = $userlist->get_context();
 
-        $allowedcontexts = [
-            CONTEXT_SYSTEM,
-            CONTEXT_COURSECAT,
-            CONTEXT_COURSE,
-            CONTEXT_MODULE,
-            CONTEXT_USER
-        ];
+        // Calendar Events can exist at Site (CONTEXT_SYSTEM), Course Category (CONTEXT_COURSECAT),
+        // Course and Course Group (CONTEXT_COURSE), User (CONTEXT_USER), or Course Modules (CONTEXT_MODULE) contexts.
+        if ($context->contextlevel == CONTEXT_MODULE) {
+            $params = ['cmid' => $context->instanceid];
+
+            $sql = "SELECT e.userid
+                      FROM {course_modules} cm
+                      JOIN {modules} m ON m.id = cm.module
+                      JOIN {event} e ON e.modulename = m.name AND e.courseid = cm.course AND e.instance = cm.instance
+                     WHERE cm.id = :cmid";
+
+            $userlist->add_from_sql('userid', $sql, $params);
+        } else if ($context->contextlevel == CONTEXT_SYSTEM) {
+            // Get contexts of Calendar Events for the owner.
+            $sql = "SELECT userid FROM {event} WHERE eventtype = 'site'";
+            $userlist->add_from_sql('userid', $sql, []);
+
+            // Get contexts for Calendar Subscriptions for the owner.
+            $sql = "SELECT userid FROM {event_subscriptions} WHERE eventtype = 'site'";
+            $userlist->add_from_sql('userid', $sql, []);
+        } else if (in_array($context->contextlevel, [CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_USER])) {
+            $eventfields = [
+                CONTEXT_COURSECAT   => 'categoryid',
+                CONTEXT_COURSE      => 'courseid',
+                CONTEXT_USER        => 'userid'
+            ];
+            $eventfield = $eventfields[$context->contextlevel];
 
-        if (!in_array($context->contextlevel, $allowedcontexts)) {
-            return;
+            $eventtypes = [
+                CONTEXT_COURSECAT   => 'category',
+                CONTEXT_COURSE      => ['course' , 'group'],
+                CONTEXT_USER        => 'user'
+            ];
+            list($eventtypesql, $eventtypeparams) = $DB->get_in_or_equal($eventtypes[$context->contextlevel], SQL_PARAMS_NAMED);
+
+            $params = $eventtypeparams + ['instanceid' => $context->instanceid];
+
+            // Get contexts of Calendar Events for the owner.
+            $sql = "SELECT userid
+                      FROM {event}
+                     WHERE eventtype $eventtypesql
+                           AND $eventfield = :instanceid";
+            $userlist->add_from_sql('userid', $sql, $params);
+
+            // Get contexts for Calendar Subscriptions for the owner.
+            $sql = "SELECT userid
+                      FROM {event_subscriptions}
+                     WHERE eventtype $eventtypesql
+                           AND $eventfield = :instanceid";
+            $userlist->add_from_sql('userid', $sql, $params);
         }
-
-        $params = [
-            'modulecontext'      => CONTEXT_MODULE,
-            'contextid'          => $context->id,
-        ];
-
-        $sql = "SELECT e.userid
-                  FROM {course_modules} cm
-                  JOIN {modules} m
-                       ON m.id = cm.module
-                  JOIN {event} e
-                       ON e.modulename = m.name
-                          AND e.courseid = cm.course
-                          AND e.instance = cm.instance
-                  JOIN {context} ctx
-                       ON ctx.instanceid = cm.id
-                          AND ctx.contextlevel = :modulecontext
-                 WHERE ctx.id = :contextid";
-
-        $userlist->add_from_sql('userid', $sql, $params);
-
-        // Calendar Events can exist at Site, Course Category, Course, Course Group, User, or Course Modules contexts.
-        $params = [
-            'sitecontext'        => CONTEXT_SYSTEM,
-            'categorycontext'    => CONTEXT_COURSECAT,
-            'coursecontext'      => CONTEXT_COURSE,
-            'groupcontext'       => CONTEXT_COURSE,
-            'usercontext'        => CONTEXT_USER,
-            'contextid'          => $context->id
-        ];
-
-        // Get contexts of Calendar Events for the owner.
-        $sql = "SELECT e.userid
-                  FROM {event} e
-                  JOIN {context} ctx
-                       ON (ctx.contextlevel = :sitecontext
-                          AND e.eventtype = 'site')
-                       OR (ctx.contextlevel = :categorycontext
-                          AND ctx.instanceid = e.categoryid
-                          AND e.eventtype = 'category')
-                       OR (ctx.contextlevel = :coursecontext
-                          AND ctx.instanceid = e.courseid
-                          AND e.eventtype = 'course')
-                       OR (ctx.contextlevel = :groupcontext
-                          AND ctx.instanceid = e.courseid
-                          AND e.eventtype = 'group')
-                       OR (ctx.contextlevel = :usercontext
-                          AND ctx.instanceid = e.userid
-                          AND e.eventtype = 'user')
-                 WHERE ctx.id = :contextid";
-
-        $userlist->add_from_sql('userid', $sql, $params);
-
-        // Get contexts for Calendar Subscriptions for the owner.
-        $sql = "SELECT s.userid
-                  FROM {event_subscriptions} s
-                  JOIN {context} ctx
-                       ON (ctx.contextlevel = :sitecontext
-                          AND s.eventtype = 'site')
-                       OR (ctx.instanceid = s.categoryid
-                          AND ctx.contextlevel = :categorycontext
-                          AND s.eventtype = 'category')
-                       OR (ctx.instanceid = s.courseid
-                          AND ctx.contextlevel = :coursecontext
-                          AND s.eventtype = 'course')
-                       OR (ctx.instanceid = s.courseid
-                          AND ctx.contextlevel = :groupcontext
-                          AND s.eventtype = 'group')
-                       OR (ctx.instanceid = s.userid
-                          AND ctx.contextlevel = :usercontext
-                          AND s.eventtype = 'user')
-                 WHERE ctx.id = :contextid";
-
-        $userlist->add_from_sql('userid', $sql, $params);
     }
 
     /**
@@ -481,6 +449,12 @@ class provider implements
     protected static function get_calendar_event_ids_by_context(\context $context, $userids = array()) {
         global $DB;
 
+        // Calendar Events can exist at Site (CONTEXT_SYSTEM), Course Category (CONTEXT_COURSECAT),
+        // Course and Course Group (CONTEXT_COURSE), User (CONTEXT_USER), or Course Modules (CONTEXT_MODULE) contexts.
+        if (!in_array($context->contextlevel, [CONTEXT_SYSTEM, CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_USER, CONTEXT_MODULE])) {
+            return [];
+        }
+
         $whereusersql = '';
         $userparams = array();
         if (!empty($userids)) {
@@ -488,59 +462,47 @@ class provider implements
             $whereusersql = "AND e.userid {$usersql}";
         }
 
-        // Calendar Events can exist at Site, Course Category, Course, Course Group, User, or Course Modules contexts.
-        if ($context->contextlevel == CONTEXT_MODULE) { // Course Module Contexts.
-            $params = [
-                'modulecontext'     => $context->contextlevel,
-                'contextid'         => $context->id
-            ];
+        if ($context->contextlevel == CONTEXT_MODULE) { // Course Module events.
+            $params = ['cmid' => $context->instanceid];
 
             // Get Calendar Events for the specified Course Module context.
-            $sql = "SELECT DISTINCT
-                           e.id AS eventid
-                      FROM {context} ctx
-                INNER JOIN {course_modules} cm
-                           ON cm.id = ctx.instanceid
-                              AND ctx.contextlevel = :modulecontext
-                INNER JOIN {modules} m
-                           ON m.id = cm.module
-                INNER JOIN {event} e
-                           ON e.modulename = m.name
-                              AND e.courseid = cm.course
-                              AND e.instance = cm.instance
-                     WHERE ctx.id = :contextid
-                           {$whereusersql}";
-        } else {                                        // Other Moodle Contexts.
-            $params = [
-                'sitecontext'       => CONTEXT_SYSTEM,
-                'categorycontext'   => CONTEXT_COURSECAT,
-                'coursecontext'     => CONTEXT_COURSE,
-                'groupcontext'      => CONTEXT_COURSE,
-                'usercontext'       => CONTEXT_USER,
-                'contextid'         => $context->id
+            $sql = "SELECT DISTINCT e.id AS eventid
+                      FROM {course_modules} cm
+                      JOIN {modules} m ON m.id = cm.module
+                      JOIN {event} e ON e.modulename = m.name AND e.courseid = cm.course AND e.instance = cm.instance
+                     WHERE cm.id = :cmid
+                           $whereusersql";
+        } else if ($context->contextlevel == CONTEXT_SYSTEM) { // Site events.
+            $params = [];
+            $sql = "SELECT DISTINCT e.id AS eventid
+                      FROM {event} e
+                     WHERE e.eventtype = 'site'
+                           $whereusersql";
+        } else { // The rest.
+            $eventfields = [
+                CONTEXT_COURSECAT   => 'categoryid',
+                CONTEXT_COURSE      => 'courseid',
+                CONTEXT_USER        => 'userid'
             ];
+            $eventfield = $eventfields[$context->contextlevel];
+
+            $eventtypes = [
+                CONTEXT_COURSECAT   => 'category',
+                CONTEXT_COURSE      => ['course' , 'group'],
+                CONTEXT_USER        => 'user'
+            ];
+            list($eventtypesql, $eventtypeparams) = $DB->get_in_or_equal($eventtypes[$context->contextlevel], SQL_PARAMS_NAMED);
+
+            $params = $eventtypeparams + ['instanceid' => $context->instanceid];
 
             // Get Calendar Events for the specified Moodle context.
-            $sql = "SELECT DISTINCT
-                           e.id AS eventid
-                      FROM {context} ctx
-                INNER JOIN {event} e
-                           ON (e.eventtype = 'site'
-                              AND ctx.contextlevel = :sitecontext)
-                           OR (e.categoryid = ctx.instanceid
-                              AND e.eventtype = 'category'
-                              AND ctx.contextlevel = :categorycontext)
-                           OR (e.courseid = ctx.instanceid
-                              AND (e.eventtype = 'course'
-                                  OR e.eventtype = 'group'
-                                  OR e.modulename != '0')
-                              AND ctx.contextlevel = :coursecontext)
-                           OR (e.userid = ctx.instanceid
-                              AND e.eventtype = 'user'
-                              AND ctx.contextlevel = :usercontext)
-                     WHERE ctx.id = :contextid
-                           {$whereusersql}";
+            $sql = "SELECT DISTINCT e.id AS eventid
+                      FROM {event} e
+                     WHERE e.eventtype $eventtypesql
+                           AND e.{$eventfield} = :instanceid
+                           $whereusersql";
         }
+
         $params += $userparams;
 
         return $DB->get_records_sql($sql, $params);
@@ -558,15 +520,11 @@ class provider implements
     protected static function get_calendar_subscription_ids_by_context(\context $context, $userids = array()) {
         global $DB;
 
-        // Calendar Subscriptions can exist at Site, Course Category, Course, Course Group, or User contexts.
-        $params = [
-            'sitecontext'       => CONTEXT_SYSTEM,
-            'categorycontext'   => CONTEXT_COURSECAT,
-            'coursecontext'     => CONTEXT_COURSE,
-            'groupcontext'      => CONTEXT_COURSE,
-            'usercontext'       => CONTEXT_USER,
-            'contextid'         => $context->id
-        ];
+        // Calendar Subscriptions can exist at Site (CONTEXT_SYSTEM), Course Category (CONTEXT_COURSECAT),
+        // Course and Course Group (CONTEXT_COURSE), or User (CONTEXT_USER) contexts.
+        if (!in_array($context->contextlevel, [CONTEXT_SYSTEM, CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_USER])) {
+            return [];
+        }
 
         $whereusersql = '';
         $userparams = array();
@@ -575,27 +533,38 @@ class provider implements
             $whereusersql = "AND s.userid {$usersql}";
         }
 
-        // Get Calendar Subscriptions for the specified context.
-        $sql = "SELECT DISTINCT
-                       s.id AS subscriptionid
-                  FROM {context} ctx
-            INNER JOIN {event_subscriptions} s
-                       ON (s.eventtype = 'site'
-                          AND ctx.contextlevel = :sitecontext)
-                       OR (s.categoryid = ctx.instanceid
-                          AND s.eventtype = 'category'
-                          AND ctx.contextlevel = :categorycontext)
-                       OR (s.courseid = ctx.instanceid
-                          AND s.eventtype = 'course'
-                          AND ctx.contextlevel = :coursecontext)
-                       OR (s.courseid = ctx.instanceid
-                          AND s.eventtype = 'group'
-                          AND ctx.contextlevel = :groupcontext)
-                       OR (s.userid = ctx.instanceid
-                          AND s.eventtype = 'user'
-                          AND ctx.contextlevel = :usercontext)
-                 WHERE ctx.id = :contextid
-                       {$whereusersql}";
+        if ($context->contextlevel == CONTEXT_SYSTEM) {
+            $params = [];
+
+            // Get Calendar Subscriptions for the system context.
+            $sql = "SELECT DISTINCT s.id AS subscriptionid
+                      FROM {event_subscriptions} s
+                     WHERE s.eventtype = 'site'
+                           $whereusersql";
+        } else {
+            $eventfields = [
+                CONTEXT_COURSECAT   => 'categoryid',
+                CONTEXT_COURSE      => 'courseid',
+                CONTEXT_USER        => 'userid'
+            ];
+            $eventfield = $eventfields[$context->contextlevel];
+
+            $eventtypes = [
+                CONTEXT_COURSECAT   => 'category',
+                CONTEXT_COURSE      => ['course' , 'group'],
+                CONTEXT_USER        => 'user'
+            ];
+            list($eventtypesql, $eventtypeparams) = $DB->get_in_or_equal($eventtypes[$context->contextlevel], SQL_PARAMS_NAMED);
+
+            $params = $eventtypeparams + ['instanceid' => $context->instanceid];
+
+            // Get Calendar Subscriptions for the specified context.
+            $sql = "SELECT DISTINCT s.id AS subscriptionid
+                      FROM {event_subscriptions} s
+                     WHERE s.eventtype $eventtypesql
+                           AND s.{$eventfield} = :instanceid
+                           $whereusersql";
+        }
 
         $params += $userparams;
 
index 0921296..2b7157d 100644 (file)
@@ -365,12 +365,23 @@ class core_calendar_privacy_testcase extends provider_testcase {
         // Delete all Calendar Events for all Users by Context for Course 2.
         provider::delete_data_for_all_users_in_context($course2context);
 
-        // Verify all Calendar Events for Course 2 were deleted.
-        $events = $DB->get_records('event', array('courseid' => $course2->id));
+        // Verify all Calendar Events for Course 2 context were deleted.
+        $events = $DB->get_records('event', array('courseid' => $course2->id, 'modulename' => '0'));
         $this->assertCount(0, $events);
         // Verify all Calendar Subscriptions for Course 2 were deleted.
         $subscriptions = $DB->get_records('event_subscriptions', array('courseid' => $course2->id));
         $this->assertCount(0, $subscriptions);
+
+        // Verify all Calendar Events for the assignment exists still.
+        $events = $DB->get_records('event', array('modulename' => 'assign'));
+        $this->assertCount(2, $events);
+
+        // Delete all Calendar Events for all Users by Context for the assignment.
+        provider::delete_data_for_all_users_in_context($modulecontext);
+
+        // Verify all Calendar Events for the assignment context were deleted.
+        $events = $DB->get_records('event', array('modulename' => 'assign'));
+        $this->assertCount(0, $events);
     }
 
     /**
index 29377ed..9788554 100644 (file)
@@ -95,21 +95,12 @@ class provider implements
             return;
         }
 
-        $params = [
-            'contextid' => $context->id,
-            'contextsystem' => CONTEXT_SYSTEM,
-            'contextcoursecat' => CONTEXT_COURSECAT,
-        ];
+        $params = ['contextid' => $context->id];
 
-        $sql = "SELECT cm.userid as userid
+        $sql = "SELECT cm.userid
                   FROM {cohort_members} cm
-                  JOIN {cohort} c
-                       ON cm.cohortid = c.id
-                  JOIN {context} ctx
-                       ON c.contextid = ctx.id
-                       AND (ctx.contextlevel = :contextsystem
-                            OR ctx.contextlevel = :contextcoursecat)
-                 WHERE ctx.id = :contextid";
+                  JOIN {cohort} c ON cm.cohortid = c.id
+                 WHERE c.contextid = :contextid";
 
         $userlist->add_from_sql('userid', $sql, $params);
     }
index 1e5bdfd..2c61be4 100644 (file)
@@ -38,7 +38,7 @@ use core_competency\user_evidence;
  * @return array
  */
 function core_competency_comment_add($comment, $params) {
-    global $USER;
+    global $USER, $PAGE;
 
     if (!get_config('core_competency', 'enabled')) {
         return;
@@ -132,10 +132,16 @@ function core_competency_comment_add($comment, $params) {
         $message->contexturl = $url->out(false);
         $message->contexturlname = $urlname;
 
+        $userpicture = new \user_picture($user);
         // Message each recipient.
         foreach ($recipients as $recipient) {
             $msgcopy = clone($message);
             $msgcopy->userto = $recipient;
+            // Generate an out-of-session token for the user receiving the message.
+            $userpicture->includetoken = $recipient;
+            $msgcopy->customdata = [
+                'notificationiconurl' => $userpicture->get_url($PAGE)->out(false),
+            ];
             message_send($msgcopy);
         }
 
@@ -201,10 +207,16 @@ function core_competency_comment_add($comment, $params) {
         $message->contexturl = $url->out(false);
         $message->contexturlname = $urlname;
 
+        $userpicture = new \user_picture($user);
         // Message each recipient.
         foreach ($recipients as $recipient) {
             $msgcopy = clone($message);
             $msgcopy->userto = $recipient;
+            // Generate an out-of-session token for the user receiving the message.
+            $userpicture->includetoken = $recipient;
+            $msgcopy->customdata = [
+                'notificationiconurl' => $userpicture->get_url($PAGE)->out(false),
+            ];
             message_send($msgcopy);
         }
     }
index 9ed2e92..dcfe80e 100644 (file)
@@ -40,12 +40,12 @@ global $CFG;
 class core_competency_lib_testcase extends advanced_testcase {
 
     public function test_comment_add_user_competency() {
-        global $DB;
+        global $DB, $PAGE;
         $this->resetAfterTest();
         $dg = $this->getDataGenerator();
         $lpg = $dg->get_plugin_generator('core_competency');
 
-        $u1 = $dg->create_user();
+        $u1 = $dg->create_user(['picture' => 1]);
         $u2 = $dg->create_user();
         $u3 = $dg->create_user();
         $reviewerroleid = $dg->create_role();
@@ -96,6 +96,13 @@ class core_competency_lib_testcase extends advanced_testcase {
         $this->assertEquals(FORMAT_MOODLE, $message->fullmessageformat);
         $this->assertEquals($expectedurl->out(false), $message->contexturl);
         $this->assertEquals($expectedurlname, $message->contexturlname);
+        // Test customdata.
+        $customdata = json_decode($message->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
+        $this->assertContains('tokenpluginfile.php', $customdata->notificationiconurl);
+        $userpicture = new \user_picture($u1);
+        $userpicture->includetoken = $u2->id;
+        $this->assertEquals($userpicture->get_url($PAGE)->out(false), $customdata->notificationiconurl);
 
         // Reviewer posts a comment for the user competency being in two plans. Owner is messaged.
         $this->setUser($u2);
@@ -218,6 +225,9 @@ class core_competency_lib_testcase extends advanced_testcase {
         $message = array_pop($messages);
         $this->assertEquals(core_user::get_noreply_user()->id, $message->useridfrom);
         $this->assertEquals($u1->id, $message->useridto);
+        // Test customdata.
+        $customdata = json_decode($message->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
 
         // Post a comment in a plan with reviewer. The reviewer is messaged.
         $p1->set('reviewerid', $u2->id);
index 0861a0e..fb3a4f9 100644 (file)
@@ -110,28 +110,27 @@ class provider implements
      * @param userlist $userlist The userlist to add to.
      */
     public static function add_course_completion_users_to_userlist(userlist $userlist) {
-        $params = [
-            'contextid' => $userlist->get_context()->id,
-            'contextcourse' => CONTEXT_COURSE,
-        ];
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        $params = ['courseid' => $context->instanceid];
 
         $sql = "SELECT cmc.userid
-                 FROM {context} ctx
-                 JOIN {course} c ON ctx.instanceid = c.id
+                 FROM {course} c
                  JOIN {course_completion_criteria} ccc ON ccc.course = c.id
                  JOIN {course_modules_completion} cmc ON cmc.coursemoduleid = ccc.moduleinstance
-                WHERE ctx.id = :contextid
-                  AND ctx.contextlevel = :contextcourse";
+                WHERE c.id = :courseid";
 
         $userlist->add_from_sql('userid', $sql, $params);
 
         $sql = "SELECT ccc_compl.userid
-                 FROM {context} ctx
-                 JOIN {course} c ON ctx.instanceid = c.id
+                 FROM {course} c
                  JOIN {course_completion_criteria} ccc ON ccc.course = c.id
                  JOIN {course_completion_crit_compl} ccc_compl ON ccc_compl.criteriaid = ccc.id
-                WHERE ctx.id = :contextid
-                  AND ctx.contextlevel = :contextcourse";
+                WHERE c.id = :courseid";
 
         $userlist->add_from_sql('userid', $sql, $params);
     }
index 296f92e..3956959 100644 (file)
@@ -152,4 +152,72 @@ class behat_completion extends behat_base {
             array($imgalttext, "icon", $activityxpath, "xpath_element")
         );
     }
+
+    /**
+     * Checks if the activity with specified name shows a information completion checkbox (i.e. showing the completion tracking
+     * configuration).
+     *
+     * @Given /^the "(?P<activityname_string>(?:[^"]|\\")*)" "(?P<activitytype_string>(?:[^"]|\\")*)" activity with "(manual|auto)" completion shows a configuration completion checkbox/
+     * @param string $activityname The activity name.
+     * @param string $activitytype The activity type.
+     * @param string $completiontype The completion type.
+     */
+    public function activity_has_configuration_completion_checkbox($activityname, $activitytype, $completiontype) {
+        if ($completiontype == "manual") {
+            $imgname = 'i/completion-manual-enabled';
+        } else {
+            $imgname = 'i/completion-auto-enabled';
+        }
+        $iconxpath = "//li[contains(concat(' ', @class, ' '), ' modtype_" . strtolower($activitytype) . " ')]";
+        $iconxpath .= "[descendant::*[contains(text(), '" . $activityname . "')]]";
+        $iconxpath .= "/descendant::span[@class='actions']/descendant::img[contains(@src, 'i/completion-')]";
+
+        $this->execute("behat_general::the_attribute_of_should_contain",
+            array("src", $iconxpath, "xpath_element", $imgname)
+        );
+    }
+
+    /**
+     * Checks if the activity with specified name shows a tracking completion checkbox (i.e. showing my completion tracking status)
+     *
+     * @Given /^the "(?P<activityname_string>(?:[^"]|\\")*)" "(?P<activitytype_string>(?:[^"]|\\")*)" activity with "(manual|auto)" completion shows a status completion checkbox/
+     * @param string $activityname The activity name.
+     * @param string $activitytype The activity type.
+     * @param string $completiontype The completion type.
+     */
+    public function activity_has_status_completion_checkbox($activityname, $activitytype, $completiontype) {
+        if ($completiontype == "manual") {
+            $imgname = 'i/completion-manual-';
+        } else {
+            $imgname = 'i/completion-auto-';
+        }
+        $iconxpath = "//li[contains(concat(' ', @class, ' '), ' modtype_" . strtolower($activitytype) . " ')]";
+        $iconxpath .= "[descendant::*[contains(text(), '" . $activityname . "')]]";
+        $iconxpath .= "/descendant::span[@class='actions']/descendant::img[contains(@src, 'i/completion-')]";
+
+        $this->execute("behat_general::the_attribute_of_should_contain",
+            array("src", $iconxpath, "xpath_element", $imgname)
+        );
+
+        $this->execute("behat_general::the_attribute_of_should_not_contain",
+            array("src", $iconxpath, "xpath_element", '-enabled')
+        );
+    }
+
+    /**
+     * Checks if the activity with specified name does not show any completion checkbox.
+     *
+     * @Given /^the "(?P<activityname_string>(?:[^"]|\\")*)" "(?P<activitytype_string>(?:[^"]|\\")*)" activity does not show any completion checkbox/
+     * @param string $activityname The activity name.
+     * @param string $activitytype The activity type.
+     */
+    public function activity_has_not_any_completion_checkbox($activityname, $activitytype) {
+        $iconxpath = "//li[contains(concat(' ', @class, ' '), ' modtype_" . strtolower($activitytype) . " ')]";
+        $iconxpath .= "[descendant::*[contains(text(), '" . $activityname . "')]]";
+        $iconxpath .= "/descendant::img[contains(@src, 'i/completion-')]";
+
+        $this->execute("behat_general::should_not_exist",
+            array($iconxpath, "xpath_element")
+        );
+    }
 }
diff --git a/completion/tests/behat/completion_course_page_checkboxes.feature b/completion/tests/behat/completion_course_page_checkboxes.feature
new file mode 100644 (file)
index 0000000..48005ee
--- /dev/null
@@ -0,0 +1,65 @@
+@core @core_completion
+Feature: Show activity completion status or activity completion configuration on the course page
+  In order to understand the configuration or status of an activity's completion
+  As a user
+  I want to see an appropriate checkbox icon besides the activity
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher   | First    | teacher1@example.com |
+      | teacher2 | Teacher   | Second   | teacher2@example.com |
+      | student1 | Student   | First    | student1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | teacher2 | C1 | teacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Enable completion tracking | Yes |
+    And I press "Save and display"
+    And the following "activities" exist:
+      | activity | course | idnumber | name            | intro                  | completion | completionview | completionexpected |
+      | forum    | C1     | forum1   | Test forum name | Test forum description | 1          | 0              | 0                  |
+    And the following "activities" exist:
+      | activity | course | idnumber | name                 | intro                       | completion | completionview | completionexpected |
+      | assign   | C1     | assign1  | Test assignment name | Test assignment description | 2          | 1              | 0                  |
+    And the following "activities" exist:
+      | activity | course | idnumber | name           | intro                 | completion | completionview | completionexpected |
+      | quiz     | C1     | quiz1    | Test quiz name | Test quiz description | 0          | 0              | 0                  |
+    And I log out
+
+  Scenario: Show completion status to students
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    Then I should see "Your progress"
+    And the "Test forum name" "Forum" activity with "manual" completion shows a status completion checkbox
+    And the "Test assignment name" "Assign" activity with "auto" completion shows a status completion checkbox
+    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+
+  Scenario: Show completion configuration to editing teachers
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    Then I should not see "Your progress"
+    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
+    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
+    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+    And I am on "Course 1" course homepage with editing mode on
+    And I should not see "Your progress"
+    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
+    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
+    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+
+  Scenario: Show completion configuration to non-editing teachers
+    Given I log in as "teacher2"
+    And I am on "Course 1" course homepage
+    Then I should not see "Your progress"
+    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
+    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
+    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
index db2324d..dc0dbed 100644 (file)
@@ -4,6 +4,10 @@ information provided here is intended especially for developers.
 === 3.7 ===
  * External function core_completion_external::get_activities_completion_status new returns the following additional field:
    - valueused (indicates whether the completion state affects the availability of other content)
+ * On the course page, only users with the capability 'moodle/course:isincompletionreports' (students, by default) can now tick the
+   completion checkboxes. Teachers no longer get working checkboxes; tey see slightly different icons that indicate whether
+   completion is enabled for the activity. These are the same icons which have always been shown to teachers before when the
+   enabled the course editing mode.
 
 === 2.9 ===
 
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 55f5ebe..9d1439c 100644 (file)
@@ -403,8 +403,12 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     public function course_section_cm_completion($course, &$completioninfo, cm_info $mod, $displayoptions = array()) {
-        global $CFG, $DB;
+        global $CFG, $DB, $USER;
         $output = '';
+
+        $istrackeduser = $completioninfo->is_tracked_user($USER->id);
+        $isediting = $this->page->user_is_editing();
+
         if (!empty($displayoptions['hidecompletion']) || !isloggedin() || isguestuser() || !$mod->uservisible) {
             return $output;
         }
@@ -412,49 +416,52 @@ class core_course_renderer extends plugin_renderer_base {
             $completioninfo = new completion_info($course);
         }
         $completion = $completioninfo->is_enabled($mod);
+
         if ($completion == COMPLETION_TRACKING_NONE) {
-            if ($this->page->user_is_editing()) {
+            if ($isediting) {
                 $output .= html_writer::span('&nbsp;', 'filler');
             }
             return $output;
         }
 
-        $completiondata = $completioninfo->get_data($mod, true);
         $completionicon = '';
 
-        if ($this->page->user_is_editing()) {
+        if ($isediting || !$istrackeduser) {
             switch ($completion) {
                 case COMPLETION_TRACKING_MANUAL :
                     $completionicon = 'manual-enabled'; break;
                 case COMPLETION_TRACKING_AUTOMATIC :
                     $completionicon = 'auto-enabled'; break;
             }
-        } else if ($completion == COMPLETION_TRACKING_MANUAL) {
-            switch($completiondata->completionstate) {
-                case COMPLETION_INCOMPLETE:
-                    $completionicon = 'manual-n' . ($completiondata->overrideby ? '-override' : '');
-                    break;
-                case COMPLETION_COMPLETE:
-                    $completionicon = 'manual-y' . ($completiondata->overrideby ? '-override' : '');
-                    break;
-            }
-        } else { // Automatic
-            switch($completiondata->completionstate) {
-                case COMPLETION_INCOMPLETE:
-                    $completionicon = 'auto-n' . ($completiondata->overrideby ? '-override' : '');
-                    break;
-                case COMPLETION_COMPLETE:
-                    $completionicon = 'auto-y' . ($completiondata->overrideby ? '-override' : '');
-                    break;
-                case COMPLETION_COMPLETE_PASS:
-                    $completionicon = 'auto-pass'; break;
-                case COMPLETION_COMPLETE_FAIL:
-                    $completionicon = 'auto-fail'; break;
+        } else {
+            $completiondata = $completioninfo->get_data($mod, true);
+            if ($completion == COMPLETION_TRACKING_MANUAL) {
+                switch($completiondata->completionstate) {
+                    case COMPLETION_INCOMPLETE:
+                        $completionicon = 'manual-n' . ($completiondata->overrideby ? '-override' : '');
+                        break;
+                    case COMPLETION_COMPLETE:
+                        $completionicon = 'manual-y' . ($completiondata->overrideby ? '-override' : '');
+                        break;
+                }
+            } else { // Automatic
+                switch($completiondata->completionstate) {
+                    case COMPLETION_INCOMPLETE:
+                        $completionicon = 'auto-n' . ($completiondata->overrideby ? '-override' : '');
+                        break;
+                    case COMPLETION_COMPLETE:
+                        $completionicon = 'auto-y' . ($completiondata->overrideby ? '-override' : '');
+                        break;
+                    case COMPLETION_COMPLETE_PASS:
+                        $completionicon = 'auto-pass'; break;
+                    case COMPLETION_COMPLETE_FAIL:
+                        $completionicon = 'auto-fail'; break;
+                }
             }
         }
         if ($completionicon) {
             $formattedname = html_entity_decode($mod->get_formatted_name(), ENT_QUOTES, 'UTF-8');
-            if ($completiondata->overrideby) {
+            if (!$isediting && $istrackeduser && $completiondata->overrideby) {
                 $args = new stdClass();
                 $args->modname = $formattedname;
                 $overridebyuser = \core_user::get_user($completiondata->overrideby, '*', MUST_EXIST);
@@ -464,7 +471,7 @@ class core_course_renderer extends plugin_renderer_base {
                 $imgalt = get_string('completion-alt-' . $completionicon, 'completion', $formattedname);
             }
 
-            if ($this->page->user_is_editing() || !has_capability('moodle/course:togglecompletion', $mod->context)) {
+            if ($isediting || !$istrackeduser || !has_capability('moodle/course:togglecompletion', $mod->context)) {
                 // When editing, the icon is just an image.
                 $completionpixicon = new pix_icon('i/completion-'.$completionicon, $imgalt, '',
                         array('title' => $imgalt, 'class' => 'iconsmall'));
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 a7d4a96..4691244 100644 (file)
@@ -194,12 +194,9 @@ class provider implements
         if ($context->contextlevel == CONTEXT_COURSE) {
             $sql = "SELECT ue.id
                       FROM {user_enrolments} ue
-                      JOIN {enrol} e
-                        ON e.id = ue.enrolid
-                      JOIN {context} ctx
-                        ON ctx.instanceid = e.courseid
-                     WHERE ctx.id = :contextid";
-            $params = ['contextid' => $context->id];
+                      JOIN {enrol} e ON e.id = ue.enrolid
+                     WHERE e.courseid = :courseid";
+            $params = ['courseid' => $context->instanceid];
             $enrolsids = $DB->get_fieldset_sql($sql, $params);
             if (!empty($enrolsids)) {
                 list($insql, $inparams) = $DB->get_in_or_equal($enrolsids, SQL_PARAMS_NAMED);
@@ -223,14 +220,11 @@ class provider implements
 
             $sql = "SELECT ue.id
                       FROM {user_enrolments} ue
-                      JOIN {enrol} e
-                        ON e.id = ue.enrolid
-                      JOIN {context} ctx
-                        ON ctx.instanceid = e.courseid
-                     WHERE ctx.id = :contextid
-                     AND ue.userid {$usersql}";
+                      JOIN {enrol} e ON e.id = ue.enrolid
+                     WHERE e.courseid = :courseid
+                           AND ue.userid {$usersql}";
 
-            $params = ['contextid' => $context->id] + $userparams;
+            $params = ['courseid' => $context->instanceid] + $userparams;
             $enrolsids = $DB->get_fieldset_sql($sql, $params);
 
             if (!empty($enrolsids)) {
index 6c7db8f..529b9ec 100644 (file)
@@ -32,7 +32,7 @@ require_once($CFG->dirroot . '/filter/algebra/filter.php');
 /**
  * Unit tests for filter_algebra.
  *
- * Note that this only tests some of the filter logic. It does not acutally test
+ * Note that this only tests some of the filter logic. It does not actually test
  * the normal case of the filter working, because I cannot make it work on my
  * test server, and if it does not work here, it probably does not also work
  * for other people. A failing test will be irritating noise.
index 33f172f..334b74f 100644 (file)
@@ -267,13 +267,14 @@ class provider implements
         $context = $userlist->get_context();
         $userids = $userlist->get_userids();
 
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
         list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 
-        $groupselect = "SELECT g.id
-                          FROM {groups} g
-                          JOIN {context} ctx ON g.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
-                         WHERE ctx.id = :contextid";
-        $groupparams = ['contextid' => $context->id, 'contextcourse' => CONTEXT_COURSE];
+        $groupselect = "SELECT id FROM {groups} WHERE courseid = :courseid";
+        $groupparams = ['courseid' => $context->instanceid];
 
         $select = "component = :component AND userid {$usersql} AND groupid IN ({$groupselect})";
         $params = ['component' => $component] + $groupparams + $userparams;
index 2243db8..428ca61 100644 (file)
@@ -153,3 +153,4 @@ hidedockpanel,core_block
 undockall,core_block
 undockblock,core_block
 undockitem,core_block
+canceledit,core_message
index e3a2fb7..8c18ed7 100644 (file)
@@ -27,7 +27,7 @@ $string['addcontact'] = 'Add contact';
 $string['addcontactconfirm'] = 'Are you sure you want to add {$a} to your contacts?';
 $string['addtoyourcontacts'] = 'Add to contacts';
 $string['addtoyourcontactsandmessage'] = 'Add to contacts and message';
-$string['addtofavourites'] = 'Star';
+$string['addtofavourites'] = 'Star conversation';
 $string['ago'] = '{$a} ago';
 $string['allusers'] = 'All messages from all users';
 $string['backto'] = 'Back to {$a}';
@@ -38,7 +38,7 @@ $string['blockuser'] = 'Block user';
 $string['blockuserconfirm'] = 'Are you sure you want to block {$a}?';
 $string['blockuserconfirmbutton'] = 'Block';
 $string['blocknoncontacts'] = 'Prevent non-contacts from messaging me';
-$string['canceledit'] = 'Cancel editing messages';
+$string['cancelselection'] = 'Cancel message selection';
 $string['contactableprivacy'] = 'Accept messages from:';
 $string['contactableprivacy_onlycontacts'] = 'My contacts only';
 $string['contactableprivacy_coursemember'] = 'My contacts and anyone in my courses';
@@ -47,6 +47,7 @@ $string['contactblocked'] = 'Contact blocked';
 $string['contactrequests'] = 'Contact requests';
 $string['contactrequestsent'] = 'Contact request sent';
 $string['contacts'] = 'Contacts';
+$string['conversationactions'] = 'Conversation actions menu';
 $string['decline'] = 'Decline';
 $string['defaultmessageoutputs'] = 'Notification settings';
 $string['defaults'] = 'Defaults';
@@ -84,7 +85,7 @@ $string['groupconversations'] = 'Group';
 $string['hidemessagewindow'] = 'Hide message window';
 $string['hidenotificationwindow'] = 'Hide notification window';
 $string['individualconversations'] = 'Private';
-$string['info'] = 'Info';
+$string['info'] = 'User info';
 $string['isnotinyourcontacts'] = '{$a} is not in your contacts';
 $string['loadmore'] = 'Load more';
 $string['loggedin'] = 'Online';
@@ -111,6 +112,7 @@ $string['messages'] = 'Messages';
 $string['messagesselected:'] = 'Messages selected:';
 $string['messagingdatahasnotbeenmigrated'] = 'Your messages are temporarily unavailable due to upgrades in the messaging infrastructure. Please wait for them to be migrated.';
 $string['muteconversation'] = 'Mute';
+$string['mutedconversation'] = 'Muted conversation';
 $string['newonlymsg'] = 'Show only new';
 $string['newmessage'] = 'New message';
 $string['newmessagesearch'] = 'Select or search for a contact to send a new message.';
@@ -155,6 +157,7 @@ $string['privacy:metadata:messages:useridfrom'] = 'The ID of the user who sent t
 $string['privacy:metadata:messages:smallmessage'] = 'A small version of the message';
 $string['privacy:metadata:messages:subject'] = 'The subject of the message';
 $string['privacy:metadata:messages:timecreated'] = 'The time when the message was created';
+$string['privacy:metadata:messages:customdata'] = 'Custom data, usually contains internal ids and a public URL of the sender image (user or group).';
 $string['privacy:metadata:message_contacts'] = 'The list of contacts';
 $string['privacy:metadata:message_contacts:contactid'] = 'The ID of the user who is a contact';
 $string['privacy:metadata:message_contacts:timecreated'] = 'The time when the contact was created';
@@ -195,6 +198,7 @@ $string['privacy:metadata:notifications:timeread'] = 'The time when the notifica
 $string['privacy:metadata:notifications:timecreated'] = 'The time when the notification was created';
 $string['privacy:metadata:notifications:useridfrom'] = 'The ID of the user who sent the notification';
 $string['privacy:metadata:notifications:useridto'] = 'The ID of the user who received the notification';
+$string['privacy:metadata:notifications:customdata'] = 'Custom data, usually contains internal ids and a public URL of the sender picture (if any).';
 $string['privacy:metadata:preference:core_message_settings'] = 'Settings related to messaging';
 $string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['privacy:export:conversationprefix'] = 'Conversation: ';
@@ -203,7 +207,7 @@ $string['removecontact'] = 'Remove contact';
 $string['removecontactconfirm'] = 'Are you sure you want to remove {$a} from your contacts?';
 $string['removecoursefilter'] = 'Remove filter for course {$a}';
 $string['removefromyourcontacts'] = 'Remove from contacts';
-$string['removefromfavourites'] = 'Unstar';
+$string['removefromfavourites'] = 'Unstar conversation';
 $string['requirecontacttomessage'] = 'You need to request {$a} to add you as a contact to be able to message them.';
 $string['requiresconfiguration'] = 'Requires configuration';
 $string['searchforuser'] = 'Search for a user';
@@ -277,3 +281,4 @@ $string['outputdisabled'] = 'Output disabled';
 $string['outputdoesnotexist'] = 'Message output does not exist';
 $string['outputenabled'] = 'Output enabled';
 $string['outputnotconfigured'] = 'Not configured';
+$string['canceledit'] = 'Cancel editing messages';
index 3f336d7..01ecbc8 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 $string['abouttobeinstalled'] = 'about to be installed';
+$string['accept'] = 'Accept';
 $string['action'] = 'Action';
 $string['actionchoice'] = 'What do you want to do with the file \'{$a}\'?';
 $string['actions'] = 'Actions';
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 2f156d9..3b05402 100644 (file)
@@ -1012,6 +1012,11 @@ function badges_notify_badge_award(badge $badge, $userid, $issued, $filepathhash
     $eventdata->fullmessageformat = FORMAT_HTML;
     $eventdata->fullmessagehtml   = $message;
     $eventdata->smallmessage      = '';
+    $eventdata->customdata        = [
+        'notificationiconurl' => moodle_url::make_pluginfile_url(
+            $badge->get_context()->id, 'badges', 'badgeimage', $badge->id, '/', 'f1')->out(),
+        'hash' => $issued,
+    ];
 
     // Attach badge image if possible.
     if (!empty($CFG->allowattachments) && $badge->attachment && is_string($filepathhash)) {
@@ -1049,6 +1054,11 @@ function badges_notify_badge_award(badge $badge, $userid, $issued, $filepathhash
         $eventdata->fullmessageformat = FORMAT_HTML;
         $eventdata->fullmessagehtml   = $creatormessage;
         $eventdata->smallmessage      = '';
+        $eventdata->customdata        = [
+            'notificationiconurl' => moodle_url::make_pluginfile_url(
+                $badge->get_context()->id, 'badges', 'badgeimage', $badge->id, '/', 'f1')->out(),
+            'hash' => $issued,
+        ];
 
         message_send($eventdata);
         $DB->set_field('badge_issued', 'issuernotified', time(), array('badgeid' => $badge->id, 'userid' => $userid));
index 26fa673..6d73f4a 100644 (file)
@@ -168,7 +168,7 @@ XPATH
         //div[@data-region='empty-message-container' and not(contains(@class, 'hidden')) and contains(., %locator%)]
 XPATH
     , 'group_message_tab' => <<<XPATH
-        .//*[@data-region='message-drawer']//button[@data-toggle='collapse']//*[text()[contains(., %locator%)]]/..
+        .//*[@data-region='message-drawer']//button[@data-toggle='collapse' and contains(string(), %locator%)]
 XPATH
         , 'icon' => <<<XPATH
 .//*[contains(concat(' ', normalize-space(@class), ' '), ' icon ') and ( contains(normalize-space(@title), %locator%))]
index 8dce568..66744b1 100644 (file)
@@ -169,6 +169,14 @@ class manager {
 
             // Spoof the userto based on the current member id.
             $localisedeventdata->userto = $recipient;
+            // Check if the notification is including images that will need a user token to be displayed outside Moodle.
+            if (!empty($localisedeventdata->customdata)) {
+                $customdata = json_decode($localisedeventdata->customdata);
+                if (is_object($customdata) && !empty($customdata->notificationiconurl)) {
+                    $customdata->tokenpluginfile = get_user_key('core_files', $localisedeventdata->userto->id);
+                    $localisedeventdata->customdata = $customdata; // Message class will JSON encode again.
+                }
+            }
 
             $s = new \stdClass();
             $s->sitename = format_string($SITE->shortname, true, array('context' => \context_course::instance(SITEID)));
index 9ad4a40..6f4e26e 100644 (file)
@@ -52,6 +52,7 @@ defined('MOODLE_INTERNAL') || die();
  *  replyto string An email address which can be used to send an reply.
  *  attachment stored_file File instance that needs to be sent as attachment.
  *  attachname string Name of the attachment.
+ *  customdata mixed Custom data to be passed to the message processor. Must be serialisable using json_encode().
  *
  * @package   core_message
  * @since     Moodle 2.9
@@ -125,9 +126,12 @@ class message {
     /** @var  int The time the message was created.*/
     private $timecreated;
 
-     /** @var boolean Mark trust content. */
+    /** @var boolean Mark trust content. */
     private $fullmessagetrust;
 
+    /** @var  mixed Custom data to be passed to the message processor. Must be serialisable using json_encode(). */
+    private $customdata;
+
     /** @var array a list of properties that is allowed for each message. */
     private $properties = array(
         'courseid',
@@ -152,8 +156,9 @@ class message {
         'attachment',
         'attachname',
         'timecreated',
-        'fullmessagetrust'
-        );
+        'fullmessagetrust',
+        'customdata',
+    );
 
     /** @var array property to store any additional message processor specific content */
     private $additionalcontent = array();
@@ -203,6 +208,20 @@ class message {
         }
     }
 
+    /**
+     * Always JSON encode customdata.
+     *
+     * @param mixed $customdata a data structure that must be serialisable using json_encode().
+     */
+    protected function set_customdata($customdata) {
+        // Always include the courseid (because is not stored in the notifications or messages table).
+        if (!empty($this->courseid) && (is_object($customdata) || is_array($customdata))) {
+            $customdata = (array) $customdata;
+            $customdata['courseid'] = $this->courseid;
+        }
+        $this->customdata = json_encode($customdata);
+    }
+
     /**
      * Helper method used to get message content added with processor specific content.
      *
@@ -255,6 +274,12 @@ class message {
      * @throws \coding_exception
      */
     public function __set($prop, $value) {
+
+        // Custom data must be JSON encoded always.
+        if ($prop == 'customdata') {
+            return $this->set_customdata($value);
+        }
+
         if (in_array($prop, $this->properties)) {
             return $this->$prop = $value;
         }
index 3d8a5e7..cac848f 100644 (file)
@@ -319,9 +319,10 @@ class completion_info {
      * @return string HTML code for help icon, or blank if not needed
      */
     public function display_help_icon() {
-        global $PAGE, $OUTPUT;
+        global $PAGE, $OUTPUT, $USER;
         $result = '';
-        if ($this->is_enabled() && !$PAGE->user_is_editing() && isloggedin() && !isguestuser()) {
+        if ($this->is_enabled() && !$PAGE->user_is_editing() && $this->is_tracked_user($USER->id) && isloggedin() &&
+                !isguestuser()) {
             $result .= html_writer::tag('div', get_string('yourprogress','completion') .
                     $OUTPUT->help_icon('completionicons', 'completion'), array('id' => 'completionprogressid',
                     'class' => 'completionprogress'));
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 c924dba..434974d 100644 (file)
         <FIELD NAME="timeusertodeleted" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="eventtype" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="customdata" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Custom data to be passed to the message processor. Must be serialisable using json_encode()"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="smallmessage" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="fullmessagetrust" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="customdata" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Custom data to be passed to the message processor. Must be serialisable using json_encode()"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="contexturlname" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timeread" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="customdata" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Custom data to be passed to the message processor. Must be serialisable using json_encode()"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index e2b63f1..d3473bb 100644 (file)
@@ -72,12 +72,18 @@ $messageproviders = array (
 
     // Course request approval notification
     'courserequestapproved' => array (
-         'capability'  => 'moodle/course:request'
+         'capability'  => 'moodle/course:request',
+         'defaults' => array(
+            'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
+        ),
     ),
 
     // Course request rejection notification
     'courserequestrejected' => array (
-        'capability'  => 'moodle/course:request'
+        'capability'  => 'moodle/course:request',
+        'defaults' => array(
+            'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
+        ),
     ),
 
     // Badge award notification to a badge recipient.
@@ -85,6 +91,7 @@ $messageproviders = array (
         'defaults' => array(
             'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
+            'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
         ),
         'capability'  => 'moodle/badges:earnbadge'
     ),
@@ -107,6 +114,7 @@ $messageproviders = array (
         'defaults' => [
             'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
+            'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
         ]
     ),
 
@@ -115,6 +123,7 @@ $messageproviders = array (
         'defaults' => [
             'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
+            'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
         ]
     ],
 
index dafe307..dea5215 100644 (file)
@@ -3005,6 +3005,8 @@ function xmldb_main_upgrade($oldversion) {
         // the convhash and star them.
         $sql = "SELECT mcm.conversationid, mcm.userid, MAX(mcm.id) as maxid
                   FROM {message_conversation_members} mcm
+            INNER JOIN {user} u ON mcm.userid = u.id
+                 WHERE u.deleted = 0
               GROUP BY mcm.conversationid, mcm.userid
                 HAVING COUNT(*) > 1";
         $selfconversationsrs = $DB->get_recordset_sql($sql);
@@ -3025,10 +3027,11 @@ function xmldb_main_upgrade($oldversion) {
             $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);
+            if (!$DB->record_exists('favourite', (array)$favouriterecord)) {
+                $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;
@@ -3046,8 +3049,11 @@ function xmldb_main_upgrade($oldversion) {
 
         // 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);
+        $sql = "SELECT mr.*
+                  FROM {message_read} mr
+            INNER JOIN {user} u ON mr.useridfrom = u.id
+                 WHERE mr.useridfrom = mr.useridto AND mr.notification = 0 AND u.deleted = 0";
+        $legacyselfmessagesrs = $DB->get_recordset_sql($sql);
         foreach ($legacyselfmessagesrs as $message) {
             // Get the self-conversation or create and star it if doesn't exist.
             $conditions = [
@@ -3082,10 +3088,11 @@ function xmldb_main_upgrade($oldversion) {
                 $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);
+                if (!$DB->record_exists('favourite', (array)$favouriterecord)) {
+                    $favouriterecord->timecreated = time();
+                    $favouriterecord->timemodified = $favouriterecord->timecreated;
+                    $DB->insert_record('favourite', $favouriterecord);
+                }
             }
 
             // Create the object we will be inserting into the database.
@@ -3132,7 +3139,7 @@ function xmldb_main_upgrade($oldversion) {
         // Get all the users without a self-conversation.
         $sql = "SELECT u.id
                   FROM {user} u
-                  WHERE u.id NOT IN (SELECT mcm.userid
+                  WHERE u.deleted = 0 AND 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 = ?
@@ -3217,5 +3224,51 @@ function xmldb_main_upgrade($oldversion) {
         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);
+    }
+
+    if ($oldversion < 2019042300.03) {
+
+        // Add new customdata field to message table.
+        $table = new xmldb_table('message');
+        $field = new xmldb_field('customdata', XMLDB_TYPE_TEXT, null, null, null, null, null, 'eventtype');
+
+        // Conditionally launch add field output.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Add new customdata field to notifications and messages table.
+        $table = new xmldb_table('notifications');
+        $field = new xmldb_field('customdata', XMLDB_TYPE_TEXT, null, null, null, null, null, 'timecreated');
+
+        // Conditionally launch add field output.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        $table = new xmldb_table('messages');
+        // Conditionally launch add field output.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019042300.03);
+    }
+
     return true;
 }
index 64ebe3a..9a3f3bf 100644 (file)
@@ -10720,7 +10720,7 @@ window.tinymce.dom.Sizzle = Sizzle;
 
                                // Nodes needs to be attached to something in WebKit/Opera
                                // Older builds of Opera crashes if you attach the node to an document created dynamically
-                               // and since we can't feature detect a crash we need to sniff the acutal build number
+                               // and since we can't feature detect a crash we need to sniff the actual build number
                                // This fix will make DOM ranges and make Sizzle happy!
                                impl = node.ownerDocument.implementation;
                                if (impl.createHTMLDocument) {
index 1a3141b..16d17fb 100644 (file)
@@ -470,7 +470,7 @@ function file_prepare_draft_area(&$draftitemid, $contextid, $component, $fileare
  * @param   array   $options
  *          bool    $options.forcehttps Force the user of https
  *          bool    $options.reverse Reverse the behaviour of the function
- *          bool    $options.includetoken Use a token for authentication
+ *          mixed   $options.includetoken Use a token for authentication. True for current user, int value for other user id.
  *          string  The processed text.
  */
 function file_rewrite_pluginfile_urls($text, $file, $contextid, $component, $filearea, $itemid, array $options=null) {
@@ -483,7 +483,8 @@ function file_rewrite_pluginfile_urls($text, $file, $contextid, $component, $fil
 
     $baseurl = "{$CFG->wwwroot}/{$file}";
     if (!empty($options['includetoken'])) {
-        $token = get_user_key('core_files', $USER->id);
+        $userid = $options['includetoken'] === true ? $USER->id : $options['includetoken'];
+        $token = get_user_key('core_files', $userid);
         $finalfile = basename($file);
         $tokenfile = "token{$finalfile}";
         $file = substr($file, 0, strlen($file) - strlen($finalfile)) . $tokenfile;
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..7195401 100644 (file)
@@ -166,8 +166,9 @@ class MoodleQuickForm_select extends HTML_QuickForm_select implements templatabl
     */
     function exportValue(&$submitValues, $assoc = false)
     {
+        $emptyvalue = $this->getMultiple() ? [] : null;
         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 1dcc437..203779f 100644 (file)
@@ -165,6 +165,7 @@ function message_send(\core\message\message $eventdata) {
         $tabledata->fullmessagehtml = $eventdata->fullmessagehtml;
         $tabledata->smallmessage = $eventdata->smallmessage;
         $tabledata->timecreated = time();
+        $tabledata->customdata = $eventdata->customdata;
 
         // The Trusted Content system.
         // Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.
@@ -267,6 +268,7 @@ function message_send(\core\message\message $eventdata) {
     $tabledata->eventtype = $eventdata->name;
     $tabledata->component = $eventdata->component;
     $tabledata->timecreated = time();
+    $tabledata->customdata = $eventdata->customdata;
     if (!empty($eventdata->contexturl)) {
         $tabledata->contexturl = (string)$eventdata->contexturl;
     } else {
index cb79c92..91c0a54 100644 (file)
@@ -207,7 +207,8 @@ class user_picture implements renderable {
     public $includefullname = false;
 
     /**
-     * @var bool Include user authentication token.
+     * @var mixed Include user authentication token. True indicates to generate a token for current user, and integer value
+     * indicates to generate a token for the user whose id is the value indicated.
      */
     public $includetoken = false;
 
index 4845832..437a1f3 100644 (file)
@@ -2434,7 +2434,7 @@ class core_renderer extends renderer_base {
      *     - class = image class attribute (default 'userpicture')
      *     - visibletoscreenreaders=true (whether to be visible to screen readers)
      *     - includefullname=false (whether to include the user's full name together with the user picture)
-     *     - includetoken = false
+     *     - includetoken = false (whether to use a token for authentication. True for current user, int value for other user id)
      * @return string HTML fragment
      */
     public function user_picture(stdClass $user, array $options = null) {
index 22fa4f7..a465c7d 100644 (file)
@@ -659,7 +659,7 @@ function question_move_question_tags_to_new_context(array $questions, context $n
 /**
  * This function should be considered private to the question bank, it is called from
  * question/editlib.php question/contextmoveq.php and a few similar places to to the
- * work of acutally moving questions and associated data. However, callers of this
+ * work of actually moving questions and associated data. However, callers of this
  * function also have to do other work, which is why you should not call this method
  * directly from outside the questionbank.
  *
index 78c72e1..8be665f 100644 (file)
@@ -198,6 +198,16 @@ class behat_data_generators extends behat_base {
             'required' => array('user', 'contact'),
             'switchids' => array('user' => 'userid', 'contact' => 'contactid')
         ),
+        'private messages' => array(
+            'datagenerator' => 'private_messages',
+            'required' => array('user', 'contact', 'message'),
+            'switchids' => array('user' => 'userid', 'contact' => 'contactid')
+        ),
+        'favourite conversations' => array(
+            'datagenerator' => 'favourite_conversations',
+            'required' => array('user', 'contact'),
+            'switchids' => array('user' => 'userid', 'contact' => 'contactid')
+        ),
     );
 
     /**
@@ -876,4 +886,38 @@ class behat_data_generators extends behat_base {
         }
         return $id;
     }
+
+    /**
+     * Send a new message from user to contact in a private conversation
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function process_private_messages(array $data) {
+        if (!$conversationid = \core_message\api::get_conversation_between_users([$data['userid'], $data['contactid']])) {
+            $conversation = \core_message\api::create_conversation(
+                \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+                [$data['userid'], $data['contactid']]
+            );
+            $conversationid = $conversation->id;
+        }
+        \core_message\api::send_message_to_conversation($data['userid'], $conversationid, $data['message'], FORMAT_PLAIN);
+    }
+
+    /**
+     * Mark a private conversation as favourite for user
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function process_favourite_conversations(array $data) {
+        if (!$conversationid = \core_message\api::get_conversation_between_users([$data['userid'], $data['contactid']])) {
+            $conversation = \core_message\api::create_conversation(
+                \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+                [$data['userid'], $data['contactid']]
+            );
+            $conversationid = $conversation->id;
+        }
+        \core_message\api::set_favourite_conversation($conversationid, $data['userid']);
+    }
 }
index 8adb430..a1b428a 100644 (file)
@@ -1150,6 +1150,19 @@ EOF;
 
         // Compare the final text is the same that the original.
         $this->assertEquals($originaltext, $finaltext);
+
+        // Now indicates a user different than $USER.
+        $user = $this->getDataGenerator()->create_user();
+        $options = ['includetoken' => $user->id];
+
+        // Rewrite the content. This will generate a new token.
+        $finaltext = file_rewrite_pluginfile_urls(
+                $originaltext, 'pluginfile.php', $syscontext->id, 'user', 'private', 0, $options);
+
+        $token = get_user_key('core_files', $user->id);
+        $expectedurl = new \moodle_url("/tokenpluginfile.php/{$token}/{$syscontext->id}/user/private/0/image.png");
+        $expectedtext = "Fake test with an image <img src=\"{$expectedurl}\">";
+        $this->assertEquals($expectedtext, $finaltext);
     }
 
     /**
index 9bc529e..bb42547 100644 (file)
@@ -202,6 +202,7 @@ class core_messagelib_testcase extends advanced_testcase {
         $message->fullmessagehtml = '<p>message body</p>';
         $message->smallmessage = 'small message';
         $message->notification = '0';
+        $message->customdata = ['datakey' => 'data'];
 
         $sink = $this->redirectMessages();
         $this->setCurrentTimeStart();
@@ -218,6 +219,12 @@ class core_messagelib_testcase extends advanced_testcase {
         $this->assertEquals($message->smallmessage, $savedmessage->smallmessage);
         $this->assertEquals($message->smallmessage, $savedmessage->smallmessage);
         $this->assertEquals($message->notification, $savedmessage->notification);
+        $this->assertEquals($message->customdata, $savedmessage->customdata);
+        $this->assertContains('datakey', $savedmessage->customdata);
+        // Check it was a unserialisable json.
+        $customdata = json_decode($savedmessage->customdata);
+        $this->assertEquals('data', $customdata->datakey);
+        $this->assertEquals(1, $customdata->courseid);
         $this->assertTimeCurrent($savedmessage->timecreated);
         $record = $DB->get_record('messages', array('id' => $savedmessage->id), '*', MUST_EXIST);
         unset($savedmessage->useridto);
index 077a1ff..958a70b 100644 (file)
@@ -113,7 +113,7 @@ class core_outputcomponents_testcase extends advanced_testcase {
     }
 
     public function test_get_url() {
-        global $DB, $CFG;
+        global $DB, $CFG, $USER;
 
         $this->resetAfterTest();
 
@@ -219,6 +219,18 @@ class core_outputcomponents_testcase extends advanced_testcase {
         $up1 = new user_picture($user1);
         $this->assertSame($CFG->wwwroot.'/pluginfile.php/'.$context1->id.'/user/icon/boost/f2?rev=11', $up1->get_url($page, $renderer)->out(false));
 
+        // Uploaded image with token-based access for current user.
+        $up1 = new user_picture($user1);
+        $up1->includetoken = true;
+        $token = get_user_key('core_files', $USER->id);
+        $this->assertSame($CFG->wwwroot.'/tokenpluginfile.php/'.$token.'/'.$context1->id.'/user/icon/boost/f2?rev=11', $up1->get_url($page, $renderer)->out(false));
+
+        // Uploaded image with token-based access for other user.
+        $up1 = new user_picture($user1);
+        $up1->includetoken = $user2->id;
+        $token = get_user_key('core_files', $user2->id);
+        $this->assertSame($CFG->wwwroot.'/tokenpluginfile.php/'.$token.'/'.$context1->id.'/user/icon/boost/f2?rev=11', $up1->get_url($page, $renderer)->out(false));
+
         // Https version.
         $CFG->wwwroot = str_replace('http:', 'https:', $CFG->wwwroot);
 
index 158187b..d73a1fe 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,7 +25,7 @@ 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.
@@ -42,6 +41,13 @@ 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
+* `$includetoken` parameter type has been changed. Now supports:
+   boolean: False indicates to not include the token, true indicates to generate a token for the current user ($USER).
+   integer: Indicates to generate a token for the user whose id is the integer value.
+* The following functions have been updated to support the new usage:
+    - make_pluginfile_url
+    - file_rewrite_pluginfile_urls
 
 === 3.6 ===
 
index 3199a1d..73fbcf9 100644 (file)
@@ -773,7 +773,9 @@ class moodle_url {
      * @param string $pathname
      * @param string $filename
      * @param bool $forcedownload
-     * @param boolean $includetoken Whether to use a user token when displaying this group image.
+     * @param mixed $includetoken Whether to use a user token when displaying this group image.
+     *                True indicates to generate a token for current user, and integer value indicates to generate a token for the
+     *                user whose id is the value indicated.
      *                If the group picture is included in an e-mail or some other location where the audience is a specific
      *                user who will not be logged in when viewing, then we use a token to authenticate the user.
      * @return moodle_url
@@ -786,7 +788,8 @@ class moodle_url {
 
         if ($includetoken) {
             $urlbase = "$CFG->wwwroot/tokenpluginfile.php";
-            $token = get_user_key('core_files', $USER->id);
+            $userid = $includetoken === true ? $USER->id : $includetoken;
+            $token = get_user_key('core_files', $userid);
             if ($CFG->slasharguments) {
                 $path[] = $token;
             }
@@ -2491,6 +2494,8 @@ function print_collapsible_region_end($return = false) {
  * @param boolean $return If false print picture, otherwise return the output as string
  * @param boolean $link Enclose image in a link to view specified course?
  * @param boolean $includetoken Whether to use a user token when displaying this group image.
+ *                True indicates to generate a token for current user, and integer value indicates to generate a token for the
+ *                user whose id is the value indicated.
  *                If the group picture is included in an e-mail or some other location where the audience is a specific
  *                user who will not be logged in when viewing, then we use a token to authenticate the user.
  * @return string|void Depending on the setting of $return
@@ -2545,6 +2550,8 @@ function print_group_picture($group, $courseid, $large = false, $return = false,
  * @param  int $courseid The course ID for the group.
  * @param  bool $large A large or small group picture? Default is small.
  * @param  boolean $includetoken Whether to use a user token when displaying this group image.
+ *                 True indicates to generate a token for current user, and integer value indicates to generate a token for the
+ *                 user whose id is the value indicated.
  *                 If the group picture is included in an e-mail or some other location where the audience is a specific
  *                 user who will not be logged in when viewing, then we use a token to authenticate the user.
  * @return moodle_url Returns the url for the group picture.
index b183404..fb3b8e7 100644 (file)
@@ -1929,7 +1929,7 @@ class api {
      */
     public static function send_message_to_conversation(int $userid, int $conversationid, string $message,
                                                         int $format) : \stdClass {
-        global $DB;
+        global $DB, $PAGE;
 
         if (!self::can_send_message_to_conversation($userid, $conversationid)) {
             throw new \moodle_exception("User $userid cannot send a message to conversation $conversationid");
@@ -1939,7 +1939,7 @@ class api {
         $eventdata->courseid         = 1;
         $eventdata->component        = 'moodle';
         $eventdata->name             = 'instantmessage';
-        $eventdata->userfrom         = $userid;
+        $eventdata->userfrom         = \core_user::get_user($userid);
         $eventdata->convid           = $conversationid;
 
         if ($format == FORMAT_HTML) {
@@ -1957,6 +1957,37 @@ class api {
 
         $eventdata->timecreated     = time();
         $eventdata->notification    = 0;
+        // Custom data for event.
+        $customdata = [
+            'actionbuttons' => [
+                'send' => get_string('send', 'message'),
+            ],
+            'placeholders' => [
+                'send' => get_string('writeamessage', 'message'),
+            ],
+        ];
+
+        $conv = $DB->get_record('message_conversations', ['id' => $conversationid]);
+        if ($conv->type == self::MESSAGE_CONVERSATION_TYPE_GROUP) {
+            $convextrafields = self::get_linked_conversation_extra_fields([$conv]);
+            // Conversation image.
+            $imageurl = isset($convextrafields[$conv->id]) ? $convextrafields[$conv->id]['imageurl'] : null;
+            if ($imageurl) {
+                $customdata['notificationiconurl'] = $imageurl;
+            }
+            // Conversation name.
+            if (is_null($conv->contextid)) {
+                $convcontext = \context_user::instance($userid);
+            } else {
+                $convcontext = \context::instance_by_id($conv->contextid);
+            }
+            $customdata['conversationname'] = format_string($conv->name, true, ['context' => $convcontext]);
+        } else if ($conv->type == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
+            $userpicture = new \user_picture($eventdata->userfrom);
+            $customdata['notificationiconurl'] = $userpicture->get_url($PAGE)->out(false);
+        }
+        $eventdata->customdata = $customdata;
+
         $messageid = message_send($eventdata);
 
         $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage,
@@ -2578,7 +2609,7 @@ class api {
      * @return \stdClass the request
      */
     public static function create_contact_request(int $userid, int $requesteduserid) : \stdClass {
-        global $DB;
+        global $DB, $PAGE;
 
         $request = new \stdClass();
         $request->userid = $userid;
@@ -2609,6 +2640,15 @@ class api {
         $message->fullmessagehtml = $fullmessage;
         $message->smallmessage = '';
         $message->contexturl = $url->out(false);
+        $userpicture = new \user_picture($userfrom);
+        $userpicture->includetoken = $userto->id; // Generate an out-of-session token for the user receiving the message.
+        $message->customdata = [
+            'notificationiconurl' => $userpicture->get_url($PAGE)->out(false),
+            'actionbuttons' => [
+                'accept' => get_string_manager()->get_string('accept', 'moodle', null, $userto->lang),
+                'reject' => get_string_manager()->get_string('reject', 'moodle', null, $userto->lang),
+            ],
+        ];
 
         message_send($message);
 
index 2bba6da..07e204e 100644 (file)
@@ -73,7 +73,8 @@ class provider implements
                 'fullmessageformat' => 'privacy:metadata:messages:fullmessageformat',
                 'fullmessagehtml' => 'privacy:metadata:messages:fullmessagehtml',
                 'smallmessage' => 'privacy:metadata:messages:smallmessage',
-                'timecreated' => 'privacy:metadata:messages:timecreated'
+                'timecreated' => 'privacy:metadata:messages:timecreated',
+                'customdata' => 'privacy:metadata:messages:customdata',
             ],
             'privacy:metadata:messages'
         );
@@ -155,6 +156,7 @@ class provider implements
                 'contexturlname' => 'privacy:metadata:notifications:contexturlname',
                 'timeread' => 'privacy:metadata:notifications:timeread',
                 'timecreated' => 'privacy:metadata:notifications:timecreated',
+                'customdata' => 'privacy:metadata:notifications:customdata',
             ],
             'privacy:metadata:notifications'
         );
@@ -930,7 +932,8 @@ class provider implements
                 'issender' => transform::yesno($issender),
                 'message' => message_format_message_text($message),
                 'timecreated' => transform::datetime($message->timecreated),
-                'timeread' => $timeread
+                'timeread' => $timeread,
+                'customdata' => $message->customdata,
             ];
             if ($conversation->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP && !$issender) {
                 // Only export sender for group conversations when is not the current user.
@@ -1042,7 +1045,8 @@ class provider implements
                 'contexturl' => $notification->contexturl,
                 'contexturlname' => $notification->contexturlname,
                 'timeread' => $timeread,
-                'timecreated' => transform::datetime($notification->timecreated)
+                'timecreated' => transform::datetime($notification->timecreated),
+                'customdata' => $notification->customdata,
             ];
 
             $notificationdata[] = $data;
index f7edd53..4f23efa 100644 (file)
@@ -3208,7 +3208,12 @@ class core_message_external extends external_api {
                             'timecreated' => new external_value(PARAM_INT, 'Time created'),
                             'timeread' => new external_value(PARAM_INT, 'Time read'),
                             'usertofullname' => new external_value(PARAM_TEXT, 'User to full name'),
-                            'userfromfullname' => new external_value(PARAM_TEXT, 'User from full name')
+                            'userfromfullname' => new external_value(PARAM_TEXT, 'User from full name'),
+                            'component' => new external_value(PARAM_TEXT, 'The component that generated the notification',
+                                VALUE_OPTIONAL),
+                            'eventtype' => new external_value(PARAM_TEXT, 'The type of notification', VALUE_OPTIONAL),
+                            'customdata' => new external_value(PARAM_RAW, 'Custom data to be passed to the message processor.
+                                The data here is serialised using json_encode().', VALUE_OPTIONAL),
                         ), 'message'
                     )
                 ),
index b98190f..9493f9e 100644 (file)
@@ -325,7 +325,7 @@ function message_format_contexturl($message) {
  * @return int|false the ID of the new message or false
  */
 function message_post_message($userfrom, $userto, $message, $format) {
-    global $SITE, $CFG, $USER;
+    global $PAGE;
 
     $eventdata = new \core\message\message();
     $eventdata->courseid         = 1;
@@ -351,6 +351,18 @@ function message_post_message($userfrom, $userto, $message, $format) {
     $eventdata->smallmessage     = $message;//store the message unfiltered. Clean up on output.
     $eventdata->timecreated     = time();
     $eventdata->notification    = 0;
+    // User image.
+    $userpicture = new user_picture($userfrom);
+    $userpicture->includetoken = $userto->id; // Generate an out-of-session token for the user receiving the message.
+    $eventdata->customdata = [
+        'notificationiconurl' => $userpicture->get_url($PAGE)->out(false),
+        'actionbuttons' => [
+            'send' => get_string_manager()->get_string('send', 'message', null, $eventdata->userto->lang),
+        ],
+        'placeholders' => [
+            'send' => get_string_manager()->get_string('writeamessage', 'message', null, $eventdata->userto->lang),
+        ],
+    ];
     return message_send($eventdata);
 }
 
index 72e6b29..d0d3a54 100644 (file)
@@ -129,7 +129,7 @@ class message_airnotifier_external_testcase extends externallib_advanced_testcas
         $expected = array(
             array(
                 'userid' => $user1->id,
-                'configured' => 0
+                'configured' => 1
             )
         );
         $this->assertEquals($expected, $preferences['users']);
index b384cc6..2f6281b 100644 (file)
@@ -72,7 +72,7 @@ class api {
                        n.subject, n.fullmessage, n.fullmessageformat,
                        n.fullmessagehtml, n.smallmessage, n.contexturl,
                        n.contexturlname, n.timecreated, n.component,
-                       n.eventtype, n.timeread
+                       n.eventtype, n.timeread, n.customdata
                   FROM {notifications} n
                  WHERE n.id IN (SELECT notificationid FROM {message_popup_notifications})
                    AND n.useridto = ?
index ffbc092..5925b27 100644 (file)
@@ -165,6 +165,8 @@ class message_popup_external extends external_api {
                             'component' => new external_value(PARAM_TEXT, 'The component that generated the notification',
                                 VALUE_OPTIONAL),
                             'eventtype' => new external_value(PARAM_TEXT, 'The type of notification', VALUE_OPTIONAL),
+                            'customdata' => new external_value(PARAM_RAW, 'Custom data to be passed to the message processor.
+                                The data here is serialised using json_encode().', VALUE_OPTIONAL),
                         ), 'message'
                     )
                 ),
index 1a45188..840a68f 100644 (file)
@@ -49,6 +49,7 @@ trait message_popup_test_helper {
         $record->fullmessage = $message;
         $record->smallmessage = $message;
         $record->timecreated = $timecreated ? $timecreated : time();
+        $record->customdata  = json_encode(['datakey' => 'data']);
 
         $id = $DB->insert_record('notifications', $record);
 
index a8ca638..94fe0db 100644 (file)
@@ -96,6 +96,15 @@ class message_popup_externallib_testcase extends advanced_testcase {
         $this->setAdminUser();
         $result = message_popup_external::get_popup_notifications($recipient->id, false, 0, 0);
         $this->assertCount(4, $result['notifications']);
+        // Check we receive custom data as a unserialisable json.
+        $found = 0;
+        foreach ($result['notifications'] as $notification) {
+            if (!empty($notification->customdata)) {
+                $this->assertObjectHasAttribute('datakey', json_decode($notification->customdata));
+                $found++;
+            }
+        }
+        $this->assertEquals(2, $found);
 
         $this->setUser($recipient);
         $result = message_popup_external::get_popup_notifications($recipient->id, false, 0, 0);
index ea355c2..b3ab9cd 100644 (file)
@@ -44,7 +44,6 @@
             <img
                 class="rounded-circle"
                 src="{{profileimageurl}}"
-                aria-hidden="true"
                 alt="{{#str}} pictureof, moodle, {{fullname}} {{/str}}"
                 title="{{#str}} pictureof, moodle, {{fullname}} {{/str}}"
                 style="height: 100px; width: 100px"
index c584e01..54129dc 100644 (file)
     style="overflow-y: auto; overflow-x: hidden"
 >
     <div class="position-relative h-100" data-region="content-container" style="overflow-y: auto; overflow-x: hidden">
-        <div class="p-3 text-center hidden" data-region="self-conversation-message-container">
-            <p class="m-0">{{#str}} selfconversation, core_message {{/str}}</p>
-            <p class="font-italic font-weight-light" data-region="text">{{#str}} selfconversationdefaultmessage, core_message {{/str}}</p>
-        </div>
-        <div class="p-3 text-center hidden" data-region="contact-request-sent-message-container">
-            <p class="m-0">{{#str}} contactrequestsent, core_message {{/str}}</p>
-            <p class="font-italic font-weight-light" data-region="text"></p>
-        </div>
-        <div class="content-message-container hidden h-100 px-2 pb-2 pt-0" data-region="content-message-container" role="log" style="overflow-y: auto; overflow-x: hidden">
+        <div class="content-message-container hidden h-100 px-2 pt-0" data-region="content-message-container" role="log" style="overflow-y: auto; overflow-x: hidden">
+            <div class="py-3 bg-light sticky-top border-bottom text-center hidden" data-region="contact-request-sent-message-container">
+                <p class="m-0">{{#str}} contactrequestsent, core_message {{/str}}</p>
+                <p class="font-italic font-weight-light" data-region="text"></p>
+            </div>
+            <div class="p-3 bg-light text-center hidden" data-region="self-conversation-message-container">
+                <p class="m-0">{{#str}} selfconversation, core_message {{/str}}</p>
+                <p class="font-italic font-weight-light" data-region="text">{{#str}} selfconversationdefaultmessage, core_message {{/str}}</p>
+           </div>
             <div class="hidden text-center p-3" data-region="more-messages-loading-icon-container">{{> core/loading }}</div>
         </div>
         <div class="p-4 w-100 h-100 hidden position-absolute" data-region="confirm-dialogue-container" style="top: 0; background: rgba(0,0,0,0.3);">
index 9f63c26..35555c4 100644 (file)
@@ -63,7 +63,8 @@
                     aria-label="{{#str}} favourites, core {{/str}}">
                         {{#pix}} i/star-rating, core {{/pix}}
                     </span>
-                    <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                    <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container"
+                    aria-label="{{#str}} mutedconversation, core_message {{/str}}">
                         {{#pix}} i/muted, core {{/pix}}
                     </span>
                 </div>
         </a>
     </div>
     <div class="ml-auto dropdown">
-        <button class="btn btn-link btn-icon icon-size-3" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        <button id="conversation-actions-menu-button" class="btn btn-link btn-icon icon-size-3" type="button"
+        data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
+        aria-label="{{#str}} conversationactions, core_message {{/str}}" aria-controls="conversation-actions-menu">
             {{#pix}} i/moremenu, core {{/pix}}
         </button>
-        <div class="dropdown-menu float-right">
-            <a class="dropdown-item" href="#" data-action="view-contact">
+        <div id="conversation-actions-menu" class="dropdown-menu float-right" role="menu"
+        aria-labelledby="conversation-actions-menu-button">
+            <a class="dropdown-item" href="#" data-action="view-contact" role="menuitem">
                 {{#str}} info, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-favourite">
+            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#"
+            data-action="confirm-favourite" role="menuitem">
                 {{#str}} addtofavourites, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-unfavourite">
+            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#"
+            data-action="confirm-unfavourite" role="menuitem">
                 {{#str}} removefromfavourites, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#isblocked}}hidden{{/isblocked}}" href="#" data-action="request-block">
+            <a class="dropdown-item {{#isblocked}}hidden{{/isblocked}}" href="#" data-action="request-block" role="menuitem">
                 {{#str}} blockuser, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^isblocked}}hidden{{/isblocked}}" href="#" data-action="request-unblock">
+            <a class="dropdown-item {{^isblocked}}hidden{{/isblocked}}" href="#" data-action="request-unblock" role="menuitem">
                 {{#str}} unblockuser, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute">
+            <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute" role="menuitem">
                 {{#str}} muteconversation, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute">
+            <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute" role="menuitem">
                 {{#str}} unmuteconversation, core_message {{/str}}
             </a>
-            <a class="dropdown-item" href="#" data-action="request-delete-conversation">
+            <a class="dropdown-item" href="#" data-action="request-delete-conversation" role="menuitem">
                 {{#str}} deleteconversation, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#iscontact}}hidden{{/iscontact}}" href="#" data-action="request-add-contact">
+            <a class="dropdown-item {{#iscontact}}hidden{{/iscontact}}" href="#" data-action="request-add-contact" role="menuitem">
                 {{#str}} addtoyourcontacts, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^iscontact}}hidden{{/iscontact}}" href="#" data-action="request-remove-contact">
+            <a class="dropdown-item {{^iscontact}}hidden{{/iscontact}}" href="#" data-action="request-remove-contact"
+            role="menuitem">
                 {{#str}} removefromyourcontacts, core_message {{/str}}
             </a>
         </div>
     </div>
-</div>
\ No newline at end of file
+</div>
index 7a9a0f2..95eaac6 100644 (file)
@@ -62,7 +62,8 @@
                 aria-label="{{#str}} favourites, core {{/str}}">
                     {{#pix}} i/star-rating, core {{/pix}}
                 </span>
-                <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container"
+                aria-label="{{#str}} mutedconversation, core_message {{/str}}">
                     {{#pix}} i/muted, core {{/pix}}
                 </span>
             </div>
index f3a19a6..3000029 100644 (file)
@@ -62,7 +62,8 @@
                         data-region="favourite-icon-container" aria-label="{{#str}} favourites, core {{/str}}">
                             {{#pix}} i/star-rating, core {{/pix}}
                         </span>
-                        <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                        <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container"
+                        aria-label="{{#str}} mutedconversation, core_message {{/str}}">
                             {{#pix}} i/muted, core {{/pix}}
                         </span>
                     </div>
             </a>
         </div>
         <div class="ml-auto dropdown">
-            <button class="btn btn-link btn-icon icon-size-3" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <button id="conversation-actions-menu-button" class="btn btn-link btn-icon icon-size-3" type="button"
+            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
+            aria-label="{{#str}} conversationactions, core_message {{/str}}" aria-controls="conversation-actions-menu">
                 {{#pix}} i/moremenu, core {{/pix}}
             </button>
-            <div class="dropdown-menu float-right">
-                <a class="dropdown-item" href="#" data-action="view-group-info">
+            <div id="conversation-actions-menu" class="dropdown-menu float-right" role="menu"
+            aria-labelledby="conversation-actions-menu-button">
+                <a class="dropdown-item" href="#" data-action="view-group-info" role="menuitem">
                     {{#str}} groupinfo, core_message {{/str}}
                 </a>
-                <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-favourite">
+                <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-favourite"
+                role="menuitem">
                     {{#str}} addtofavourites, core_message {{/str}}
                 </a>
-                <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-unfavourite">
+                <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-unfavourite"
+                role="menuitem">
                     {{#str}} removefromfavourites, core_message {{/str}}
                 </a>
-                <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute">
+                <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute"
+                role="menuitem">
                     {{#str}} muteconversation, core_message {{/str}}
                 </a>
-                <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute">
+                <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute"
+                role="menuitem">
                     {{#str}} unmuteconversation, core_message {{/str}}
                 </a>
             </div>
             </small>
         </a>
     </div>
-</div>
\ No newline at end of file
+</div>
index 3470764..31df078 100644 (file)
@@ -38,7 +38,8 @@
 <div class="d-flex p-2 align-items-center">
     {{#str}} messagesselected:, core_message {{/str}}
     <span class="ml-1" data-region="message-selected-court">1</span>
-    <button type="button" class="ml-auto close" aria-label="" data-action="cancel-edit-mode">
-        <span aria-hidden="true">&times;</span>
+    <button type="button" class="ml-auto close" aria-label="{{#str}} cancelselection, core_message {{/str}}"
+        data-action="cancel-edit-mode">
+            <span aria-hidden="true">&times;</span>
     </button>
 </div>
index f86a2ff..4e9f4e0 100644 (file)
@@ -4955,7 +4955,15 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
 
+        $sink = $this->redirectMessages();
         $request = \core_message\api::create_contact_request($user1->id, $user2->id);
+        $messages = $sink->get_messages();
+        $sink->close();
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
+        $this->assertObjectHasAttribute('actionbuttons', $customdata);
+        $this->assertCount(2, (array) $customdata->actionbuttons);
 
         $this->assertEquals($user1->id, $request->userid);
         $this->assertEquals($user2->id, $request->requesteduserid);
@@ -6007,8 +6015,17 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Send a message to an individual conversation.
         $sink = $this->redirectEvents();
+        $messagessink = $this->redirectMessages();
         $message1 = \core_message\api::send_message_to_conversation($user1->id, $ic1->id, 'this is a message', FORMAT_MOODLE);
         $events = $sink->get_events();
+        $messages = $messagessink->get_messages();
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
+        $this->assertObjectHasAttribute('actionbuttons', $customdata);
+        $this->assertCount(1, (array) $customdata->actionbuttons);
+        $this->assertObjectHasAttribute('placeholders', $customdata);
+        $this->assertCount(1, (array) $customdata->placeholders);
 
         // Verify the message returned.
         $this->assertInstanceOf(\stdClass::class, $message1);
@@ -6048,15 +6065,23 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Send a message to a group conversation.
         $sink = $this->redirectEvents();
+        $messagessink = $this->redirectMessages();
         $message1 = \core_message\api::send_message_to_conversation($user1->id, $gc2->id, 'message to the group', FORMAT_MOODLE);
         $events = $sink->get_events();
-
+        $messages = $messagessink->get_messages();
         // Verify the message returned.
         $this->assertInstanceOf(\stdClass::class, $message1);
         $this->assertObjectHasAttribute('id', $message1);
         $this->assertAttributeEquals($user1->id, 'useridfrom', $message1);
         $this->assertAttributeEquals('message to the group', 'text', $message1);
         $this->assertObjectHasAttribute('timecreated', $message1);
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertObjectHasAttribute('actionbuttons', $customdata);
+        $this->assertCount(1, (array) $customdata->actionbuttons);
+        $this->assertObjectHasAttribute('placeholders', $customdata);
+        $this->assertCount(1, (array) $customdata->placeholders);
+        $this->assertObjectNotHasAttribute('notificationiconurl', $customdata);    // No group image means no image.
 
         // Verify events. Note: the event is a message read event because of an if (PHPUNIT) conditional within message_send(),
         // however, we can still determine the number and ids of any recipients this way.
@@ -6067,6 +6092,66 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertContains($user4->id, $userids);
     }
 
+    /**
+     * Test verifying that messages can be sent to existing linked group conversations.
+     */
+    public function test_send_message_to_conversation_linked_group_conversation() {
+        global $CFG;
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a group with a linked conversation and a valid image.
+        $this->setAdminUser();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id);
+        $group = $this->getDataGenerator()->create_group([
+            'courseid' => $course->id,
+            'enablemessaging' => 1,
+            'picturepath' => $CFG->dirroot . '/lib/tests/fixtures/gd-logo.png'
+        ]);
+
+        // Add users to group.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user2->id));
+
+        // Verify the group with the image works as expected.
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertEquals(2, $conversations[0]->membercount);
+        $this->assertEquals($course->shortname, $conversations[0]->subname);
+        $groupimageurl = get_group_picture_url($group, $group->courseid, true);
+        $this->assertEquals($groupimageurl, $conversations[0]->imageurl);
+
+        // Redirect messages.
+        // This marks messages as read, but we can still observe and verify the number of conversation recipients,
+        // based on the message_viewed events generated as part of marking the message as read for each user.
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        // Send a message to a group conversation.
+        $messagessink = $this->redirectMessages();
+        $message1 = \core_message\api::send_message_to_conversation($user1->id, $conversations[0]->id,
+            'message to the group', FORMAT_MOODLE);
+        $messages = $messagessink->get_messages();
+        // Verify the message returned.
+        $this->assertInstanceOf(\stdClass::class, $message1);
+        $this->assertObjectHasAttribute('id', $message1);
+        $this->assertAttributeEquals($user1->id, 'useridfrom', $message1);
+        $this->assertAttributeEquals('message to the group', 'text', $message1);
+        $this->assertObjectHasAttribute('timecreated', $message1);
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
+        $this->assertEquals($groupimageurl, $customdata->notificationiconurl);
+        $this->assertEquals($group->name, $customdata->conversationname);
+
+    }
+
     /**
      * Test verifying that messages cannot be sent to conversations that don't exist.
      */
index 90f325d..935a366 100644 (file)
@@ -82,11 +82,18 @@ class behat_message extends behat_base {
 
         $this->execute('behat_general::i_click_on_in_the',
             array(
-                "//button[@data-action='view-contact-profile']
-                [contains(normalize-space(.), '" . $this->escape($userfullname) . "')]",
-                'xpath_element',
-                ".messages-header",
-                "css_element",
+                "//a[@data-action='view-contact']",
+                "xpath_element",
+                "//*[@data-region='message-drawer']//div[@data-region='header-container']",
+                "xpath_element",
+            )
+        );
+        $this->execute('behat_general::i_click_on_in_the',
+            array(
+                $this->escape($userfullname),
+                "link",
+                "//*[@data-region='message-drawer']//*[@data-region='view-contact']",
+                "xpath_element",
             )
         );
 
@@ -232,4 +239,21 @@ class behat_message extends behat_base {
             $this->escape($convname).'")]';
         $this->execute('behat_general::i_click_on', array($xpath, 'xpath_element'));
     }
+
+    /**
+     * Open the settings preferences.
+     *
+     * @Given /^I open messaging settings preferences$/
+     */
+    public function i_open_messaging_settings_preferences() {
+        $this->execute('behat_general::wait_until_the_page_is_ready');
+        $this->execute('behat_general::i_click_on',
+            array(
+                '//*[@data-region="message-drawer"]//a[@data-route="view-settings"]',
+                'xpath_element',
+                '',
+                '',
+            )
+        );
+    }
 }
diff --git a/message/tests/behat/favourite_conversations.feature b/message/tests/behat/favourite_conversations.feature
new file mode 100644 (file)
index 0000000..3babfe4
--- /dev/null
@@ -0,0 +1,95 @@
+@core @core_message @javascript
+Feature: Star and unstar conversations
+  In order to manage a course group in a course
+  As a user
+  I need to be able to star and unstar conversations
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role |
+      | student1 | C1     | student |
+      | student2 | C1     | student |
+    And the following "groups" exist:
+      | name    | course | idnumber | enablemessaging |
+      | Group 1 | C1     | G1       | 1               |
+    And the following "group members" exist:
+      | user     | group |
+      | student1 | G1 |
+      | student2 | G1 |
+    And the following config values are set as admin:
+      | messaging | 1 |
+
+  Scenario: Star a group conversation
+    Given I log in as "student1"
+    Then I open messaging
+    And "Group 1" "group_message" should exist
+    And I select "Group 1" conversation in messaging
+    And I open contact menu
+    And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    And I go back in "view-conversation" message drawer
+    And I open the "Starred" conversations list
+    And I should see "Group 1" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I open the "Group" conversations list
+    And I should not see "Group 1" in the "//div[@data-region='view-overview-group-messages']" "xpath_element"
+
+  Scenario: Unstar a group conversation
+    Given I log in as "student1"
+    Then I open messaging
+    And "Group 1" "group_message" should exist
+    And I select "Group 1" conversation in messaging
+    And I open contact menu
+    And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    And I go back in "view-conversation" message drawer
+    And I open the "Starred" conversations list
+    And I should see "Group 1" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I select "Group 1" conversation in messaging
+    And I open contact menu
+    And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    And I go back in "view-conversation" message drawer
+    And I open the "Starred" conversations list
+    And I should not see "Group 1" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I open the "Group" conversations list
+    And I should see "Group 1" in the "//div[@data-region='view-overview-group-messages']" "xpath_element"
+
+  Scenario: Star a private conversation
+    Given the following "private messages" exist:
+      | user     | contact  | message |
+      | student1 | student2 | Hi!     |
+    Then I log in as "student1"
+    And I open messaging
+    And I open the "Private" conversations list
+    And "Student 2" "group_message" should exist
+    And I select "Student 2" conversation in messaging
+    And I open contact menu
+    And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    And I go back in "view-conversation" message drawer
+    And I open the "Starred" conversations list
+    And I should see "Student 2" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I open the "Private" conversations list
+    And I should not see "Student 2" in the "//div[@data-region='view-overview-messages']" "xpath_element"
+
+  Scenario: Unstar a private conversation
+    Given the following "private messages" exist:
+      | user     | contact  | message |
+      | student1 | student2 | Hi!     |
+    Given the following "favourite conversations" exist:
+      | user     | contact  |
+      | student1 | student2 |
+    Then I log in as "student1"
+    And I open messaging
+    And I should see "Student 2" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I select "Student 2" conversation in messaging
+    And I open contact menu
+    And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    And I go back in "view-conversation" message drawer
+    And I open the "Starred" conversations list
+    And I should not see "Group 1" in the "//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I open the "Private" conversations list
+    And I should see "Student 2" in the "//div[@data-region='view-overview-messages']" "xpath_element"
\ No newline at end of file
diff --git a/message/tests/behat/message_delete_conversation.feature b/message/tests/behat/message_delete_conversation.feature
new file mode 100644 (file)
index 0000000..630a5db
--- /dev/null
@@ -0,0 +1,116 @@
+@core @core_message @javascript
+Feature: Message delete conversations
+  In order to communicate with fellow users
+  As a user
+  I need to be able to delete conversations
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+    And the following config values are set as admin:
+      | messaging         | 1 |
+      | messagingallusers | 1 |
+    And the following "private messages" exist:
+      | user     | contact  | message               |
+      | student1 | student2 | Hi!                   |
+      | student2 | student1 | What do you need?     |
+
+  Scenario: Delete a private conversation
+    And I log in as "student2"
+    And I open messaging
+    And I select "Student 1" conversation in the "messages" conversations list
+    And I open contact menu
+    And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
+#   Confirm deletion, so conversation should not be there
+    And I should see "Delete"
+    And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
+    And I should not see "Delete"
+    And I should not see "Hi!" in the "Student 1" "group_message_conversation"
+    And I should not see "What do you need?" in the "Student 1" "group_message_conversation"
+    And I should not see "##today##j F##" in the "Student 1" "group_message_conversation"
+#   Check user is deleting private conversation only for them
+    And I log out
+    And I log in as "student1"
+    And I open messaging
+    And I select "Student 2" conversation in the "messages" conversations list
+    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "What do you need?" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+
+  Scenario: Cancel deleting a private conversation
+    Given I log in as "student1"
+    And I open messaging
+    And I select "Student 2" conversation in the "messages" conversations list
+    And I open contact menu
+    And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
+#   Cancel deletion, so conversation should be there
+    And I should see "Cancel"
+    And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
+    And I should not see "Cancel"
+    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+
+  Scenario: Delete a stared conversation
+    Given the following "favourite conversations" exist:
+      | user     | contact  |
+      | student1 | student2 |
+    And I log in as "student1"
+    And I open messaging
+    And I select "Student 2" conversation in the "favourites" conversations list
+    And I open contact menu
+    And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
+#   Confirm deletion, so conversation should not be there
+    And I should see "Delete"
+    And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
+    And I should not see "Delete"
+    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "What do you need?" in the "Student 2" "group_message_conversation"
+    And I should not see "##today##j F##" in the "Student 2" "group_message_conversation"
+#   Check user is deleting private conversation only for them
+    And I log out
+    And I log in as "student2"
+    And I open messaging
+    And I select "Student 1" conversation in the "messages" conversations list
+    And I should see "Hi!" in the "Student 1" "group_message_conversation"
+    And I should see "What do you need?" in the "Student 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+
+  Scenario: Cancel deleting a stared conversation
+    Given the following "favourite conversations" exist:
+      | user     | contact  |
+      | student1 | student2 |
+    When I log in as "student1"
+    And I open messaging
+    And I select "Student 2" conversation in the "favourites" conversations list
+    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I open contact menu
+    And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
+#   Cancel deletion, so conversation should be there
+    And I should see "Cancel"
+    And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
+    And I should not see "Cancel"
+    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+
+  Scenario: Check a deleted stared conversation is still stared
+    Given the following "favourite conversations" exist:
+      | user     | contact  |
+      | student1 | student2 |
+    When I log in as "student1"
+    And I open messaging
+    And I select "Student 2" conversation in the "favourites" conversations list
+    And I open contact menu
+    And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
+    Then I should see "Delete"
+    And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
+    And I should not see "Delete"
+    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
+    And I go back in "view-conversation" message drawer
+    And I should not see "Student 2" in the "//*[@data-region='message-drawer']//div[@data-region='view-overview-favourites']" "xpath_element"
+    And I send "Hi!" message to "Student 2" user
+    And I go back in "view-conversation" message drawer
+    And I go back in "view-search" message drawer
+    And I should see "Student 2" in the "//*[@data-region='message-drawer']//div[@data-region='view-overview-favourites']" "xpath_element"
\ No newline at end of file
diff --git a/message/tests/behat/message_manage_preferences.feature b/message/tests/behat/message_manage_preferences.feature
new file mode 100644 (file)
index 0000000..964e3d2
--- /dev/null
@@ -0,0 +1,95 @@
+@core @core_message @javascript
+Feature: Manage preferences
+  In order to control whether I'm contactable
+  As a user
+  I need to be able to update my messaging preferences
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+      | student3 | Student   | 3        | student3@example.com |
+      | student4 | Student   | 4        | student4@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role |
+      | student1 | C1     | student |
+      | student2 | C1     | student |
+      | student3 | C1     | student |
+    And the following "groups" exist:
+      | name    | course | idnumber | enablemessaging |
+      | Group 1 | C1     | G1       | 1               |
+    And the following "message contacts" exist:
+      | user     | contact |
+      | student1 | student2 |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
+
+  Scenario: Allow send me a message whe you are a contact and the prefrence is my contacts only
+    Given I log in as "student1"
+    And I open messaging
+    And I open messaging settings preferences
+    When I click on "//label[text()[contains(.,'My contacts only')]]" "xpath_element"
+    And I log out
+    Then I log in as "student2"
+    And I open messaging
+    And I send "Hi!" message to "Student 1" user
+    And I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
+
+  Scenario: Not allowed to send a message if you are not contact to the sender or you are not in the same course
+    Given I log in as "student1"
+    And I open messaging
+    And I open messaging settings preferences
+    When I click on "//label[text()[contains(.,'My contacts and anyone in my courses')]]" "xpath_element"
+    And I log out
+    Then I log in as "student4"
+    And I open messaging
+    And I select "Student 1" user in messaging
+    And I should see "You are unable to message this user" in the "//*[@data-region='content-messages-footer-unable-to-message']" "xpath_element"
+
+  Scenario: Allow send me a message whe you are a contact and the prefrence is my contacts only
+    Given I log in as "student1"
+    And I open messaging
+    And I open messaging settings preferences
+    When I click on "//label[text()[contains(.,'My contacts and anyone in my courses')]]" "xpath_element"
+    And I log out
+    Then I log in as "student3"
+    And I open messaging
+    And I send "Hi!" message to "Student 1" user
+    And I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
+
+  Scenario: Allowed to send a message if you are not contact to the sender and  you are not in the same course
+    Given I log in as "student1"
+    And I open messaging
+    And I open messaging settings preferences
+    When I click on "//label[text()[contains(.,'Anyone on the site')]]" "xpath_element"
+    And I log out
+    Then I log in as "student4"
+    And I open messaging
+    And I send "Hi!" message to "Student 1" user
+    And I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
+
+  Scenario: Allow send a message using Enter button
+    Given I log in as "student1"
+    And I open messaging
+    And I select "Student 2" user in messaging
+    And I set the field with xpath "//textarea[@data-region='send-message-txt']" to "Hi!"
+    And I press key "13" in "//textarea[@data-region='send-message-txt']" "xpath_element"
+    Then I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
+
+  Scenario: No allow to send a messade using Enter button
+    Given I log in as "student1"
+    And I open messaging
+    And I open messaging settings preferences
+    When I click on "//label[text()[contains(.,'Use enter to send')]]" "xpath_element"
+    And I go back in "view-settings" message drawer
+    Then I select "Student 2" user in messaging
+    And I set the field with xpath "//textarea[@data-region='send-message-txt']" to "Hi!"
+    And I press key "13" in "//textarea[@data-region='send-message-txt']" "xpath_element"
+    And I should not see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
+    And I press "Send message"
+    And I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
diff --git a/message/tests/behat/unread_messages.feature b/message/tests/behat/unread_messages.feature
new file mode 100644 (file)
index 0000000..d1ddec0
--- /dev/null
@@ -0,0 +1,85 @@
+@core @core_message @javascript
+Feature: Unread messages
+  In order to know how many unread messages I have
+  As a user
+  I need to be able to view an unread message
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role |
+      | student1 | C1     | student |
+      | student2 | C1     | student |
+    And the following "groups" exist:
+      | name      | course | idnumber | enablemessaging |
+      | New group | C1     | NG       | 1               |
+    And the following "group members" exist:
+      | user     | group |
+      | student1 | NG |
+      | student2 | NG |
+    And the following config values are set as admin:
+      | messaging | 1 |
+
+  Scenario: Unread messages for group conversation
+    Given I log in as "student1"
+    When I open messaging
+    Then "New group" "group_message" should exist
+    And I select "New group" conversation in messaging
+    And I send "Hi!" message in the message area
+    And I should see "Hi!" in the "New group" "group_message_conversation"
+    And I should see "##today##j F##" in the "New group" "group_message_conversation"
+    And I log out
+    And I log in as "student2"
+    And I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I open messaging
+    And I should see "1" in the "Group" "group_message_tab"
+    And "New group" "group_message" should exist
+    And I should see "1" in the "New group" "group_message"
+    And I select "New group" conversation in messaging
+    And I should see "Hi!" in the "New group" "group_message_conversation"
+    And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I should not see "1" in the "Group" "group_message_tab"
+    And I should not see "1" in the "New group" "group_message"
+
+  Scenario: Unread messages for private conversation
+    Given the following "private messages" exist:
+      | user     | contact  | message               |
+      | student1 | student2 | Hi!                   |
+      | student2 | student1 | What do you need?     |
+    When I log in as "student1"
+    Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I open messaging
+    And I should see "1" in the "Private" "group_message_tab"
+    And "Student 2" "group_message" should exist
+    And I should see "1" in the "Student 2" "group_message"
+    And I select "Student 2" conversation in messaging
+    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I should not see "1" in the "Private" "group_message_tab"
+    And I should not see "1" in the "Student 2" "group_message"
+
+  Scenario: Unread messages for starred conversation
+    Given the following "private messages" exist:
+      | user     | contact  | message               |
+      | student1 | student2 | Hi!                   |
+      | student2 | student1 | What do you need?     |
+    And the following "favourite conversations" exist:
+      | user     | contact  |
+      | student1 | student2 |
+    When I log in as "student1"
+    Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I open messaging
+    And I should see "1" in the "Starred" "group_message_tab"
+    And "Student 2" "group_message" should exist
+    And I should see "1" in the "Student 2" "group_message"
+    And I select "Student 2" conversation in messaging
+    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
+    And I should not see "1" in the "Starred" "group_message_tab"
+    And I should not see "1" in the "Student 2" "group_message"
index a87a181..f8a209b 100644 (file)
@@ -1612,6 +1612,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $eventdata->fullmessageformat = FORMAT_PLAIN;
         $eventdata->fullmessagehtml  = '<strong>Feedback submitted</strong>';
         $eventdata->smallmessage     = '';
+        $eventdata->customdata         = ['datakey' => 'data'];
         message_send($eventdata);
 
         $this->setUser($user1);
@@ -1644,6 +1645,10 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $messages = core_message_external::get_messages(0, $user1->id, 'notifications', true, true, 0, 0);
         $messages = external_api::clean_returnvalue(core_message_external::get_messages_returns(), $messages);
         $this->assertCount(1, $messages['messages']);
+        // Check we receive custom data as a unserialisable json.
+        $this->assertObjectHasAttribute('datakey', json_decode($messages['messages'][0]['customdata']));
+        $this->assertEquals('mod_feedback', $messages['messages'][0]['component']);
+        $this->assertEquals('submission', $messages['messages'][0]['eventtype']);
 
         // Test warnings.
         $CFG->messaging = 0;
index 6fc2436..28a7dcf 100644 (file)
@@ -89,6 +89,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertArrayHasKey('fullmessagehtml', $privacyfields);
         $this->assertArrayHasKey('smallmessage', $privacyfields);
         $this->assertArrayHasKey('timecreated', $privacyfields);
+        $this->assertArrayHasKey('customdata', $privacyfields);
         $this->assertEquals('privacy:metadata:messages', $messagestable->get_summary());
 
         $privacyfields = $messageuseractionstable->get_privacy_fields();
@@ -136,6 +137,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertArrayHasKey('contexturlname', $privacyfields);
         $this->assertArrayHasKey('timeread', $privacyfields);
         $this->assertArrayHasKey('timecreated', $privacyfields);
+        $this->assertArrayHasKey('customdata', $privacyfields);
         $this->assertEquals('privacy:metadata:notifications', $notificationstable->get_summary());
     }
 
@@ -2724,6 +2726,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $record->fullmessage = 'A rad message ' . $i;
         $record->smallmessage = 'A rad message ' . $i;
         $record->timecreated = $timecreated;
+        $record->customdata = json_encode(['akey' => 'avalue']);
 
         $i++;
 
@@ -2763,6 +2766,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $record->smallmessage = 'Yo homie, you got some stuff to do, yolo. ' . $i;
         $record->timeread = $timeread;
         $record->timecreated = $timecreated;
+        $record->customdata = json_encode(['akey' => 'avalue']);
 
         $i++;
 
index e37e9e4..b0142f1 100644 (file)
@@ -12,6 +12,13 @@ information provided here is intended especially for developers.
 * A new parameter 'mergeself' has been added to the methods \core_message\api::get_conversations() and
   core_message_external::get_conversations(), to decide whether the self-conversations should be included or not when the
   private ones are requested, to display them together.
+* A new 'customdata' field for both messages and notifications has been added. This new field can store any custom data
+  serialised using json_encode().
+  This new field can be used for storing any data not fitting in the current message structure. For example, it will be used
+  to store additional information for the "Mobile notifications" processor.
+  Existing external functions: core_message_get_messages and message_popup_get_popup_notifications has been udated to return the
+  new field.
+* External function core_message_get_messages now returns the component and eventtype.
 
 === 3.6 ===
 
index f5d1fe7..ce4ba7f 100644 (file)
@@ -103,17 +103,11 @@ class provider implements
             return;
         }
 
-        $params = [
-            'contextid' => $context->id,
-            'contextuser' => CONTEXT_USER,
-        ];
+        $params = ['userid' => $context->instanceid];
 
-        $sql = "SELECT me.userid
-                  FROM {mnetservice_enrol_enrolments} me
-                  JOIN {context} ctx
-                       ON ctx.instanceid = me.userid
-                       AND ctx.contextlevel = :contextuser
-                 WHERE ctx.id = :contextid";
+        $sql = "SELECT userid
+                  FROM {mnetservice_enrol_enrolments}
+                 WHERE userid = :userid";
 
         $userlist->add_from_sql('userid', $sql, $params);
     }
index 0dab7f9..a9f0174 100644 (file)
@@ -6194,7 +6194,7 @@ class assign {
                                                         $assignmentname,
                                                         $blindmarking,
                                                         $uniqueidforuser) {
-        global $CFG;
+        global $CFG, $PAGE;
 
         $info = new stdClass();
         if ($blindmarking) {
@@ -6244,6 +6244,20 @@ class assign {
         $eventdata->notification    = 1;
         $eventdata->contexturl      = $info->url;
         $eventdata->contexturlname  = $info->assignment;
+        $customdata = [
+            'cmid' => $coursemodule->id,
+            'instance' => $coursemodule->instance,
+            'messagetype' => $messagetype,
+            'blindmarking' => $blindmarking,
+            'uniqueidforuser' => $uniqueidforuser,
+        ];
+        // Check if the userfrom is real and visible.
+        if (!empty($userfrom->id) && core_user::is_real_user($userfrom->id)) {
+            $userpicture = new user_picture($userfrom);
+            $userpicture->includetoken = $userto->id; // Generate an out-of-session token for the user receiving the message.
+            $customdata['notificationiconurl'] = $userpicture->get_url($PAGE)->out(false);
+        }
+        $eventdata->customdata = $customdata;
 
         message_send($eventdata);
     }
index b9b7dc1..e2fe585 100644 (file)
@@ -1490,6 +1490,7 @@ class mod_assign_locallib_testcase extends advanced_testcase {
     }
 
     public function test_cron() {
+        global $PAGE;
         $this->resetAfterTest();
 
         // First run cron so there are no messages waiting to be sent (from other tests).
@@ -1519,6 +1520,15 @@ class mod_assign_locallib_testcase extends advanced_testcase {
         $this->assertEquals(1, count($messages));
         $this->assertEquals(1, $messages[0]->notification);
         $this->assertEquals($assign->get_instance()->name, $messages[0]->contexturlname);
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertEquals($assign->get_course_module()->id, $customdata->cmid);
+        $this->assertEquals($assign->get_instance()->id, $customdata->instance);
+        $this->assertEquals('feedbackavailable', $customdata->messagetype);
+        $userpicture = new user_picture($teacher);
+        $this->assertEquals($userpicture->get_url($PAGE)->out(false), $customdata->notificationiconurl);
+        $this->assertEquals(0, $customdata->uniqueidforuser);   // Not used in this case.
+        $this->assertFalse($customdata->blindmarking);
     }
 
     public function test_cron_without_notifications() {
index f1ec218..c78070f 100644 (file)
@@ -2550,7 +2550,7 @@ function feedback_print_numeric_option_list() {
  * @return void
  */
 function feedback_send_email($cm, $feedback, $course, $user, $completed = null) {
-    global $CFG, $DB;
+    global $CFG, $DB, $PAGE;
 
     if ($feedback->email_notification == 0) {  // No need to do anything
         return;
@@ -2617,6 +2617,10 @@ function feedback_send_email($cm, $feedback, $course, $user, $completed = null)
                 $posthtml = '';
             }
 
+            $customdata = [
+                'cmid' => $cm->id,
+                'instance' => $feedback->id,
+            ];
             if ($feedback->anonymous == FEEDBACK_ANONYMOUS_NO) {
                 $eventdata = new \core\message\message();
                 $eventdata->courseid         = $course->id;
@@ -2632,6 +2636,11 @@ function feedback_send_email($cm, $feedback, $course, $user, $completed = null)
                 $eventdata->courseid         = $course->id;
                 $eventdata->contexturl       = $info->url;
                 $eventdata->contexturlname   = $info->feedback;
+                // User image.
+                $userpicture = new user_picture($user);
+                $userpicture->includetoken = $teacher->id; // Generate an out-of-session token for the user receiving the message.
+                $customdata['notificationiconurl'] = $userpicture->get_url($PAGE)->out(false);
+                $eventdata->customdata = $customdata;
                 message_send($eventdata);
             } else {
                 $eventdata = new \core\message\message();
@@ -2648,6 +2657,9 @@ function feedback_send_email($cm, $feedback, $course, $user, $completed = null)
                 $eventdata->courseid         = $course->id;
                 $eventdata->contexturl       = $info->url;
                 $eventdata->contexturlname   = $info->feedback;
+                // Feedback icon if can be easily reachable.
+                $customdata['notificationiconurl'] = ($cm instanceof cm_info) ? $cm->get_icon_url()->out() : '';
+                $eventdata->customdata = $customdata;
                 message_send($eventdata);
             }
         }
@@ -2710,6 +2722,12 @@ function feedback_send_email_anonym($cm, $feedback, $course) {
             $eventdata->courseid         = $course->id;
             $eventdata->contexturl       = $info->url;
             $eventdata->contexturlname   = $info->feedback;
+            $eventdata->customdata       = [
+                'cmid' => $cm->id,
+                'instance' => $feedback->id,
+                'notificationiconurl' => ($cm instanceof cm_info) ? $cm->get_icon_url()->out() : '',  // Performance wise.
+            ];
+
             message_send($eventdata);
         }
     }
index 9aff6c5..d920b9b 100644 (file)
@@ -54,7 +54,8 @@ class mod_feedback_external_testcase extends externallib_advanced_testcase {
 
         // Setup test data.
         $this->course = $this->getDataGenerator()->create_course();
-        $this->feedback = $this->getDataGenerator()->create_module('feedback', array('course' => $this->course->id));
+        $this->feedback = $this->getDataGenerator()->create_module('feedback',
+            array('course' => $this->course->id, 'email_notification' => 1));
         $this->context = context_module::instance($this->feedback->cmid);
         $this->cm = get_coursemodule_from_instance('feedback', $this->feedback->id);
 
@@ -518,6 +519,7 @@ class mod_feedback_external_testcase extends externallib_advanced_testcase {
         $this->assertCount(7, $tmpitems);   // 2 from the first page + 5 from the second page.
 
         // And finally, save everything! We are going to modify one previous recorded value.
+        $messagessink = $this->redirectMessages();
         $data[2]['value'] = 2; // 2 is value of the option 'b'.
         $secondpagedata = [$data[2], $data[3], $data[4], $data[5], $data[6]];
         $result = mod_feedback_external::process_page($this->feedback->id, 1, $secondpagedata);
@@ -540,6 +542,15 @@ class mod_feedback_external_testcase extends externallib_advanced_testcase {
         }
         $completed = $DB->get_record('feedback_completed', []);
         $this->assertEquals(0, $completed->courseid);
+
+        // Test notifications sent.
+        $messages = $messagessink->get_messages();
+        $messagessink->close();
+        // Test customdata.
+        $customdata = json_decode($messages[0]->customdata);
+        $this->assertEquals($this->feedback->id, $customdata->instance);
+        $this->assertEquals($this->feedback->cmid, $customdata->cmid);
+        $this->assertObjectHasAttribute('notificationiconurl', $customdata);
     }
 
     /**
diff --git a/mod/forum/amd/build/lock_toggle.min.js b/mod/forum/amd/build/lock_toggle.min.js
new file mode 100644 (file)
index 0000000..fd17819
Binary files /dev/null and b/mod/forum/amd/build/lock_toggle.min.js differ
index 3c7817f..f809871 100644 (file)
Binary files a/mod/forum/amd/build/repository.min.js and b/mod/forum/amd/build/repository.min.js differ
index 41fdb35..d3dda42 100644 (file)
Binary files a/mod/forum/amd/build/selectors.min.js and b/mod/forum/amd/build/selectors.min.js differ
diff --git a/mod/forum/amd/src/lock_toggle.js b/mod/forum/amd/src/lock_toggle.js
new file mode 100644 (file)
index 0000000..37ca209
--- /dev/null
@@ -0,0 +1,65 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Handle the manual locking of individual discussions
+ *
+ * @module     mod_forum/lock_toggle
+ * @package    mod_forum
+ * @copyright  2019 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+        'jquery',
+        'core/templates',
+        'core/notification',
+        'mod_forum/repository',
+        'mod_forum/selectors',
+    ], function(
+        $,
+        Templates,
+        Notification,
+        Repository,
+        Selectors
+    ) {
+
+    /**
+     * Register event listeners for the subscription toggle.
+     *
+     * @param {object} root The discussion list root element
+     */
+    var registerEventListeners = function(root) {
+        root.on('click', Selectors.lock.toggle, function(e) {
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var state = toggleElement.data('state');
+
+            Repository.setDiscussionLockState(forumId, discussionId, state)
+                .then(function() {
+                    return location.reload();
+                })
+                .catch(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
+    return {
+        init: function(root) {
+            registerEventListeners(root);
+        }
+    };
+});
index d8a3758..fcd10af 100644 (file)
@@ -14,7 +14,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Forum repository class to encapsulate all of the AJAX requests that
+ * Forum repository class to encapsulate all of the AJAX requests that subscribe or unsubscribe
  * can be sent for forum.
  *
  * @module     mod_forum/repository
@@ -56,8 +56,21 @@ define(['core/ajax'], function(Ajax) {
         return Ajax.call([request])[0];
     };
 
+    var setDiscussionLockState = function(forumId, discussionId, targetState) {
+        var request = {
+            methodname: 'mod_forum_set_lock_state',
+            args: {
+                forumid: forumId,
+                discussionid: discussionId,
+                targetstate: targetState
+            }
+        };
+        return Ajax.call([request])[0];
+    };
+
     return {
         setDiscussionSubscriptionState: setDiscussionSubscriptionState,
-        addDiscussionPost: addDiscussionPost
+        addDiscussionPost: addDiscussionPost,
+        setDiscussionLockState: setDiscussionLockState
     };
 });
index ba34ce8..605c138 100644 (file)
@@ -41,7 +41,10 @@ define([], function() {
             inpageReplyForm: "form[data-content='inpage-reply-form']",
             inpageSubmitBtn: "[data-action='forum-inpage-submit']",
             repliesContainer: "[data-region='replies-container']",
-            modeSelect: "select[name='mode']",
+            modeSelect: "select[name='mode']"
+        },
+        lock: {
+            toggle: "[data-action='toggle'][data-type='lock-toggle']",
         }
     };
 });
index 7bc891f..75b3456 100644 (file)
@@ -51,7 +51,7 @@ class backup_forum_activity_structure_step extends backup_activity_structure_ste
         $discussion = new backup_nested_element('discussion', array('id'), array(
             'name', 'firstpost', 'userid', 'groupid',
             'assessed', 'timemodified', 'usermodified', 'timestart',
-            'timeend', 'pinned'));
+            'timeend', 'pinned', 'timelocked'));
 
         $posts = new backup_nested_element('posts');
 
index b8703c8..b8997e5 100644 (file)
@@ -98,7 +98,8 @@ class container {
         return new vault_factory(
             $DB,
             self::get_entity_factory(),
-            get_file_storage()
+            get_file_storage(),
+            self::get_legacy_data_mapper_factory()
         );
     }
 
index cbbf4c1..2973fa4 100644 (file)
@@ -57,7 +57,8 @@ class discussion {
                 'usermodified' => $discussion->get_user_modified(),
                 'timestart' => $discussion->get_time_start(),
                 'timeend' => $discussion->get_time_end(),
-                'pinned' => $discussion->is_pinned()
+                'pinned' => $discussion->is_pinned(),
+                'timelocked' => $discussion->get_locked()
             ];
         }, $discussions);
     }
index bbc21ed..b433401 100644 (file)
@@ -61,6 +61,8 @@ class discussion {
     private $timeend;
     /** @var bool $pinned Is the discussion pinned? */
     private $pinned;
+    /** @var int $locked The timestamp of when the discussion was locked */
+    private $timelocked;
 
     /**
      * Constructor.
@@ -78,6 +80,7 @@ class discussion {
      * @param int $timestart Start time for the discussion
      * @param int $timeend End time for the discussion
      * @param bool $pinned Is the discussion pinned?
+     * @param int $locked Time this discussion was locked
      */
     public function __construct(
         int $id,
@@ -92,7 +95,8 @@ class discussion {
         int $usermodified,
         int $timestart,
         int $timeend,
-        bool $pinned
+        bool $pinned,
+        int $locked
     ) {
         $this->id = $id;
         $this->courseid = $courseid;
@@ -107,6 +111,7 @@ class discussion {
         $this->timestart = $timestart;
         $this->timeend = $timeend;
         $this->pinned = $pinned;
+        $this->timelocked = $locked;
     }
 
     /**
@@ -228,6 +233,34 @@ class discussion {
         return $this->pinned;
     }
 
+    /**
+     * Get the locked time of this discussion.
+     *
+     * @return bool
+     */
+    public function get_locked() : int {
+        return $this->timelocked;
+    }
+
+    /**
+     * Is this discussion locked based on it's locked attribute
+     *
+     * @return bool
+     */
+    public function is_locked() : bool {
+        return ($this->timelocked ? true : false);
+    }
+
+    /**
+     * Set the locked timestamp
+     *
+     * @param int $timestamp The value we want to store into 'locked'
+     */
+    public function toggle_locked_state(int $timestamp) {
+        // Check the current value against what we want the value to be i.e. '$timestamp'.
+        $this->timelocked = ($this->timelocked && $timestamp ? $this->timelocked : $timestamp);
+    }
+
     /**
      * Check if the given post is the first post in this discussion.
      *
index 3445963..66d6364 100644 (file)
@@ -539,12 +539,12 @@ class forum {
     }
 
     /**
-     * Is the discussion locked?
+     * Check whether the discussion is locked based on forum's time based locking criteria
      *
-     * @param discussion_entity $discussion The discussion to check
+     * @param discussion_entity $discussion
      * @return bool
      */
-    public function is_discussion_locked(discussion_entity $discussion) : bool {
+    public function is_discussion_time_locked(discussion_entity $discussion) : bool {
         if (!$this->has_lock_discussions_after()) {
             return false;
         }
@@ -618,4 +618,18 @@ class forum {
 
         return false;
     }
+
+    /**
+     * Is the discussion locked? - Takes into account both discussion settings AND forum's criteria
+     *
+     * @param discussion_entity $discussion The discussion to check
+     * @return bool
+     */
+    public function is_discussion_locked(discussion_entity $discussion) : bool {
+        if ($discussion->is_locked()) {
+            return true;
+        }
+
+        return $this->is_discussion_time_locked($discussion);
+    }
 }
index d23f32d..e7d2e4e 100644 (file)
@@ -64,6 +64,8 @@ class discussion extends exporter {
             'id' => ['type' => PARAM_INT],
             'forumid' => ['type' => PARAM_INT],
             'pinned' => ['type' => PARAM_BOOL],
+            'locked' => ['type' => PARAM_BOOL],
+            'istimelocked' => ['type' => PARAM_BOOL],
             'name' => ['type' => PARAM_TEXT],
             'group' => [
                 'optional' => true,
@@ -88,6 +90,7 @@ class discussion extends exporter {
                     'modified' => ['type' => PARAM_INT],
                     'start' => ['type' => PARAM_INT],
                     'end' => ['type' => PARAM_INT],
+                    'locked' => ['type' => PARAM_INT],
                 ],
             ],
             'userstate' => [
@@ -100,7 +103,8 @@ class discussion extends exporter {
                     'subscribe' => ['type' => PARAM_BOOL],
                     'move' => ['type' => PARAM_BOOL],
                     'pin' => ['type' => PARAM_BOOL],
-                    'post' => ['type' => PARAM_BOOL]
+                    'post' => ['type' => PARAM_BOOL],
+                    'manage' => ['type' => PARAM_BOOL],
                 ]
             ],
             'urls' => [
@@ -179,6 +183,8 @@ class discussion extends exporter {
             'id' => $discussion->get_id(),
             'forumid' => $forum->get_id(),
             'pinned' => $discussion->is_pinned(),
+            'locked' => $forum->is_discussion_locked($discussion),
+            'istimelocked' => $forum->is_discussion_time_locked($discussion),
             'name' => format_string($discussion->get_name(), true, [
                 'context' => $this->related['context']
             ]),
@@ -186,15 +192,17 @@ class discussion extends exporter {
                 'modified' => $discussion->get_time_modified(),
                 'start' => $discussion->get_time_start(),
                 'end' => $discussion->get_time_end(),
+                'locked' => $discussion->get_locked()
             ],
             'userstate' => [
-                'subscribed' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord, $discussion->get_id()),
+                'subscribed' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord, $discussion->get_id())
             ],
             'capabilities' => [
                 'subscribe' => $capabilitymanager->can_subscribe_to_discussion($user, $discussion),
                 'move' => $capabilitymanager->can_move_discussion($user, $discussion),
                 'pin' => $capabilitymanager->can_pin_discussion($user, $discussion),
-                'post' => $capabilitymanager->can_post_in_discussion($user, $discussion)
+                'post' => $capabilitymanager->can_post_in_discussion($user, $discussion),
+                'manage' => $capabilitymanager->can_manage_forum($user)
             ],
             'urls' => [
                 'view' => $urlfactory->get_discussion_view_url_from_discussion($discussion)->out(false),
index e3a322a..55263f0 100644 (file)
@@ -126,7 +126,8 @@ class entity {
             $record->usermodified,
             $record->timestart,
             $record->timeend,
-            $record->pinned
+            $record->pinned,
+            $record->timelocked
         );
     }
 
index 1801bdd..816e25a 100644 (file)
@@ -30,6 +30,7 @@ use mod_forum\local\data_mappers\legacy\author as author_data_mapper;
 use mod_forum\local\data_mappers\legacy\discussion as discussion_data_mapper;
 use mod_forum\local\data_mappers\legacy\forum as forum_data_mapper;
 use mod_forum\local\data_mappers\legacy\post as post_data_mapper;
+use mod_forum\local\entities\forum;
 
 /**
  * Legacy data mapper factory.
@@ -76,4 +77,23 @@ class legacy_data_mapper {
     public function get_author_data_mapper() : author_data_mapper {
         return new author_data_mapper();
     }
+
+    /**
+     * Get the corresponding entity based on the supplied value
+     *
+     * @param string $entity
+     * @return author_data_mapper|discussion_data_mapper|forum_data_mapper|post_data_mapper
+     */
+    public function get_legacy_data_mapper_for_vault($entity) {
+        switch($entity) {
+            case 'forum':
+                return $this->get_forum_data_mapper();
+            case 'discussion':
+                return $this->get_discussion_data_mapper();
+            case 'post':
+                return $this->get_post_data_mapper();
+            case 'author':
+                return $this->get_author_data_mapper();
+        }
+    }
 }
index a120edc..1f1f9e2 100644 (file)
@@ -49,6 +49,8 @@ use moodle_database;
 class vault {
     /** @var entity_factory $entityfactory Entity factory */
     private $entityfactory;
+    /** @var legacy_data_mapper $legacymapper Entity factory */
+    private $legacymapper;
     /** @var moodle_database $db A moodle database */
     private $db;
     /** @var file_storage $filestorage A file storage instance */
@@ -60,11 +62,14 @@ class vault {
      * @param moodle_database $db A moodle database
      * @param entity_factory $entityfactory Entity factory
      * @param file_storage $filestorage A file storage instance
+     * @param legacy_data_mapper $legacyfactory Datamapper
      */
-    public function __construct(moodle_database $db, entity_factory $entityfactory, file_storage $filestorage) {
+    public function __construct(moodle_database $db, entity_factory $entityfactory,
+        file_storage $filestorage, legacy_data_mapper $legacyfactory) {
         $this->db = $db;
         $this->entityfactory = $entityfactory;
         $this->filestorage = $filestorage;
+        $this->legacymapper = $legacyfactory;
     }
 
     /**
@@ -75,7 +80,8 @@ class vault {
     public function get_forum_vault() : forum_vault {
         return new forum_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('forum')
         );
     }
 
@@ -87,7 +93,8 @@ class vault {
     public function get_discussion_vault() : discussion_vault {
         return new discussion_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('discussion')
         );
     }
 
@@ -99,7 +106,8 @@ class vault {
     public function get_discussions_in_forum_vault() : discussion_list_vault {
         return new discussion_list_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('discussion')
         );
     }
 
@@ -111,7 +119,8 @@ class vault {
     public function get_post_vault() : post_vault {
         return new post_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('post')
         );
     }
 
@@ -123,7 +132,8 @@ class vault {
     public function get_author_vault() : author_vault {
         return new author_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('author')
         );
     }
 
@@ -135,7 +145,8 @@ class vault {
     public function get_post_read_receipt_collection_vault() : post_read_receipt_collection_vault {
         return new post_read_receipt_collection_vault(
             $this->db,
-            $this->entityfactory
+            $this->entityfactory,
+            $this->legacymapper->get_legacy_data_mapper_for_vault('post')
         );
     }
 
index cd4ce42..47e3027 100644 (file)
@@ -163,6 +163,7 @@ class discussion_list {
 
         $forumview = [
             'forum' => (array) $forumexporter->export($this->renderer),
+            'newdiscussionhtml' => $this->get_discussion_form($user, $cm, $groupid),
             'groupchangemenu' => groups_print_activity_menu(
                 $cm,
                 $this->urlfactory->get_forum_view_url_from_forum($forum),
@@ -192,6 +193,65 @@ class discussion_list {
         return $this->renderer->render_from_template($this->template, $forumview);
     }
 
+    /**
+     * Get the mod_forum_post_form. This is the default boiler plate from mod_forum/post_form.php with the inpage flag caveat
+     *
+     * @param stdClass $user The user the form is being generated for
+     * @param \cm_info $cm
+     * @param int $groupid The groupid if any
+     *
+     * @return string The rendered html
+     */
+    private function get_discussion_form(stdClass $user, \cm_info $cm, ?int $groupid) {
+        $forum = $this->forum;
+        $forumrecord = $this->legacydatamapperfactory->get_forum_data_mapper()->to_legacy_object($forum);
+        $modcontext = \context_module::instance($cm->id);
+        $coursecontext = \context_course::instance($forum->get_course_id());
+        $post = (object) [
+            'course' => $forum->get_course_id(),
+            'forum' => $forum->get_id(),
+            'discussion' => 0,           // Ie discussion # not defined yet.
+            'parent' => 0,
+            'subject' => '',
+            'userid' => $user->id,
+            'message' => '',
+            'messageformat' => editors_get_preferred_format(),
+            'messagetrust' => 0,
+            'groupid' => $groupid,
+        ];
+        $thresholdwarning = forum_check_throttling($forumrecord, $cm);
+
+        $formparams = array(
+            'course' => $forum->get_course_record(),
+            'cm' => $cm,
+            'coursecontext' => $coursecontext,
+            'modcontext' => $modcontext,
+            'forum' => $forumrecord,
+            'post' => $post,
+            'subscribe' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord,
+                null, $cm),
+            'thresholdwarning' => $thresholdwarning,
+            'inpagereply' => true,
+            'edit' => 0
+        );
+        $posturl = new \moodle_url('/mod/forum/post.php');
+        $mformpost = new \mod_forum_post_form($posturl, $formparams, 'post', '', array('id' => 'mformforum'));
+        $discussionsubscribe = \mod_forum\subscriptions::get_user_default_subscription($forumrecord, $coursecontext, $cm, null);
+
+        $params = array('reply' => 0, 'forum' => $forumrecord->id, 'edit' => 0) +
+            (isset($post->groupid) ? array('groupid' => $post->groupid) : array()) +
+            array(
+                'userid' => $post->userid,
+                'parent' => $post->parent,
+                'discussion' => $post->discussion,
+                'course' => $forum->get_course_id(),
+                'discussionsubscribe' => $discussionsubscribe
+            );
+        $mformpost->set_data($params);
+
+        return $mformpost->render();
+    }
+
     /**
      * Get the list of groups to show based on the current user and requested groupid.
      *
index 9beb5b2..b78daf8 100644 (file)
@@ -40,19 +40,24 @@ abstract class db_table_vault {
     private $db;
     /** @var entity_factory $entityfactory Entity factory */
     private $entityfactory;
+    /** @var object $legacyfactory Entity->legacy factory */
+    private $legacyfactory;
 
     /**
      * Constructor.
      *
      * @param moodle_database $db A moodle database
      * @param entity_factory $entityfactory Entity factory
+     * @param object $legacyfactory Legacy factory
      */
     public function __construct(
         moodle_database $db,
-        entity_factory $entityfactory
+        entity_factory $entityfactory,
+        $legacyfactory
     ) {
         $this->db = $db;
         $this->entityfactory = $entityfactory;
+        $this->legacyfactory = $legacyfactory;
     }
 
     /**
@@ -116,6 +121,15 @@ abstract class db_table_vault {
         return $this->entityfactory;
     }
 
+    /**
+     * Get the legacy factory
+     *
+     * @return object
+     */
+    protected function get_legacy_factory() {
+        return $this->legacyfactory;
+    }
+
     /**
      * Execute the defined preprocessors on the DB record results and then convert
      * them into entities.
index 43383f9..00d4e97 100644 (file)
@@ -125,4 +125,21 @@ class discussion extends db_table_vault {
         return $this->get_db()->count_records(self::TABLE, [
             'forum' => $forum->get_id()]);
     }
+
+    /**
+     * Update the discussion
+     *
+     * @param discussion_entity $discussion
+     * @return discussion_entity|null
+     */
+    public function update_discussion(discussion_entity $discussion) : ?discussion_entity {
+        $discussionrecord = $this->get_legacy_factory()->to_legacy_object($discussion);
+        if ($this->get_db()->update_record('forum_discussions', $discussionrecord)) {
+            $records = $this->transform_db_records_to_entities([$discussionrecord]);
+
+            return count($records) ? array_shift($records) : null;
+        }
+
+        return null;
+    }
 }
index 1d9d303..bea04eb 100644 (file)
@@ -93,9 +93,14 @@ class mod_forum_post_form extends moodleform {
         $subscribe = $this->_customdata['subscribe'];
         $edit = $this->_customdata['edit'];
         $thresholdwarning = $this->_customdata['thresholdwarning'];
-        $canreplyprivately = $this->_customdata['canreplyprivately'];
+        $canreplyprivately = array_key_exists('canreplyprivately', $this->_customdata) ?
+            $this->_customdata['canreplyprivately'] : false;
+        $inpagereply = $this->_customdata['inpagereply'] ?? false;
 
-        $mform->addElement('header', 'general', '');//fill in the data depending on page params later using set_data
+        if (!$inpagereply) {
+            // Fill in the data depending on page params later using set_data.
+            $mform->addElement('header', 'general', '');
+        }
 
         // If there is a warning message and we are not editing a post we need to handle the warning.
         if (!empty($thresholdwarning) && !$edit) {
@@ -115,157 +120,184 @@ class mod_forum_post_form extends moodleform {
         $mform->setType('message', PARAM_RAW);
         $mform->addRule('message', get_string('required'), 'required', null, 'client');
 
-        $manageactivities = has_capability('moodle/course:manageactivities', $coursecontext);
-
-        if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
-            $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
-            $mform->freeze('discussionsubscribe');
-            $mform->setDefaults('discussionsubscribe', 0);
-            $mform->addHelpButton('discussionsubscribe', 'forcesubscribed', 'forum');
+        if (!$inpagereply) {
+            $manageactivities = has_capability('moodle/course:manageactivities', $coursecontext);
 
-        } else if (\mod_forum\subscriptions::subscription_disabled($forum) && !$manageactivities) {
-            $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
-            $mform->freeze('discussionsubscribe');
-            $mform->setDefaults('discussionsubscribe', 0);
-            $mform->addHelpButton('discussionsubscribe', 'disallowsubscription', 'forum');
+            if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
+                $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
+                $mform->freeze('discussionsubscribe');
+                $mform->setDefaults('discussionsubscribe', 0);
+                $mform->addHelpButton('discussionsubscribe', 'forcesubscribed', 'forum');
 
-        } else {
-            $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
-            $mform->addHelpButton('discussionsubscribe', 'discussionsubscription', 'forum');
-        }
+            } else if (\mod_forum\subscriptions::subscription_disabled($forum) && !$manageactivities) {
+                $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
+                $mform->freeze('discussionsubscribe');
+                $mform->setDefaults('discussionsubscribe', 0);
+                $mform->addHelpButton('discussionsubscribe', 'disallowsubscription', 'forum');
 
-        if (forum_can_create_attachment($forum, $modcontext)) {
-            $mform->addElement('filemanager', 'attachments', get_string('attachment', 'forum'), null, self::attachment_options($forum));
-            $mform->addHelpButton('attachments', 'attachment', 'forum');
-        }
+            } else {
+                $mform->addElement('checkbox', 'discussionsubscribe', get_string('discussionsubscription', 'forum'));
+                $mform->addHelpButton('discussionsubscribe', 'discussionsubscription', 'forum');
+            }
 
-        if (!$post->parent && has_capability('mod/forum:pindiscussions', $modcontext)) {
-            $mform->addElement('checkbox', 'pinned', get_string('discussionpinned', 'forum'));
-            $mform->addHelpButton('pinned', 'discussionpinned', 'forum');
-        }
+            if (forum_can_create_attachment($forum, $modcontext)) {
+                $mform->addElement('filemanager', 'attachments', get_string('attachment', 'forum'), null,
+                    self::attachment_options($forum));
+                $mform->addHelpButton('attachments', 'attachment', 'forum');
+            }
 
-        if (empty($post->id) && $manageactivities) {
-            $mform->addElement('checkbox', 'mailnow', get_string('mailnow', 'forum'));
-        }
+            if (!$post->parent && has_capability('mod/forum:pindiscussions', $modcontext)) {
+                $mform->addElement('checkbox', 'pinned', get_string('discussionpinned', 'forum'));
+                $mform->addHelpButton('pinned', 'discussionpinned', 'forum');
+            }
 
-        if ((empty($post->id) && $canreplyprivately) || (!empty($post) && !empty($post->privatereplyto))) {
-            // Only show the option to change private reply settings if this is a new post and the user can reply
-            // privately, or if this is already private reply, in which case the state is shown but is not editable.
-            $mform->addElement('checkbox', 'isprivatereply', get_string('privatereply', 'forum'));
-            $mform->addHelpButton('isprivatereply', 'privatereply', 'forum');
-            if (!empty($post->privatereplyto)) {
-                $mform->setDefault('isprivatereply', 1);
-                $mform->freeze('isprivatereply');
+            if ((empty($post->id) && $canreplyprivately) || (!empty($post) && !empty($post->privatereplyto))) {
+                // Only show the option to change private reply settings if this is a new post and the user can reply
+                // privately, or if this is already private reply, in which case the state is shown but is not editable.
+                $mform->addElement('checkbox', 'isprivatereply', get_string('privatereply', 'forum'));
+                $mform->addHelpButton('isprivatereply', 'privatereply', 'forum');
+                if (!empty($post->privatereplyto)) {
+                    $mform->setDefault('isprivatereply', 1);
+                    $mform->freeze('isprivatereply');
+                }
             }
-        }
 
-        if ($groupmode = groups_get_activity_groupmode($cm, $course)) {
-            $groupdata = groups_get_activity_allowed_groups($cm);
+            if ($groupmode = groups_get_activity_groupmode($cm, $course)) {
+                $groupdata = groups_get_activity_allowed_groups($cm);
+                if (empty($post->id) && $manageactivities) {
+                    $mform->addElement('checkbox', 'mailnow', get_string('mailnow', 'forum'));
+                }
 
-            $groupinfo = array();
-            foreach ($groupdata as $groupid => $group) {
-                // Check whether this user can post in this group.
-                // We must make this check because all groups are returned for a visible grouped activity.
-                if (forum_user_can_post_discussion($forum, $groupid, null, $cm, $modcontext)) {
-                    // Build the data for the groupinfo select.
-                    $groupinfo[$groupid] = $group->name;
-                } else {
-                    unset($groupdata[$groupid]);
+                $groupinfo = array();
+                foreach ($groupdata as $groupid => $group) {
+                    // Check whether this user can post in this group.
+                    // We must make this check because all groups are returned for a visible grouped activity.
+                    if (forum_user_can_post_discussion($forum, $groupid, null, $cm, $modcontext)) {
+                        // Build the data for the groupinfo select.
+                        $groupinfo[$groupid] = $group->name;
+                    } else {
+                        unset($groupdata[$groupid]);
+                    }
+                }
+                $groupcount = count($groupinfo);
+
+                // Check whether a user can post to all of their own groups.
+
+                // Posts to all of my groups are copied to each group that the user is a member of. Certain conditions must be met.
+                // 1) It only makes sense to allow this when a user is in more than one group.
+                // Note: This check must come before we consider adding accessallgroups, because that is not a real group.
+                $canposttoowngroups = empty($post->edit) && $groupcount > 1;
+
+                // 2) Important: You can *only* post to multiple groups for a top level post. Never any reply.
+                $canposttoowngroups = $canposttoowngroups && empty($post->parent);
+
+                // 3) You also need the canposttoowngroups capability.
+                $canposttoowngroups = $canposttoowngroups && has_capability('mod/forum:canposttomygroups', $modcontext);
+                if ($canposttoowngroups) {
+                    // This user is in multiple groups, and can post to all of their own groups.
+                    // Note: This is not the same as accessallgroups. This option will copy a post to all groups that a
+                    // user is a member of.
+                    $mform->addElement('checkbox', 'posttomygroups', get_string('posttomygroups', 'forum'));
+                    $mform->addHelpButton('posttomygroups', 'posttomygroups', 'forum');
+                    $mform->disabledIf('groupinfo', 'posttomygroups', 'checked');
                 }
-            }
-            $groupcount = count($groupinfo);
-
-            // Check whether a user can post to all of their own groups.
-
-            // Posts to all of my groups are copied to each group that the user is a member of. Certain conditions must be met.
-            // 1) It only makes sense to allow this when a user is in more than one group.
-            // Note: This check must come before we consider adding accessallgroups, because that is not a real group.
-            $canposttoowngroups = empty($post->edit) && $groupcount > 1;
-
-            // 2) Important: You can *only* post to multiple groups for a top level post. Never any reply.
-            $canposttoowngroups = $canposttoowngroups && empty($post->parent);
-
-            // 3) You also need the canposttoowngroups capability.
-            $canposttoowngroups = $canposttoowngroups && has_capability('mod/forum:canposttomygroups', $modcontext);
-            if ($canposttoowngroups) {
-                // This user is in multiple groups, and can post to all of their own groups.
-                // Note: This is not the same as accessallgroups. This option will copy a post to all groups that a
-                // user is a member of.
-                $mform->addElement('checkbox', 'posttomygroups', get_string('posttomygroups', 'forum'));
-                $mform->addHelpButton('posttomygroups', 'posttomygroups', 'forum');
-                $mform->disabledIf('groupinfo', 'posttomygroups', 'checked');
-            }
 
-            // Check whether this user can post to all groups.
-            // Posts to the 'All participants' group go to all groups, not to each group in a list.
-            // It makes sense to allow this, even if there currently aren't any groups because there may be in the future.
-            if (forum_user_can_post_discussion($forum, -1, null, $cm, $modcontext)) {
-                // Note: We must reverse in this manner because array_unshift renumbers the array.
-                $groupinfo = array_reverse($groupinfo, true );
-                $groupinfo[-1] = get_string('allparticipants');
-                $groupinfo = array_reverse($groupinfo, true );
-                $groupcount++;
-            }
+                // Check whether this user can post to all groups.
+                // Posts to the 'All participants' group go to all groups, not to each group in a list.
+                // It makes sense to allow this, even if there currently aren't any groups because there may be in the future.
+                if (forum_user_can_post_discussion($forum, -1, null, $cm, $modcontext)) {
+                    // Note: We must reverse in this manner because array_unshift renumbers the array.
+                    $groupinfo = array_reverse($groupinfo, true);
+                    $groupinfo[-1] = get_string('allparticipants');
+                    $groupinfo = array_reverse($groupinfo, true);
+                    $groupcount++;
+                }
 
-            // Determine whether the user can select a group from the dropdown. The dropdown is available for several reasons.
-            // 1) This is a new post (not an edit), and there are at least two groups to choose from.
-            $canselectgroupfornew = empty($post->edit) && $groupcount > 1;
+                // Determine whether the user can select a group from the dropdown. The dropdown is available for several reasons.
+                // 1) This is a new post (not an edit), and there are at least two groups to choose from.
+                $canselectgroupfornew = empty($post->edit) && $groupcount > 1;
 
-            // 2) This is editing of an existing post and the user is allowed to movediscussions.
-            // We allow this because the post may have been moved from another forum where groups are not available.
-            // We show this even if no groups are available as groups *may* have been available but now are not.
-            $canselectgroupformove = $groupcount && !empty($post->edit) && has_capability('mod/forum:movediscussions', $modcontext);
+                // 2) This is editing of an existing post and the user is allowed to movediscussions.
+                // We allow this because the post may have been moved from another forum where groups are not available.
+                // We show this even if no groups are available as groups *may* have been available but now are not.
+                $canselectgroupformove =
+                    $groupcount && !empty($post->edit) && has_capability('mod/forum:movediscussions', $modcontext);
 
-            // Important: You can *only* change the group for a top level post. Never any reply.
-            $canselectgroup = empty($post->parent) && ($canselectgroupfornew || $canselectgroupformove);
+                // Important: You can *only* change the group for a top level post. Never any reply.
+                $canselectgroup = empty($post->parent) && ($canselectgroupfornew || $canselectgroupformove);
 
-            if ($canselectgroup) {
-                $mform->addElement('select','groupinfo', get_string('group'), $groupinfo);
-                $mform->setDefault('groupinfo', $post->groupid);
-                $mform->setType('groupinfo', PARAM_INT);
-            } else {
-                if (empty($post->groupid)) {
-                    $groupname = get_string('allparticipants');
+                if ($canselectgroup) {
+                    $mform->addElement('select', 'groupinfo', get_string('group'), $groupinfo);
+                    $mform->setDefault('groupinfo', $post->groupid);
+                    $mform->setType('groupinfo', PARAM_INT);
                 } else {
-                    $groupname = format_string($groupdata[$post->groupid]->name);
+                    if (empty($post->groupid)) {
+                        $groupname = get_string('allparticipants');
+                    } else {
+                        $groupname = format_string($groupdata[$post->groupid]->name);
+                    }
+                    $mform->addElement('static', 'groupinfo', get_string('group'), $groupname);
                 }
-                $mform->addElement('static', 'groupinfo', get_string('group'), $groupname);
             }
-        }
 
-        if (!empty($CFG->forum_enabletimedposts) && !$post->parent && has_capability('mod/forum:viewhiddentimedposts', $coursecontext)) {
-            $mform->addElement('header', 'displayperiod', get_string('displayperiod', 'forum'));
+            if (!empty($CFG->forum_enabletimedposts) && !$post->parent &&
+                has_capability('mod/forum:viewhiddentimedposts', $coursecontext)) {
+                $mform->addElement('header', 'displayperiod', get_string('displayperiod', 'forum'));
 
-            $mform->addElement('date_time_selector', 'timestart', get_string('displaystart', 'forum'), array('optional' => true));
-            $mform->addHelpButton('timestart', 'displaystart', 'forum');
+                $mform->addElement('date_time_selector', 'timestart', get_string('displaystart', 'forum'),
+                    array('optional' => true));
+                $mform->addHelpButton('timestart', 'displaystart', 'forum');
 
-            $mform->addElement('date_time_selector', 'timeend', get_string('displayend', 'forum'), array('optional' => true));
-            $mform->addHelpButton('timeend', 'displayend', 'forum');
+                $mform->addElement('date_time_selector', 'timeend', get_string('displayend', 'forum'),
+                    array('optional' => true));
+                $mform->addHelpButton('timeend', 'displayend', 'forum');
 
-        } else {
-            $mform->addElement('hidden', 'timestart');
-            $mform->setType('timestart', PARAM_INT);
-            $mform->addElement('hidden', 'timeend');
-            $mform->setType('timeend', PARAM_INT);
-            $mform->setConstants(array('timestart' => 0, 'timeend' => 0));
-        }
+            } else {
+                $mform->addElement('hidden', 'timestart');
+                $mform->setType('timestart', PARAM_INT);
+                $mform->addElement('hidden', 'timeend');
+                $mform->setType('timeend', PARAM_INT);
+                $mform->setConstants(array('timestart' => 0, 'timeend' => 0));
+            }
 
-        if (core_tag_tag::is_enabled('mod_forum', 'forum_posts')) {
-            $mform->addElement('header', 'tagshdr', get_string('tags', 'tag'));
+            if (core_tag_tag::is_enabled('mod_forum', 'forum_posts')) {
+                $mform->addElement('header', 'tagshdr', get_string('tags', 'tag'));
 
-            $mform->addElement('tags', 'tags', get_string('tags'),
-                array('itemtype' => 'forum_posts', 'component' => 'mod_forum'));
+                $mform->ad