Merge branch 'MDL-64665-master' of git://github.com/cescobedo/moodle
authorDavid Monllaó <davidm@moodle.com>
Wed, 24 Apr 2019 18:00:03 +0000 (20:00 +0200)
committerDavid Monllaó <davidm@moodle.com>
Wed, 24 Apr 2019 18:00:03 +0000 (20:00 +0200)
122 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
calendar/classes/privacy/provider.php
calendar/tests/privacy_test.php
cohort/classes/privacy/provider.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/role.php
lib/completionlib.php
lib/db/access.php
lib/db/upgrade.php
lib/editor/tinymce/tiny_mce/3.5.11/tiny_mce_src.js
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/questionlib.php
lib/tests/behat/behat_data_generators.php
lib/upgrade.txt
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/behat/behat_message.php
message/tests/behat/message_delete_conversation.feature [new file with mode: 0644]
mnet/service/enrol/classes/privacy/provider.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/db/install.xml
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/subscriptions_test.php
mod/forum/version.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
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 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 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..93eb8be 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.';
@@ -203,7 +205,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 +279,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 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 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 dafe307..5395c24 100644 (file)
@@ -3217,5 +3217,21 @@ 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);
+    }
+
     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 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 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 158187b..1fa619a 100644 (file)
@@ -2,7 +2,6 @@ This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
 === 3.7 ===
-
 * Nodes in the navigation api can have labels for each group. See set/get_collectionlabel().
 * The method core_user::is_real_user() now returns false for userid = 0 parameter
 * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id
@@ -26,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,7 @@ is disabled).
 * New optional parameter $throwexception for \get_complete_user_data(). If true, an exception will be thrown when there's no
   matching record found or when there are multiple records found for the given field value. If false, it will simply return false.
   Defaults to false when not set.
+* Exposed submit button to allow custom styling (via customclassoverride variable) which can override btn-primary/btn-secondary classes
 
 === 3.6 ===
 
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 0e43c4f..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",
             )
         );
 
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
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);
     }
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..3ca9199 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,64 @@ 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
+        );
+        $mformpost = new \mod_forum_post_form('post.php', $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->addElement('tags', 'tags', get_string('tags'),
+                    array('itemtype' => 'forum_posts', 'component' => 'mod_forum'));
+            }
         }
 
         //-------------------------------------------------------------------------------
         // buttons
         if (isset($post->edit)) { // hack alert
-            $submit_string = get_string('savechanges');
+            $submitstring = get_string('savechanges');
         } else {
-            $submit_string = get_string('posttoforum', 'forum');
+            $submitstring = get_string('posttoforum', 'forum');
         }
 
-        $this->add_action_buttons(true, $submit_string);
+        // Always register a no submit button so it can be picked up if redirecting to the original post form.
+        $mform->registerNoSubmitButton('advancedadddiscussion');
+
+        // This is an inpage add discussion which requires custom buttons.
+        if ($inpagereply) {
+            $mform->addElement('hidden', 'discussionsubscribe');
+            $mform->setType('discussionsubscribe', PARAM_INT);
+            $mform->disable_form_change_checker();
+            $buttonarray = array();
+            $buttonarray[] = &$mform->createElement('submit', 'submitbutton', $submitstring);
+            $buttonarray[] = &$mform->createElement('button', 'cancelbtn',
+                get_string('cancel', 'core'),
+                // Additional attribs to handle collapsible div.
+                ['data-toggle' => 'collapse', 'data-target' => "#collapseAddForm"]);
+            $buttonarray[] = &$mform->createElement('submit', 'advancedadddiscussion',
+                get_string('advanced'), null, null, ['customclassoverride' => 'btn-link']);
+
+            $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false);
+            $mform->closeHeaderBefore('buttonar');
+        } else {
+            $this->add_action_buttons(true, $submitstring);
+        }
 
         $mform->addElement('hidden', 'course');
         $mform->setType('course', PARAM_INT);
index 8f7aa2f..5f50eb8 100644 (file)
@@ -825,4 +825,38 @@ class subscriptions {
         return true;
     }
 
+    /**
+     * Gets the default subscription value for the logged in user.
+     *
+     * @param \stdClass $forum The forum record
+     * @param \context $context The course context
+     * @param \cm_info $cm cm_info
+     * @param int|null $discussionid The discussion we are checking against
+     * @return bool Default subscription
+     * @throws coding_exception
+     */
+    public static function get_user_default_subscription($forum, $context, $cm, ?int $discussionid) {
+        global $USER;
+        $manageactivities = has_capability('moodle/course:manageactivities', $context);
+        if (\mod_forum\subscriptions::subscription_disabled($forum) && !$manageactivities) {
+            // User does not have permission to subscribe to this discussion at all.
+            $discussionsubscribe = false;
+        } else if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
+            // User does not have permission to unsubscribe from this discussion at all.
+            $discussionsubscribe = true;
+        } else {
+            if (isset($discussion) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, $discussionid, $cm)) {
+                // User is subscribed to the discussion - continue the subscription.
+                $discussionsubscribe = true;
+            } else if (!isset($discussionid) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, null, $cm)) {
+                // Starting a new discussion, and the user is subscribed to the forum - subscribe to the discussion.
+                $discussionsubscribe = true;
+            } else {
+                // User is not subscribed to either forum or discussion. Follow user preference.
+                $discussionsubscribe = $USER->autosubscribe ?? false;
+            }
+        }
+
+        return $discussionsubscribe;
+    }
 }
index bf59319..ce528a4 100644 (file)
@@ -56,6 +56,7 @@
         <FIELD NAME="timestart" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timeend" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="pinned" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timelocked" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index e2bafac..3506d12 100644 (file)
@@ -134,4 +134,15 @@ $functions = array(
         'ajax' => true,
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+
+    'mod_forum_set_lock_state' => array(
+        'classname' => 'mod_forum_external',
+        'methodname' => 'set_lock_state',
+        'classpath' => 'mod/forum/externallib.php',
+        'description' => 'Set the lock state for the discussion',
+        'type' => 'write',
+        'ajax' => true,
+        'capabilities' => 'moodle/course:manageactivities',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
 );
index 9127de4..7addc73 100644 (file)
@@ -114,7 +114,6 @@ function xmldb_forum_upgrade($oldversion) {
             $dbman->add_field($table, $field);
         }
 
-        // Forum savepoint reached.
         upgrade_mod_savepoint(true, 2019031200, 'forum');
     }
 
@@ -138,9 +137,22 @@ function xmldb_forum_upgrade($oldversion) {
             $dbman->add_field($table, $field);
         }
 
-        // Forum savepoint reached.
         upgrade_mod_savepoint(true, 2019040400, 'forum');
     }
 
+    if ($oldversion < 2019040402) {
+        // Define field deleted to be added to forum_posts.
+        $table = new xmldb_table('forum_discussions');
+        $field = new xmldb_field('timelocked', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'pinned');
+
+        // Conditionally launch add field deleted.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Forum savepoint reached.
+        upgrade_mod_savepoint(true, 2019040402, 'forum');
+    }
+
     return true;
 }
index 27da970..c7175d4 100644 (file)
@@ -588,6 +588,7 @@ class mod_forum_external extends external_api {
             }
             // The forum function returns the replies for all the discussions in a given forum.
             $canseeprivatereplies = has_capability('mod/forum:readprivatereplies', $modcontext);
+            $canlock = has_capability('moodle/course:manageactivities', $modcontext, $USER);
             $replies = forum_count_discussion_replies($forumid, $sort, -1, $page, $perpage, $canseeprivatereplies);
 
             foreach ($alldiscussions as $discussion) {
@@ -637,6 +638,7 @@ class mod_forum_external extends external_api {
                 }
 
                 $discussion->locked = forum_discussion_is_locked($forum, $discussion);
+                $discussion->canlock = $canlock;
                 $discussion->canreply = forum_user_can_post($forum, $discussion, $USER, $cm, $course, $modcontext);
 
                 if (forum_is_author_hidden($discussion, $forum)) {
@@ -728,6 +730,7 @@ class mod_forum_external extends external_api {
                                 'pinned' => new external_value(PARAM_BOOL, 'Is the discussion pinned'),
                                 'locked' => new external_value(PARAM_BOOL, 'Is the discussion locked'),
                                 'canreply' => new external_value(PARAM_BOOL, 'Can the user reply to the discussion'),
+                                'canlock' => new external_value(PARAM_BOOL, 'Can the user lock the discussion'),
                             ), 'post'
                         )
                     ),
@@ -1227,6 +1230,7 @@ class mod_forum_external extends external_api {
         $discussion->name = $discussion->subject;
         $discussion->timestart = 0;
         $discussion->timeend = 0;
+        $discussion->timelocked = 0;
         $discussion->attachments = $options['attachmentsid'];
 
         if (has_capability('mod/forum:pindiscussions', $context) && $options['discussionpinned']) {
@@ -1510,4 +1514,79 @@ class mod_forum_external extends external_api {
     public static function set_subscription_state_returns() {
         return \mod_forum\local\exporters\discussion::get_read_structure();
     }
+
+    /**
+     * Set the lock state.
+     *
+     * @param   int     $forumid
+     * @param   int     $discussionid
+     * @param   string    $targetstate
+     * @return  \stdClass
+     */
+    public static function set_lock_state($forumid, $discussionid, $targetstate) {
+        global $DB, $PAGE, $USER;
+
+        $params = self::validate_parameters(self::set_lock_state_parameters(), [
+            'forumid' => $forumid,
+            'discussionid' => $discussionid,
+            'targetstate' => $targetstate
+        ]);
+
+        $vaultfactory = mod_forum\local\container::get_vault_factory();
+        $forumvault = $vaultfactory->get_forum_vault();
+        $forum = $forumvault->get_from_id($params['forumid']);
+
+        $managerfactory = mod_forum\local\container::get_manager_factory();
+        $capabilitymanager = $managerfactory->get_capability_manager($forum);
+        if (!$capabilitymanager->can_manage_forum($USER)) {
+            throw new moodle_exception('errorcannotlock', 'forum');
+        }
+
+        // If the targetstate(currentstate) is not 0 then it should be set to the current time.
+        $lockedvalue = $targetstate ? 0 : time();
+        self::validate_context($forum->get_context());
+
+        $discussionvault = $vaultfactory->get_discussion_vault();
+        $discussion = $discussionvault->get_from_id($params['discussionid']);
+
+        // If the current state doesn't equal the desired state then update the current.
+        // state to the desired state.
+        $discussion->toggle_locked_state($lockedvalue);
+        $response = $discussionvault->update_discussion($discussion);
+        $discussion = !$response ? $response : $discussion;
+
+        $exporterfactory = mod_forum\local\container::get_exporter_factory();
+        $exporter = $exporterfactory->get_discussion_exporter($USER, $forum, $discussion);
+        return $exporter->export($PAGE->get_renderer('mod_forum'));
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function set_lock_state_parameters() {
+        return new external_function_parameters(
+            [
+                'forumid' => new external_value(PARAM_INT, 'Forum that the discussion is in'),
+                'discussionid' => new external_value(PARAM_INT, 'The discussion to lock / unlock'),
+                'targetstate' => new external_value(PARAM_INT, 'The timestamp for the lock state')
+            ]
+        );
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function set_lock_state_returns() {
+        return new external_single_structure([
+                'id' => new external_value(PARAM_INT, 'The discussion we are locking.'),
+                'locked' => new external_value(PARAM_BOOL, 'The locked state of the discussion.'),
+                'times' => new external_single_structure([
+                    'locked' => new external_value(PARAM_INT, 'The locked time of the discussion.'),
+                ])
+        ]);
+    }
 }
index 6cabc0f..15d9a11 100644 (file)
@@ -85,6 +85,8 @@ $string['cannotupdatepost'] = 'You can not update this post';
 $string['cannotviewpostyet'] = 'You cannot read other students questions in this discussion yet because you haven\'t posted';
 $string['cannotviewusersposts'] = 'There are no posts made by this user that you are able to view.';
 $string['cleanreadtime'] = 'Mark old posts as read hour';
+$string['clicktolockdiscussion'] = 'Click to lock this discussion';
+$string['clicktounlockdiscussion'] = 'Click to unlock this discussion';
 $string['clicktounsubscribe'] = 'You are subscribed to this discussion. Click to unsubscribe.';
 $string['clicktosubscribe'] = 'You are not subscribed to this discussion. Click to subscribe.';
 $string['completiondiscussions'] = 'Student must create discussions:';
@@ -221,6 +223,7 @@ $string['erroremptymessage'] = 'Post message cannot be empty';
 $string['erroremptysubject'] = 'Post subject cannot be empty.';
 $string['errorenrolmentrequired'] = 'You must be enrolled in this course to access this content';
 $string['errorwhiledelete'] = 'An error occurred while deleting record.';
+$string['errorcannotlock'] = 'You do not have the permission to lock discussions.';
 $string['eventassessableuploaded'] = 'Some content has been posted.';
 $string['everyonecanchoose'] = 'Everyone can choose to be subscribed';
 $string['everyonecannowchoose'] = 'Everyone can now choose to be subscribed';
@@ -313,6 +316,7 @@ $string['lockdiscussionafter_help'] = 'Discussions may be automatically locked a
 
 Users with the capability to reply to locked discussions can unlock a discussion by replying to it.';
 $string['longpost'] = 'Long post';
+$string['locked'] = 'Locked';
 $string['mailnow'] = 'Send forum post notifications with no editing-time delay';
 $string['manydiscussions'] = 'Discussions per page';
 $string['managesubscriptionsoff'] = 'Finish managing subscriptions';
@@ -403,6 +407,7 @@ $string['notexists'] = 'Discussion no longer exists';
 $string['nothingnew'] = 'Nothing new for {$a}';
 $string['notingroup'] = 'Sorry, but you need to be part of a group to see this forum.';
 $string['notinstalled'] = 'The forum module is not installed';
+$string['notlocked'] = 'Lock';
 $string['notpartofdiscussion'] = 'This post is not part of a discussion!';
 $string['notrackforum'] = 'Don\'t track unread posts';
 $string['noviewdiscussionspermission'] = 'You do not have the permission to view discussions in this forum';
index e1b7a82..7a42f63 100644 (file)
@@ -1822,8 +1822,9 @@ function forum_get_discussions($cm, $forumsort="", $fullpost=true, $unused=-1, $
         $updatedsincesql = 'AND d.timemodified > ?';
         $params[] = $updatedsince;
     }
-    $discussionfields = "d.id as discussionid, d.course, d.forum, d.name, d.firstpost, d.groupid, d.assessed," .
-    " d.timemodified, d.usermodified, d.timestart, d.timeend, d.pinned";
+
+    $discussionfields = "d.id as discussionid, d.course, d.forum, d.name, d.firstpost, d.userid, d.groupid, d.assessed," .
+    " d.timemodified, d.usermodified, d.timestart, d.timeend, d.pinned, d.timelocked";
 
     $allnames = get_all_user_name_fields(true, 'u');
     $sql = "SELECT $postdata, $discussionfields,
index 4aa76e7..375559c 100644 (file)
@@ -217,6 +217,11 @@ if (!empty($forum)) {
                     'returnurl' => '/mod/forum/view.php?f=' . $forum->id)),
                     get_string('youneedtoenrol'));
             }
+
+            // The forum has been locked. Just redirect back to the discussion page.
+            if (forum_discussion_is_locked($forum, $discussion)) {
+                redirect(new moodle_url('/mod/forum/discuss.php', array('d' => $discussion->id)));
+            }
         }
         print_error('nopostforum', 'forum');
     }
@@ -720,26 +725,8 @@ $postid = empty($post->id) ? null : $post->id;
 $draftideditor = file_get_submitted_draft_itemid('message');
 $editoropts = mod_forum_post_form::editor_options($modcontext, $postid);
 $currenttext = file_prepare_draft_area($draftideditor, $modcontext->id, 'mod_forum', 'post', $postid, $editoropts, $post->message);
-
-$manageactivities = has_capability('moodle/course:manageactivities', $coursecontext);
-if (\mod_forum\subscriptions::subscription_disabled($forum) && !$manageactivities) {
-    // User does not have permission to subscribe to this discussion at all.
-    $discussionsubscribe = false;
-} else if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
-    // User does not have permission to unsubscribe from this discussion at all.
-    $discussionsubscribe = true;
-} else {
-    if (isset($discussion) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, $discussion->id, $cm)) {
-        // User is subscribed to the discussion - continue the subscription.
-        $discussionsubscribe = true;
-    } else if (!isset($discussion) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, null, $cm)) {
-        // Starting a new discussion, and the user is subscribed to the forum - subscribe to the discussion.
-        $discussionsubscribe = true;
-    } else {
-        // User is not subscribed to either forum or discussion. Follow user preference.
-        $discussionsubscribe = $USER->autosubscribe;
-    }
-}
+$discussionid = isset($discussion) ? $discussion->id : null;
+$discussionsubscribe = \mod_forum\subscriptions::get_user_default_subscription($forum, $coursecontext, $cm, $discussionid);
 
 $mformpost->set_data(
     array(
@@ -781,14 +768,14 @@ if ($mformpost->is_cancelled()) {
     } else {
         redirect($urlfactory->get_discussion_view_url_from_discussion($discussionentity));
     }
-} else if ($fromform = $mformpost->get_data()) {
+} else if ($mformpost->is_submitted() && !$mformpost->no_submit_button_pressed()) {
 
     if (empty($SESSION->fromurl)) {
         $errordestination = $urlfactory->get_forum_view_url_from_forum($forumentity);
     } else {
         $errordestination = $SESSION->fromurl;
     }
-
+    $fromform = $mformpost->get_data();
     $fromform->itemid        = $fromform->message['itemid'];
     $fromform->messageformat = $fromform->message['format'];
     $fromform->message       = $fromform->message['text'];
@@ -968,6 +955,7 @@ if ($mformpost->is_cancelled()) {
 
         $discussion = $fromform;
         $discussion->name = $fromform->subject;
+        $discussion->timelocked = 0;
 
         $newstopic = false;
         if ($forum->type == 'news' && !$fromform->parent) {
index 297d60a..48cf448 100644 (file)
@@ -301,13 +301,15 @@ span.unread {
     background: url([[pix:mod_forum|t/unsubscribed]]) no-repeat -9999px -9999px;
 }
 
-.path-mod-forum .discussionsubscription {
+.path-mod-forum .discussionsubscription,
+.path-mod-forum .discussionlock {
     margin-top: -10px;
     text-align: right;
     margin-bottom: 10px;
 }
 
-.path-mod-forum .discussionsubscription > a > img {
+.path-mod-forum .discussionsubscription > a > img,
+.path-mod-forum .discussionlock > a > img {
     width: 12px;
     padding: 0 4px;
 }
index 58b0cc3..1f5303b 100644 (file)
 
     {{#forum.capabilities.create}}
         <div class="p-t-1 p-b-1">
-            <a href="{{forum.urls.create}}" class="btn btn-primary">
+            <a class="btn btn-primary" data-toggle="collapse" href="#collapseAddForm">
                 {{$discussion_create_text}}
                     {{#str}}addanewdiscussion, forum{{/str}}
                 {{/discussion_create_text}}
             </a>
+            <div class="collapse m-t-1" id="collapseAddForm">
+                {{{newdiscussionhtml}}}
+            </div>
         </div>
     {{/forum.capabilities.create}}
 
diff --git a/mod/forum/templates/discussion_lock_toggle.mustache b/mod/forum/templates/discussion_lock_toggle.mustache
new file mode 100644 (file)
index 0000000..396fafd
--- /dev/null
@@ -0,0 +1,58 @@
+{{!
+    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/>.
+}}
+{{!
+    @template mod_forum/discussion_lock_toggle
+
+    Template to display the discussion subscription toggle.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * none
+
+    Example context (json):
+    {
+        "id": 0,
+        "locked": 1
+    }
+}}
+<a
+    class="iconsmall"
+    data-type="lock-toggle"
+    data-action="toggle"
+    data-discussionid="{{id}}"
+    data-forumid="{{forumid}}"
+    data-state="{{locked}}"
+    href="#"
+    {{#locked}}
+        title="{{#str}}clicktounlockdiscussion, forum{{/str}}"
+    {{/locked}}
+    {{^locked}}
+        title="{{#str}}clicktolockdiscussion, forum{{/str}}"
+    {{/locked}}
+>
+    {{#locked}}
+        {{#pix}}t/unlock, core, {{#str}}clicktounlockdiscussion, forum{{/str}}{{/pix}}{{#str}}locked, forum{{/str}}
+    {{/locked}}
+    {{^locked}}
+        {{#pix}}t/lock, core, {{#str}}clicktolockdiscussion, forum{{/str}}{{/pix}}{{#str}}notlocked, forum{{/str}}
+    {{/locked}}
+</a>
\ No newline at end of file
index 816b492..0555e76 100644 (file)
 
 <div id="discussion-container-{{uniqid}}" data-content="forum-discussion">
 {{#html}}
-    {{{subscribe}}}
+    <div class="d-flex flex-wrap flex-row-reverse m-b-1" data-container="discussion-tools" style="text-align: right;">
+        {{#capabilities.manage}}
+            {{^timelocked}}
+            <div class="pl-1 discussionlock">
+                {{> forum/discussion_lock_toggle }}
+            </div>
+            {{/timelocked}}
+        {{/capabilities.manage}}
+        <div class="pl-1">{{{subscribe}}}</div>
+    </div>
     {{{neighbourlinks}}}
 
     <div class="d-flex flex-wrap mb-1">
 {{#html.neighbourlinks}}{{{.}}}{{/html.neighbourlinks}}
 </div>
 {{#js}}
-require(['jquery', 'mod_forum/discussion', 'mod_forum/posts_list'], function($, Discussion, PostsList) {
+require(['jquery', 'mod_forum/discussion', 'mod_forum/posts_list', 'mod_forum/lock_toggle'], function($, Discussion, PostsList, LockToggle) {
     var root = $("[data-content='forum-discussion']");
     Discussion.init(root);
     PostsList.init(root);
-    var root = $('#discussion-container-{{uniqid}}');
+    var root = $('[data-container="discussion-tools"]');
+    LockToggle.init(root);
 });
 {{/js}}
diff --git a/mod/forum/tests/behat/add_forum_inline.feature b/mod/forum/tests/behat/add_forum_inline.feature
new file mode 100644 (file)
index 0000000..b7f5d18
--- /dev/null
@@ -0,0 +1,32 @@
+@mod @mod_forum @javascript
+Feature: Add forum activities and discussions utilizing the inline add discussion form
+
+  Background: Add a forum and a discussion attaching files
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name  | Test forum name                |
+      | Forum type  | Standard forum for general use |
+      | Description | Test forum description         |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Forum post 1     |
+      | Message | This is the body |
+    And I log out
+
+  Scenario: Student can add a discussion via the inline form
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    Then I add a new discussion to "Test forum name" forum inline with:
+      | Subject | Post with attachment |
+      | Message | This is the body     |
\ No newline at end of file
index 6ad16bf..380d0e8 100644 (file)
@@ -60,6 +60,18 @@ class behat_mod_forum extends behat_base {
         $this->add_new_discussion($forumname, $table, get_string('addanewdiscussion', 'forum'));
     }
 
+    /**
+     * Adds a discussion to the forum specified by it's name with the provided table data (usually Subject and Message).
+     * The step begins from the forum's course page.
+     *
+     * @Given /^I add a new discussion to "(?P<forum_name_string>(?:[^"]|\\")*)" forum inline with:$/
+     * @param string $forumname
+     * @param TableNode $table
+     */
+    public function i_add_a_forum_discussion_to_forum_inline_with($forumname, TableNode $table) {
+        $this->add_new_discussion_inline($forumname, $table, get_string('addanewdiscussion', 'forum'));
+    }
+
     /**
      * Adds a reply to the specified post of the specified forum. The step begins from the forum's page or from the forum's course page.
      *
@@ -101,6 +113,21 @@ class behat_mod_forum extends behat_base {
         $this->execute('behat_forms::press_button', get_string('submit', 'core'));
     }
 
+    /**
+     * Navigates to a particular discussion page
+     *
+     * @Given /^I navigate to post "(?P<post_subject_string>(?:[^"]|\\")*)" in "(?P<forum_name_string>(?:[^"]|\\")*)" forum$/
+     * @param string $postsubject The subject of the post
+     * @param string $forumname The forum name
+     */
+    public function i_navigate_to_post_in_forum($postsubject, $forumname) {
+
+        // Navigate to forum discussion.
+        $this->execute('behat_general::click_link', $this->escape($forumname));
+        $this->execute('behat_general::click_link', $this->escape($postsubject));
+    }
+
+
     /**
      * Returns the steps list to add a new discussion to a forum.
      *
@@ -116,7 +143,36 @@ class behat_mod_forum extends behat_base {
         // Navigate to forum.
         $this->execute('behat_general::click_link', $this->escape($forumname));
         $this->execute('behat_general::click_link', $buttonstr);
+        $this->execute('behat_forms::press_button', get_string('advanced'));
+
+        $this->fill_new_discussion_form($table);
+    }
+
+    /**
+     * Returns the steps list to add a new discussion to a forum inline.
+     *
+     * Abstracts add a new topic and add a new discussion, as depending
+     * on the forum type the button string changes.
+     *
+     * @param string $forumname
+     * @param TableNode $table
+     * @param string $buttonstr
+     */
+    protected function add_new_discussion_inline($forumname, TableNode $table, $buttonstr) {
+
+        // Navigate to forum.
+        $this->execute('behat_general::click_link', $this->escape($forumname));
+        $this->execute('behat_general::click_link', $buttonstr);
+        $this->fill_new_discussion_form($table);
+    }
 
+    /**
+     * Fill in the forum's post form and submit. It assumes you've already navigated and enabled the form for view.
+     *
+     * @param TableNode $table
+     * @throws coding_exception
+     */
+    protected function fill_new_discussion_form(TableNode $table) {
         // Fill form and post.
         $this->execute('behat_forms::i_set_the_following_fields_to_these_values', $table);
         $this->execute('behat_forms::press_button', get_string('posttoforum', 'forum'));
diff --git a/mod/forum/tests/behat/discussion_lock.feature b/mod/forum/tests/behat/discussion_lock.feature
new file mode 100644 (file)
index 0000000..0fcce46
--- /dev/null
@@ -0,0 +1,49 @@
+@mod @mod_forum @javascript
+Feature: As a teacher, you can manually lock individual discussions when viewing the discussion
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Discussion 1 |
+      | Message | Discussion contents 1, first message |
+    And I reply "Discussion 1" post from "Test forum name" forum with:
+      | Subject | Reply 1 to discussion 1 |
+      | Message | Discussion contents 1, second message |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Discussion 2 |
+      | Message | Discussion contents 2, first message |
+    And I reply "Discussion 2" post from "Test forum name" forum with:
+      | Subject | Reply 1 to discussion 2 |
+      | Message | Discussion contents 2, second message |
+    And I log out
+
+  Scenario: Lock a discussion and view
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I navigate to post "Discussion 1" in "Test forum name" forum
+    Then "Lock" "link" should be visible
+    And I follow "Lock"
+    Then "a[@title='Lock']" "css_element" should not be visible
+    Then "Locked" "link" should be visible
+    Then I should see "This discussion has been locked so you can no longer reply to it."
+    And I follow "Discussion 2"
+    Then I should not see "This discussion has been locked so you can no longer reply to it."
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I navigate to post "Discussion 1" in "Test forum name" forum
+    Then I should see "This discussion has been locked so you can no longer reply to it."
+    And "Reply" "link" should not be visible
index 8af5d58..8c87286 100644 (file)
@@ -51,6 +51,7 @@ Feature: Edited forum posts handle tags correctly
     And I am on "Course 1" course homepage
     And I follow "Test forum"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And I expand all fieldsets
     And I open the autocomplete suggestions list
     And I should see "OT1" in the ".form-autocomplete-suggestions" "css_element"
index 3381138..ce7b007 100644 (file)
@@ -30,12 +30,14 @@ Feature: A user can control their default discussion subscription settings
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then "input[name=discussionsubscribe][checked=checked]" "css_element" should exist
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And "input[name=discussionsubscribe]:not([checked=checked])" "css_element" should exist
 
   Scenario: Replying to an existing discussion in an optional forum follows user preferences
@@ -73,12 +75,14 @@ Feature: A user can control their default discussion subscription settings
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then "input[name=discussionsubscribe][checked=checked]" "css_element" should exist
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And "input[name=discussionsubscribe][checked=checked]" "css_element" should exist
 
   Scenario: Replying to an existing discussion in an automatic forum follows forum subscription
index 7ec365e..aa5164e 100644 (file)
@@ -57,6 +57,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -69,6 +70,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I follow "Standard forum name"
     And I select "Group A" from the "Separate groups" singleselect
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then I should see "Post a copy to all groups"
     And I set the following fields to these values:
       | Subject | Teacher 1 -> Group B  |
@@ -99,6 +101,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I follow "Standard forum name"
     And I select "Group A" from the "Separate groups" singleselect
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then I should see "Post a copy to all groups"
     And I set the following fields to these values:
       | Subject | Teacher 1 -> Group C  |
@@ -128,6 +131,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And I set the following fields to these values:
       | Subject                   | Teacher 1 -> Post to all  |
       | Message                   | Teacher 1 -> Post to all  |
@@ -174,6 +178,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     When I follow "Standard forum name"
     And I select "Group A" from the "Separate groups" singleselect
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -194,6 +199,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     # Now try posting in Group A (starting at Group B)
     And I select "Group B" from the "Separate groups" singleselect
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -221,6 +227,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -235,6 +242,7 @@ Feature: Posting to all groups in a separate group discussion is restricted to u
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
index 06c0ff7..c7d5335 100644 (file)
@@ -59,12 +59,14 @@ Feature: Posting to groups in a separate group discussion when restricted to gro
     And I am on "Course 1" course homepage
     And I follow "Multiple groups forum"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should contain "All participants"
     And the "Group" select box should contain "G1G1"
     And the "Group" select box should contain "G1G2"
     And I am on "Course 1" course homepage
     And I follow "Single groups forum"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And the "Group" select box should contain "All participants"
     And the "Group" select box should contain "G2G1"
     And I should not see "Post a copy to all groups"
@@ -74,11 +76,13 @@ Feature: Posting to groups in a separate group discussion when restricted to gro
     And I am on "Course 1" course homepage
     And I follow "Multiple groups forum"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "G1G1"
     And the "Group" select box should contain "G1G2"
     And I am on "Course 1" course homepage
     And I follow "Single groups forum"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And I should see "G2G1"
     And "Group" "select" should not exist
index 02975aa..f9452a3 100644 (file)
@@ -48,6 +48,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then the "Group" select box should contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -60,6 +61,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     And I follow "Standard forum name"
     And I select "Group A" from the "Visible groups" singleselect
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then I should see "Post a copy to all groups"
     And I set the following fields to these values:
       | Subject | Teacher 1 -> Group B  |
@@ -90,6 +92,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     And I follow "Standard forum name"
     And I select "Group A" from the "Visible groups" singleselect
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     Then I should see "Post a copy to all groups"
     And I set the following fields to these values:
       | Subject | Teacher 1 -> Group C  |
@@ -119,6 +122,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     And I am on "Course 1" course homepage
     And I follow "Standard forum name"
     When I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And I set the following fields to these values:
       | Subject                   | Teacher 1 -> Post to all  |
       | Message                   | Teacher 1 -> Post to all  |
@@ -156,6 +160,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     When I follow "Standard forum name"
     Then I should see "Group A"
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And I should see "Group A"
     And I should not see "Group B"
     And I should not see "Group C"
@@ -174,6 +179,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     When I follow "Standard forum name"
     And I select "Group A" from the "Visible groups" singleselect
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
@@ -194,6 +200,7 @@ Feature: Posting to all groups in a visible group discussion is restricted to us
     # Now try posting in Group A (starting at Group B)
     And I select "Group B" from the "Visible groups" singleselect
     And I click on "Add a new discussion topic" "link"
+    And I click on "Advanced" "button"
     And the "Group" select box should not contain "All participants"
     And the "Group" select box should contain "Group A"
     And the "Group" select box should contain "Group B"
index 1cb03f6..5674237 100644 (file)
@@ -72,7 +72,8 @@ class mod_forum_entities_discussion_summary_testcase extends advanced_testcase {
             time(),
             0,
             0,
-            false
+            false,
+            0
         );
         $firstpost = new post_entity(
             1,
index 356aae2..c3bb6f8 100644 (file)
@@ -56,7 +56,8 @@ class mod_forum_entities_discussion_testcase extends advanced_testcase {
             $time,
             0,
             0,
-            false
+            false,
+            0
         );
         $firstpost = new post_entity(
             4,
@@ -147,7 +148,8 @@ class mod_forum_entities_discussion_testcase extends advanced_testcase {
             $basetime,
             $starttime,
             $endtime,
-            false
+            false,
+            0
         );
         $CFG->forum_enabletimedposts = true;
 
index 0bbfb62..829d29f 100644 (file)
@@ -58,7 +58,8 @@ class mod_forum_entities_forum_testcase extends advanced_testcase {
             $time,
             0,
             0,
-            false
+            false,
+            0
         );
 
         $past = time() - 100;
index 7eb28b6..7290395 100644 (file)
@@ -87,7 +87,8 @@ class mod_forum_exporters_discussion_testcase extends advanced_testcase {
             $now,
             0,
             0,
-            false
+            false,
+            0
         );
 
         $exporter = new discussion_exporter($discussion, [
index 96a4eeb..4713818 100644 (file)
@@ -985,6 +985,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                 'pinned' => FORUM_DISCUSSION_UNPINNED,
                 'locked' => false,
                 'canreply' => false,
+                'canlock' => false,
             );
 
         // Call the external function passing forum id.
@@ -1025,6 +1026,11 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         } catch (moodle_exception $e) {
             $this->assertEquals('requireloginerror', $e->errorcode);
         }
+
+        $this->setAdminUser();
+        $discussions = mod_forum_external::get_forum_discussions_paginated($forum1->id);
+        $discussions = external_api::clean_returnvalue(mod_forum_external::get_forum_discussions_paginated_returns(), $discussions);
+        $this->assertTrue($discussions['discussions'][0]['canlock']);
     }
 
     /**
@@ -1422,6 +1428,56 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
 
     }
 
+    /*
+     * Test set_lock_state.
+     */
+    public function test_set_lock_state() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Create courses to add the modules.
+        $course = self::getDataGenerator()->create_course();
+        $user = self::getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        // First forum with tracking off.
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->type = 'news';
+        $forum = self::getDataGenerator()->create_module('forum', $record);
+
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->userid = $user->id;
+        $record->forum = $forum->id;
+        $discussion = self::getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+
+        // User who is a student.
+        self::setUser($user);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        // Only a teacher should be able to lock a discussion.
+        try {
+            $result = mod_forum_external::set_lock_state($forum->id, $discussion->id, 0);
+            $this->fail('Exception expected due to missing capability.');
+        } catch (moodle_exception $e) {
+            $this->assertEquals('errorcannotlock', $e->errorcode);
+        }
+
+        // Set the lock.
+        self::setAdminUser();
+        $result = mod_forum_external::set_lock_state($forum->id, $discussion->id, 0);
+        $result = external_api::clean_returnvalue(mod_forum_external::set_lock_state_returns(), $result);
+        $this->assertTrue($result['locked']);
+        $this->assertNotEquals(0, $result['times']['locked']);
+
+        // Unset the lock.
+        $result = mod_forum_external::set_lock_state($forum->id, $discussion->id, time());
+        $result = external_api::clean_returnvalue(mod_forum_external::set_lock_state_returns(), $result);
+        $this->assertFalse($result['locked']);
+        $this->assertEquals('0', $result['times']['locked']);
+    }
+
     /*
      * Test can_add_discussion. A basic test since all the API functions are already covered by unit tests.
      */
index a2134d7..d8a9a7c 100644 (file)
@@ -193,6 +193,10 @@ class mod_forum_generator extends testing_module_generator {
             $record['pinned'] = FORUM_DISCUSSION_UNPINNED;
         }
 
+        if (!isset($record['timelocked'])) {
+            $record['timelocked'] = 0;
+        }
+
         if (isset($record['mailed'])) {
             $mailed = $record['mailed'];
         }
index 65479ba..823140a 100644 (file)
@@ -1378,4 +1378,48 @@ class mod_forum_subscriptions_testcase extends advanced_testcase {
 
         $this->assertEquals($expect, \mod_forum\subscriptions::is_subscribable($forum));
     }
+
+    public function test_get_user_default_subscription() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+        $context = \context_course::instance($course->id);
+        $options['course'] = $course->id;
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+        $cm = get_coursemodule_from_instance("forum", $forum->id, $course->id);
+
+        // Create a user enrolled in the course as a student.
+        list($author, $student) = $this->helper_create_users($course, 2, 'student');
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // A guest user.
+        $this->setUser(0);
+        $this->assertFalse((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, $discussion->id));
+        $this->assertFalse((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, null));
+
+        // A user enrolled in the course.
+        $this->setUser($author->id);
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, $discussion->id));
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, null));
+
+        // Subscribption disabled.
+        $this->setUser($student->id);
+        \mod_forum\subscriptions::set_subscription_mode($forum->id, FORUM_DISALLOWSUBSCRIBE);
+        $forum = $DB->get_record('forum', array('id' => $forum->id));
+        $this->assertFalse((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, $discussion->id));
+        $this->assertFalse((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, null));
+
+        \mod_forum\subscriptions::set_subscription_mode($forum->id, FORUM_FORCESUBSCRIBE);
+        $forum = $DB->get_record('forum', array('id' => $forum->id));
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, $discussion->id));
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, null));
+
+        // Admin user.
+        $this->setAdminUser();
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, $discussion->id));
+        $this->assertTrue((boolean)\mod_forum\subscriptions::get_user_default_subscription($forum, $context, $cm, null));
+    }
 }
index b84b45c..4672f96 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019040400;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2019040402;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2018112800;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)
index 62abed9..f59d94b 100644 (file)
@@ -141,9 +141,9 @@ class lesson_page_type_shortanswer extends lesson_page {
                     $ignorecase = 'i';
                 }
             } else {
-                $expectedanswer = str_replace('*', '#####', $expectedanswer);
+                $expectedanswer = str_replace('*', '%@@%@@%', $expectedanswer);
                 $expectedanswer = preg_quote($expectedanswer, '/');
-                $expectedanswer = str_replace('#####', '.*', $expectedanswer);
+                $expectedanswer = str_replace('%@@%@@%', '.*', $expectedanswer);
             }
             // see if user typed in any of the correct answers
             if ((!$this->lesson->custom && $this->lesson->jumpto_is_correct($this->properties->id, $answer->jumpto)) or ($this->lesson->custom && $answer->score > 0) ) {
index b2781eb..45daf6e 100644 (file)
@@ -54,7 +54,7 @@
     <div class="tool-card-content">
         <div class="tool-card-header">
             <div class="tool-card-subheader">
-                <div class="tag
+                <div class="badge
                             {{#state.pending}}badge-info{{/state.pending}}
                             {{#state.configured}}badge-success{{/state.configured}}
                             {{#state.rejected}}badge-danger{{/state.rejected}}
index 649e410..d849512 100644 (file)
@@ -34,7 +34,7 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
  * want to say that access is allowed, or explain the reason why it is block.
  * Therefore instead of is_access_allowed(...) we have prevent_access(...) that
  * return false if access is permitted, or a string explanation (which is treated
- * as true) if access should be blocked. Slighly unnatural, but acutally the easist
+ * as true) if access should be blocked. Slighly unnatural, but actually the easiest
  * way to implement this.
  *
  * @copyright 2009 Tim Hunt
index 549e604..3c31ba2 100644 (file)
@@ -285,7 +285,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
 
         if (!property_exists($data, 'slot')) {
             // There was a question_instance in the backup file for a question
-            // that was not acutally in the quiz. Drop it.
+            // that was not actually in the quiz. Drop it.
             $this->log('question ' . $data->questionid . ' was associated with quiz ' .
                     $this->get_new_parentid('quiz') . ' but not actually used. ' .
                     'The instance has been ignored.', backup::LOG_INFO);
index ccd91fd..c674b7d 100644 (file)
@@ -54,7 +54,7 @@ require_once(__DIR__ . '/../interactive/behaviour.php');
  *  - For the last part, they were wrong at the last try, so 0/3.
  * So, total mark is 6/12. (Really, a fraction of 0.5.)
  *
- * Of course, the details of the grading are acutally up to the particular
+ * Of course, the details of the grading are actually up to the particular
  * question type. The point is that the final grade can take into account all
  * of the tries the student made.
  *
index c0bb6fd..3781806 100644 (file)
@@ -519,7 +519,7 @@ class question_attempt_pending_step extends question_attempt_step {
 
     /**
      * If as a result of processing this step, you identify that this variant of the
-     * question is acutally identical to the another one, you may change the
+     * question is actually identical to the another one, you may change the
      * variant number recorded, in order to give better statistics. For an example
      * see qbehaviour_opaque.
      * @param int $variant the new variant number.
@@ -571,7 +571,7 @@ class question_attempt_step_read_only extends question_attempt_step {
 /**
  * A null {@link question_attempt_step} returned from
  * {@link question_attempt::get_last_step()} etc. when a an attempt has just been
- * created and there is no acutal step.
+ * created and there is no actual step.
  *
  * @copyright  2009 The Open University
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
index 02c2a44..b7614db 100644 (file)
@@ -16,7 +16,7 @@
 
 /**
  * This file contains test helper code for testing the upgrade to the new
- * question engine. The acutal tests are organised by question type in files
+ * question engine. The actual tests are organised by question type in files
  * like question/type/truefalse/tests/upgradelibnewqe_test.php.
  *
  * @package    moodlecore
index a37d419..962c56f 100644 (file)
@@ -261,7 +261,7 @@ if ($mform->is_cancelled()) {
     // Ensure we redirect back to the category the question is being saved into.
     $returnurl->param('category', $fromform->category);
 
-    // We are acutally saving the question.
+    // We are actually saving the question.
     if (!empty($question->id)) {
         question_require_capability_on($question, 'edit');
     } else {
index 9eb63ad..900b7ed 100644 (file)
@@ -128,36 +128,15 @@ class provider implements
             return;
         }
 
-        $params = [
-            'contextid' => $context->id,
-            'contextcourse' => CONTEXT_COURSE,
-        ];
-
-        $sql = "SELECT sud.userid
-                  FROM {stats_user_daily} sud
-                  JOIN {context} ctx
-                       ON ctx.instanceid = sud.courseid
-                       AND ctx.contextlevel = :contextcourse
-                 WHERE ctx.id = :contextid";
+        $params = ['courseid' => $context->instanceid];
 
+        $sql = "SELECT userid FROM {stats_user_daily} WHERE courseid = :courseid";
         $userlist->add_from_sql('userid', $sql, $params);
 
-        $sql = "SELECT suw.userid
-                  FROM {stats_user_weekly} suw
-                  JOIN {context} ctx
-                       ON ctx.instanceid = suw.courseid
-                       AND ctx.contextlevel = :contextcourse
-                 WHERE ctx.id = :contextid";
-
+        $sql = "SELECT userid FROM {stats_user_weekly} WHERE courseid = :courseid";
         $userlist->add_from_sql('userid', $sql, $params);
 
-        $sql = "SELECT sum.userid
-                  FROM {stats_user_monthly} sum
-                  JOIN {context} ctx
-                       ON ctx.instanceid = sum.courseid
-                       AND ctx.contextlevel = :contextcourse
-                 WHERE ctx.id = :contextid";
-
+        $sql = "SELECT userid FROM {stats_user_monthly} WHERE courseid = :courseid";
         $userlist->add_from_sql('userid', $sql, $params);
     }
 
index 7fe792c..183bca2 100644 (file)
@@ -27,10 +27,8 @@ $positions: static, relative, absolute, fixed, sticky;
   z-index: $zindex-fixed;
 }
 
-// .sticky-top {
-//   @supports (position: sticky) {
-//     position: sticky;
-//     top: 0;
-//     z-index: $zindex-sticky;
-//   }
-// }
+.sticky-top {
+  position: sticky;
+  top: 0;
+  z-index: $zindex-sticky;
+}
index e0e162e..16015d5 100644 (file)
@@ -69,6 +69,7 @@ select {
 
         thead .header th,
         tbody .discussion td {
+            &.discussionlock,
             &.discussionsubscription {
                 width: 16px;
                 padding-left: 0.5em;
@@ -83,12 +84,14 @@ select {
             }
 
             .discussionsubscription,
+            .discussionlock,
             .replies {
                 text-align: center;
             }
 
             .topic,
             .discussionsubscription,
+            .discussionlock,
             .topic.starter,
             .replies,
             .lastpost {
index f8968cd..0e6fadd 100644 (file)
@@ -7259,6 +7259,11 @@ button.bg-dark:focus {
   left: 0;
   z-index: 1030; }
 
+.sticky-top {
+  position: sticky;
+  top: 0;
+  z-index: 1020; }
+
 .sr-only {
   position: absolute;
   width: 1px;
@@ -15023,7 +15028,8 @@ select {
 .path-mod-forum .forumheaderlist thead .header.lastpost {
   text-align: right; }
 
-.path-mod-forum .forumheaderlist thead .header th.discussionsubscription,
+.path-mod-forum .forumheaderlist thead .header th.discussionlock, .path-mod-forum .forumheaderlist thead .header th.discussionsubscription,
+.path-mod-forum .forumheaderlist tbody .discussion td.discussionlock,
 .path-mod-forum .forumheaderlist tbody .discussion td.discussionsubscription {
   width: 16px;
   padding-left: 0.5em;
@@ -15034,11 +15040,13 @@ select {
   white-space: normal; }
 
 .path-mod-forum .forumheaderlist .discussion .discussionsubscription,
+.path-mod-forum .forumheaderlist .discussion .discussionlock,
 .path-mod-forum .forumheaderlist .discussion .replies {
   text-align: center; }
 
 .path-mod-forum .forumheaderlist .discussion .topic,
 .path-mod-forum .forumheaderlist .discussion .discussionsubscription,
+.path-mod-forum .forumheaderlist .discussion .discussionlock,
 .path-mod-forum .forumheaderlist .discussion .topic.starter,
 .path-mod-forum .forumheaderlist .discussion .replies,
 .path-mod-forum .forumheaderlist .discussion .lastpost {
index c3e7f17..64dbcba 100644 (file)
@@ -7492,6 +7492,11 @@ button.bg-dark:focus {
   left: 0;
   z-index: 1030; }
 
+.sticky-top {
+  position: sticky;
+  top: 0;
+  z-index: 1020; }
+
 .sr-only {
   position: absolute;
   width: 1px;
@@ -15280,7 +15285,8 @@ select {
 .path-mod-forum .forumheaderlist thead .header.lastpost {
   text-align: right; }
 
-.path-mod-forum .forumheaderlist thead .header th.discussionsubscription,
+.path-mod-forum .forumheaderlist thead .header th.discussionlock, .path-mod-forum .forumheaderlist thead .header th.discussionsubscription,
+.path-mod-forum .forumheaderlist tbody .discussion td.discussionlock,
 .path-mod-forum .forumheaderlist tbody .discussion td.discussionsubscription {
   width: 16px;
   padding-left: 0.5em;
@@ -15291,11 +15297,13 @@ select {
   white-space: normal; }
 
 .path-mod-forum .forumheaderlist .discussion .discussionsubscription,
+.path-mod-forum .forumheaderlist .discussion .discussionlock,
 .path-mod-forum .forumheaderlist .discussion .replies {
   text-align: center; }
 
 .path-mod-forum .forumheaderlist .discussion .topic,
 .path-mod-forum .forumheaderlist .discussion .discussionsubscription,
+.path-mod-forum .forumheaderlist .discussion .discussionlock,
 .path-mod-forum .forumheaderlist .discussion .topic.starter,
 .path-mod-forum .forumheaderlist .discussion .replies,
 .path-mod-forum .forumheaderlist .discussion .lastpost {
index 536fa20..97303aa 100644 (file)
@@ -610,9 +610,6 @@ function user_get_user_details_courses($user) {
     global $USER;
     $userdetails = null;
 
-    // Get the courses that the user is enrolled in (only active).
-    $courses = enrol_get_users_courses($user->id, true);
-
     $systemprofile = false;
     if (can_view_user_details_cap($user) || ($user->id == $USER->id) || has_coursecontact_role($user->id)) {
         $systemprofile = true;
@@ -623,8 +620,10 @@ function user_get_user_details_courses($user) {
         $userdetails = user_get_user_details($user, null);
     } else {
         // Try through course profile.
+        // Get the courses that the user is enrolled in (only active).
+        $courses = enrol_get_users_courses($user->id, true);
         foreach ($courses as $course) {
-            if (can_view_user_details_cap($user, $course) || ($user->id == $USER->id) || has_coursecontact_role($user->id)) {
+            if (user_can_view_profile($user, $course)) {
                 $userdetails = user_get_user_details($user, $course);
             }
         }
index 0eb3b97..d1ac6c1 100644 (file)
@@ -22,7 +22,6 @@ Feature: Deleting users
       | user4    | C1     | student        |
     And the following config values are set as admin:
       | messaging | 1 |
-      | messagingallusers | 1 |
 
   @javascript
   Scenario: Deleting one user at a time
index 18d9bf0..bec3170 100644 (file)
@@ -23,7 +23,6 @@ Feature: Access to full profiles of users
       | student3 | C2 | student |
     And the following config values are set as admin:
       | messaging | 1 |
-      | messagingallusers | 1 |
 
   Scenario: Viewing full profiles with default settings
     When I log in as "student1"
@@ -72,53 +71,47 @@ Feature: Access to full profiles of users
     When I follow "Profile" in the user menu
     Then I should see "First access to site"
 
-# TODO: These need to be re-written now that the messaging UI has changed
-# because you can no longer use the messaging UI to view other user's profiles.
-#
-#  @javascript
-#  Scenario: Viewing full profiles of someone with the course contact role
-#    Given I log in as "admin"
-#    And I navigate to "Appearance > Courses" in site administration
-#    And I set the following fields to these values:
-#      | Course creator | 1 |
-#    And I press "Save changes"
-#    And I navigate to "Users > Permissions > Assign system roles" in site administration
-#    And I follow "Course creator"
-#    And I click on "//div[@class='userselector']/descendant::option[contains(., 'Student 3')]" "xpath_element"
-#    And I press "Add"
-#    And I log out
-#    When I log in as "student1"
-#    And I view the "Student 3" contact in the message area
-#    And I click on ".profile-picture" "css_element"
-#    Then I should see "First access to site"
+  @javascript
+  Scenario: Viewing full profiles of someone with the course contact role
+    Given I log in as "admin"
+    And I navigate to "Appearance > Courses" in site administration
+    And I set the following fields to these values:
+      | Course creator | 1 |
+    And I press "Save changes"
+    And I navigate to "Users > Permissions > Assign system roles" in site administration
+    And I follow "Course creator"
+    And I click on "//div[@class='userselector']/descendant::option[contains(., 'Student 3')]" "xpath_element"
+    And I press "Add"
+    And I log out
+    When I log in as "student1"
+    And I view the "Student 3" contact in the message area
+    Then I should see "First access to site"
 
-#  @javascript
-#  Scenario: View full profiles of someone in the same group in a course with separate groups.
-#    Given I log in as "admin"
-#    And I am on "Course 1" course homepage
-#    And I navigate to "Edit settings" in current page administration
-#    And I set the following fields to these values:
-#      | Group mode | Separate groups |
-#      | Force group mode | Yes |
-#    And I press "Save and display"
-#    And I log out
-#    When I log in as "student1"
-#    And I view the "Student 2" contact in the message area
-#    And I click on ".profile-picture" "css_element"
-#    And I should not see "First access to site"
-#    And I should see "The details of this user are not available to you"
-#    And I log out
-#    And I log in as "admin"
-#    And I am on "Course 1" course homepage
-#    And I navigate to "Users > Groups" in current page administration
-#    And I press "Create group"
-#    And I set the following fields to these values:
-#      | Group name | Group 1 |
-#    And I press "Save changes"
-#    And I add "Student 1 (student1@example.com)" user to "Group 1" group members
-#    And I add "Student 2 (student2@example.com)" user to "Group 1" group members
-#    And I log out
-#    And I log in as "student1"
-#    And I view the "Student 2" contact in the message area
-#    And I click on ".profile-picture" "css_element"
-#    Then I should see "First access to site"
+  @javascript
+  Scenario: View full profiles of someone in the same group in a course with separate groups.
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Group mode | Separate groups |
+      | Force group mode | Yes |
+    And I press "Save and display"
+    And I log out
+    When I log in as "student1"
+    And I view the "Student 2" contact in the message area
+    And I should not see "First access to site"
+    And I should see "The details of this user are not available to you"
+    And I log out
+    And I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Groups" in current page administration
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group 1 |
+    And I press "Save changes"
+    And I add "Student 1 (student1@example.com)" user to "Group 1" group members
+    And I add "Student 2 (student2@example.com)" user to "Group 1" group members
+    And I log out
+    And I log in as "student1"
+    And I view the "Student 2" contact in the message area
+    Then I should see "First access to site"
index ef2e92f..c8327f3 100644 (file)
@@ -48,6 +48,7 @@ class core_userliblib_testcase extends advanced_testcase {
         // Create user and modify user profile.
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
 
         $course1 = $this->getDataGenerator()->create_course();
         $coursecontext = context_course::instance($course1->id);
@@ -72,6 +73,68 @@ class core_userliblib_testcase extends advanced_testcase {
         $this->assertEquals(fullname($user2), $result['fullname']);
         $this->assertEquals($course1->id, $result['enrolledcourses'][0]['id']);
 
+        // Get user2 details as a user who doesn't share any course with user2.
+        $this->setUser($user3);
+        $result = user_get_user_details_courses($user2);
+        $this->assertNull($result);
+    }
+
+    /**
+     * Verify return when course groupmode set to 'no groups'.
+     */
+    public function test_user_get_user_details_courses_groupmode_nogroups() {
+        $this->resetAfterTest();
+
+        // Enrol 2 users into a course with groupmode set to 'no groups'.
+        // Profiles should be visible.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course((object) ['groupmode' => 0]);
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $this->setUser($user1);
+        $userdetails = user_get_user_details_courses($user2);
+        $this->assertInternalType('array', $userdetails);
+        $this->assertEquals($user2->id, $userdetails['id']);
+    }
+
+    /**
+     * Verify return when course groupmode set to 'separate groups'.
+     */
+    public function test_user_get_user_details_courses_groupmode_separate() {
+        $this->resetAfterTest();
+
+        // Enrol 2 users into a course with groupmode set to 'separate groups'.
+        // The users are not in any groups, so profiles should be hidden (same as if they were in separate groups).
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course((object) ['groupmode' => 1]);
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $this->setUser($user1);
+        $this->assertNull(user_get_user_details_courses($user2));
+    }
+
+    /**
+     * Verify return when course groupmode set to 'visible groups'.
+     */
+    public function test_user_get_user_details_courses_groupmode_visible() {
+        $this->resetAfterTest();
+
+        // Enrol 2 users into a course with groupmode set to 'visible groups'.
+        // The users are not in any groups, and profiles should be visible because of the groupmode.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course((object) ['groupmode' => 2]);
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $this->setUser($user1);
+        $userdetails = user_get_user_details_courses($user2);
+        $this->assertInternalType('array', $userdetails);
+        $this->assertEquals($user2->id, $userdetails['id']);
     }
 
     /**
index 95ba7b0..8346556 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2019042300.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2019042300.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.