diff --git a/mod/forum/classes/local/builders/exported_discussion_summaries.php b/mod/forum/classes/local/builders/exported_discussion_summaries.php index 3064981101c10..fd430a3ef5f46 100644 --- a/mod/forum/classes/local/builders/exported_discussion_summaries.php +++ b/mod/forum/classes/local/builders/exported_discussion_summaries.php @@ -32,6 +32,7 @@ use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory; use mod_forum\local\factories\exporter as exporter_factory; use mod_forum\local\factories\vault as vault_factory; +use mod_forum\local\factories\manager as manager_factory; use rating_manager; use renderer_base; use stdClass; @@ -67,6 +68,9 @@ class exported_discussion_summaries { /** @var vault_factory $vaultfactory Vault factory */ private $vaultfactory; + /** @var manager_factory $managerfactory Manager factory */ + private $managerfactory; + /** @var rating_manager $ratingmanager Rating manager */ private $ratingmanager; @@ -84,12 +88,14 @@ public function __construct( legacy_data_mapper_factory $legacydatamapperfactory, exporter_factory $exporterfactory, vault_factory $vaultfactory, + manager_factory $managerfactory, rating_manager $ratingmanager ) { $this->renderer = $renderer; $this->legacydatamapperfactory = $legacydatamapperfactory; $this->exporterfactory = $exporterfactory; $this->vaultfactory = $vaultfactory; + $this->managerfactory = $managerfactory; $this->ratingmanager = $ratingmanager; } @@ -108,16 +114,18 @@ public function build( forum_entity $forum, array $discussions ) : array { + $capabilitymanager = $this->managerfactory->get_capability_manager($forum); + $canseeanyprivatereply = $capabilitymanager->can_view_any_private_reply($user); $discussionids = array_keys($discussions); $postvault = $this->vaultfactory->get_post_vault(); - $posts = $postvault->get_from_discussion_ids($discussionids); + $posts = $postvault->get_from_discussion_ids($user, $discussionids, $canseeanyprivatereply); $groupsbyid = $this->get_groups_available_in_forum($forum); $groupsbyauthorid = $this->get_author_groups_from_posts($posts, $forum); - $replycounts = $postvault->get_reply_count_for_discussion_ids($discussionids); - $latestposts = $postvault->get_latest_post_id_for_discussion_ids($discussionids); + $replycounts = $postvault->get_reply_count_for_discussion_ids($user, $discussionids, $canseeanyprivatereply); + $latestposts = $postvault->get_latest_post_id_for_discussion_ids($user, $discussionids, $canseeanyprivatereply); $unreadcounts = []; @@ -125,7 +133,7 @@ public function build( $forumrecord = $forumdatamapper->to_legacy_object($forum); if (forum_tp_can_track_forums($forumrecord)) { - $unreadcounts = $postvault->get_unread_count_for_discussion_ids($user, $discussionids); + $unreadcounts = $postvault->get_unread_count_for_discussion_ids($user, $discussionids, $canseeanyprivatereply); } $summaryexporter = $this->exporterfactory->get_discussion_summaries_exporter( diff --git a/mod/forum/classes/local/builders/exported_posts.php b/mod/forum/classes/local/builders/exported_posts.php index 1f5a7b77df5ca..2d8934d971c51 100644 --- a/mod/forum/classes/local/builders/exported_posts.php +++ b/mod/forum/classes/local/builders/exported_posts.php @@ -107,6 +107,9 @@ public function __construct( * to load the additional resources as efficiently as possible but there is no way around some of * the additional overhead. * + * Note: Some posts will be removed as part of the build process according to capabilities. + * A one-to-one mapping should not be expected. + * * @param stdClass $user The user to export the posts for. * @param forum_entity[] $forums A list of all forums that each of the $discussions belong to * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to diff --git a/mod/forum/classes/local/data_mappers/legacy/post.php b/mod/forum/classes/local/data_mappers/legacy/post.php index 5cbd2d4bdcac1..fcced5f71a7f7 100644 --- a/mod/forum/classes/local/data_mappers/legacy/post.php +++ b/mod/forum/classes/local/data_mappers/legacy/post.php @@ -59,7 +59,8 @@ public function to_legacy_objects(array $posts) : array { 'attachment' => $post->has_attachments(), 'totalscore' => $post->get_total_score(), 'mailnow' => $post->should_mail_now(), - 'deleted' => $post->is_deleted() + 'deleted' => $post->is_deleted(), + 'privatereplyto' => $post->get_private_reply_recipient_id(), ]; }, $posts); } diff --git a/mod/forum/classes/local/entities/post.php b/mod/forum/classes/local/entities/post.php index 6c394a6e482bd..f84053bd4ea39 100644 --- a/mod/forum/classes/local/entities/post.php +++ b/mod/forum/classes/local/entities/post.php @@ -65,6 +65,8 @@ class post { private $mailnow; /** @var bool $deleted Is the post deleted */ private $deleted; + /** @var int $privatereplyto The user being privately replied to */ + private $privatereplyto; /** * Constructor. @@ -84,6 +86,7 @@ class post { * @param int $totalscore Total score * @param bool $mailnow Should this post be mailed immediately * @param bool $deleted Is the post deleted + * @param int $privatereplyto Which user this reply is intended for in a private reply situation */ public function __construct( int $id, @@ -100,7 +103,8 @@ public function __construct( bool $hasattachments, int $totalscore, bool $mailnow, - bool $deleted + bool $deleted, + int $privatereplyto ) { $this->id = $id; $this->discussionid = $discussionid; @@ -117,6 +121,7 @@ public function __construct( $this->totalscore = $totalscore; $this->mailnow = $mailnow; $this->deleted = $deleted; + $this->privatereplyto = $privatereplyto; } /** @@ -263,6 +268,25 @@ public function is_deleted() : bool { return $this->deleted; } + /** + * Is this post private? + * + * @return bool + */ + public function is_private_reply() : bool { + return !empty($this->privatereplyto); + } + + /** + * Get the id of the user that this post was intended for. + * + * @return int + */ + public function get_private_reply_recipient_id() : int { + return $this->privatereplyto; + } + + /** * Get the post's age in seconds. * @@ -281,4 +305,14 @@ public function get_age() : int { public function is_owned_by_user(stdClass $user) : bool { return $this->get_author_id() == $user->id; } + + /** + * Check if the given post is a private reply intended for the given user. + * + * @param stdClass $user The user to check. + * @return bool + */ + public function is_private_reply_intended_for_user(stdClass $user) : bool { + return $this->get_private_reply_recipient_id() == $user->id; + } } diff --git a/mod/forum/classes/local/exporters/author.php b/mod/forum/classes/local/exporters/author.php index b4793c99f76ea..bd6e34d2bd87e 100644 --- a/mod/forum/classes/local/exporters/author.php +++ b/mod/forum/classes/local/exporters/author.php @@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die(); use mod_forum\local\entities\author as author_entity; +use mod_forum\local\exporters\group as group_exporter; use core\external\exporter; use renderer_base; @@ -81,31 +82,21 @@ protected static function define_other_properties() { 'null' => NULL_ALLOWED ], 'groups' => [ + 'type' => group_exporter::read_properties_definition(), 'multiple' => true, 'optional' => true, - 'type' => [ - 'id' => ['type' => PARAM_INT], - 'urls' => [ - 'type' => [ - 'image' => [ - 'type' => PARAM_URL, - 'optional' => true, - 'default' => null, - 'null' => NULL_ALLOWED - ] - ] - ] - ] ], 'urls' => [ 'type' => [ 'profile' => [ + 'description' => 'The URL for the use profile page', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'profileimage' => [ + 'description' => 'The URL for the use profile image', 'type' => PARAM_URL, 'optional' => true, 'default' => null, diff --git a/mod/forum/classes/local/exporters/group.php b/mod/forum/classes/local/exporters/group.php new file mode 100644 index 0000000000000..a639a794f14be --- /dev/null +++ b/mod/forum/classes/local/exporters/group.php @@ -0,0 +1,110 @@ +. + +/** + * Course Group exporter. + * + * @package mod_forum + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_forum\local\exporters; + +defined('MOODLE_INTERNAL') || die(); + +use mod_forum\local\entities\author as author_entity; +use core\external\exporter; +use renderer_base; +use stdClass; + +require_once($CFG->dirroot . '/mod/forum/lib.php'); + +/** + * Group exporter. + * + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class group extends exporter { + /** @var stdClass $group Group */ + private $group; + + /** + * Constructor. + * + * @param stdClass $group The group to export + * @param array $related The related data for the export. + */ + public function __construct(stdClass $group, array $related = []) { + $this->group = $group; + return parent::__construct([], $related); + } + + /** + * Return the list of additional properties. + * + * @return array + */ + protected static function define_other_properties() { + return [ + 'id' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'urls' => [ + 'type' => [ + 'image' => [ + 'description' => 'The URL for the group image', + 'type' => PARAM_URL, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ] + ], + ], + ]; + } + + /** + * Get the additional values to inject while exporting. + * + * @param renderer_base $output The renderer. + * @return array Keys are the property names, values are their values. + */ + protected function get_other_values(renderer_base $output) { + return [ + 'id' => $group->id, + 'urls' => [ + 'image' => $imageurl ? $imageurl->out(false) : null + ] + ]; + } + + /** + * Returns a list of objects that are related. + * + * @return array + */ + protected static function define_related() { + return [ + 'urlmanager' => 'mod_forum\local\managers\url', + 'context' => 'context' + ]; + } +} diff --git a/mod/forum/classes/local/exporters/post.php b/mod/forum/classes/local/exporters/post.php index 17c31b25667b2..73de263d2a26f 100644 --- a/mod/forum/classes/local/exporters/post.php +++ b/mod/forum/classes/local/exporters/post.php @@ -70,6 +70,7 @@ protected static function define_other_properties() { 'type' => [ 'export' => [ 'type' => PARAM_URL, + 'description' => 'The URL used to export the attachment', 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED @@ -80,6 +81,7 @@ protected static function define_other_properties() { 'type' => [ 'plagiarism' => [ 'type' => PARAM_RAW, + 'description' => 'The HTML source for the Plagiarism Response', 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED @@ -109,6 +111,7 @@ protected static function define_other_properties() { 'null' => NULL_ALLOWED ], 'isdeleted' => ['type' => PARAM_BOOL], + 'isprivatereply' => ['type' => PARAM_BOOL], 'haswordcount' => ['type' => PARAM_BOOL], 'wordcount' => [ 'type' => PARAM_INT, @@ -118,13 +121,41 @@ protected static function define_other_properties() { ], 'capabilities' => [ 'type' => [ - 'view' => ['type' => PARAM_BOOL], - 'edit' => ['type' => PARAM_BOOL], - 'delete' => ['type' => PARAM_BOOL], - 'split' => ['type' => PARAM_BOOL], - 'reply' => ['type' => PARAM_BOOL], - 'export' => ['type' => PARAM_BOOL], - 'controlreadstatus' => ['type' => PARAM_BOOL] + 'view' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can view the post', + ], + 'edit' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can edit the post', + ], + 'delete' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can delete the post', + ], + 'split' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can split the post', + ], + 'reply' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can reply to the post', + ], + 'export' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can export the post', + ], + 'controlreadstatus' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_ALLOWED, + 'description' => 'Whether the user can control the read status of the post', + ], ] ], 'urls' => [ @@ -133,60 +164,71 @@ protected static function define_other_properties() { 'null' => NULL_ALLOWED, 'type' => [ 'view' => [ + 'description' => 'The URL used to view the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'viewisolated' => [ + 'description' => 'The URL used to view the post in isolation', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'viewparent' => [ + 'description' => 'The URL used to view the parent of the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'edit' => [ + 'description' => 'The URL used to edit the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'delete' => [ + 'description' => 'The URL used to delete the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'split' => [ + 'description' => 'The URL used to split the discussion ' . + 'with the selected post being the first post in the new discussion', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'reply' => [ + 'description' => 'The URL used to reply to the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'export' => [ + 'description' => 'The URL used to export the post', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'markasread' => [ + 'description' => 'The URL used to mark the post as read', 'type' => PARAM_URL, 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED ], 'markasunread' => [ + 'description' => 'The URL used to mark the post as unread', 'type' => PARAM_URL, 'optional' => true, 'default' => null, @@ -210,14 +252,40 @@ protected static function define_other_properties() { 'null' => NULL_ALLOWED, 'multiple' => true, 'type' => [ - 'id' => ['type' => PARAM_INT], - 'tagid' => ['type' => PARAM_INT], - 'isstandard' => ['type' => PARAM_BOOL], - 'displayname' => ['type' => PARAM_TEXT], - 'flag' => ['type' => PARAM_BOOL], + 'id' => [ + 'type' => PARAM_INT, + 'description' => 'The ID of the Tag', + 'null' => NULL_NOT_ALLOWED, + ], + 'tagid' => [ + 'type' => PARAM_INT, + 'description' => 'The tagid', + 'null' => NULL_NOT_ALLOWED, + ], + 'isstandard' => [ + 'type' => PARAM_BOOL, + 'description' => 'Whether this is a standard tag', + 'null' => NULL_NOT_ALLOWED, + ], + 'displayname' => [ + 'type' => PARAM_TEXT, + 'description' => 'The display name of the tag', + 'null' => NULL_NOT_ALLOWED, + ], + 'flag' => [ + 'type' => PARAM_BOOL, + 'description' => 'Wehther this tag is flagged', + 'null' => NULL_NOT_ALLOWED, + ], 'urls' => [ + 'description' => 'URLs associated with the tag', + 'null' => NULL_NOT_ALLOWED, 'type' => [ - 'view' => ['type' => PARAM_URL] + 'view' => [ + 'type' => PARAM_URL, + 'description' => 'The URL to view the tag', + 'null' => NULL_NOT_ALLOWED, + ], ] ] ] @@ -231,19 +299,22 @@ protected static function define_other_properties() { 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED, - 'type' => PARAM_RAW + 'type' => PARAM_RAW, + 'description' => 'The HTML source to rate the post', ], 'taglist' => [ 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED, - 'type' => PARAM_RAW + 'type' => PARAM_RAW, + 'description' => 'The HTML source to view the list of tags', ], 'authorsubheading' => [ 'optional' => true, 'default' => null, 'null' => NULL_ALLOWED, - 'type' => PARAM_RAW + 'type' => PARAM_RAW, + 'description' => 'The HTML source to view the author details', ], ] ] @@ -269,6 +340,7 @@ protected function get_other_values(renderer_base $output) { $attachments = $this->related['attachments']; $includehtml = $this->related['includehtml']; $isdeleted = $post->is_deleted(); + $isprivatereply = $post->is_private_reply(); $hasrating = $rating != null; $hastags = !empty($tags); $discussionid = $post->get_discussion_id(); @@ -328,6 +400,7 @@ protected function get_other_values(renderer_base $output) { 'timecreated' => $timecreated, 'unread' => ($loadcontent && $readreceiptcollection) ? !$readreceiptcollection->has_user_read_post($user, $post) : null, 'isdeleted' => $isdeleted, + 'isprivatereply' => $isprivatereply, 'haswordcount' => $forum->should_display_word_count(), 'wordcount' => $forum->should_display_word_count() ? count_words($message) : null, 'capabilities' => [ diff --git a/mod/forum/classes/local/factories/builder.php b/mod/forum/classes/local/factories/builder.php index 20da60cce6c20..ef624b89a9a76 100644 --- a/mod/forum/classes/local/factories/builder.php +++ b/mod/forum/classes/local/factories/builder.php @@ -105,6 +105,7 @@ public function get_exported_discussion_summaries_builder() : exported_discussio $this->legacydatamapperfactory, $this->exporterfactory, $this->vaultfactory, + $this->managerfactory, $this->managerfactory->get_rating_manager() ); } diff --git a/mod/forum/classes/local/factories/entity.php b/mod/forum/classes/local/factories/entity.php index ee36f80f6b2d7..b7f1837960592 100644 --- a/mod/forum/classes/local/factories/entity.php +++ b/mod/forum/classes/local/factories/entity.php @@ -150,7 +150,8 @@ public function get_post_from_stdclass(stdClass $record) : post_entity { $record->attachment, $record->totalscore, $record->mailnow, - $record->deleted + $record->deleted, + $record->privatereplyto ); } diff --git a/mod/forum/classes/local/factories/renderer.php b/mod/forum/classes/local/factories/renderer.php index 2d450a0ed0fe2..dada4e0e4a358 100644 --- a/mod/forum/classes/local/factories/renderer.php +++ b/mod/forum/classes/local/factories/renderer.php @@ -431,7 +431,7 @@ private function get_detailed_discussion_list_renderer( $this->urlfactory, $template, $notifications, - function($discussions, $user, $forum) { + function($discussions, $user, $forum) use ($capabilitymanager) { $exportedpostsbuilder = $this->builderfactory->get_exported_posts_builder(); $discussionentries = []; $postentries = []; @@ -449,7 +449,12 @@ function($discussions, $user, $forum) { ); $postvault = $this->vaultfactory->get_post_vault(); - $discussionrepliescount = $postvault->get_reply_count_for_discussion_ids($discussionentriesids); + $canseeanyprivatereply = $capabilitymanager->can_view_any_private_reply($user); + $discussionrepliescount = $postvault->get_reply_count_for_discussion_ids( + $user, + $discussionentriesids, + $canseeanyprivatereply + ); array_walk($exportedposts['posts'], function($post) use ($discussionrepliescount) { $post->discussionrepliescount = $discussionrepliescount[$post->discussionid] ?? 0; diff --git a/mod/forum/classes/local/managers/capability.php b/mod/forum/classes/local/managers/capability.php index 47d9b8196ee7c..e34347af3fad1 100644 --- a/mod/forum/classes/local/managers/capability.php +++ b/mod/forum/classes/local/managers/capability.php @@ -316,7 +316,7 @@ public function can_post_in_discussion(stdClass $user, discussion_entity $discus } /** - * Can the user view the post in this discussion? + * Can the user view the content of the post in this discussion? * * @param stdClass $user The user to check * @param discussion_entity $discussion The discussion to check @@ -324,6 +324,10 @@ public function can_post_in_discussion(stdClass $user, discussion_entity $discus * @return bool */ public function can_view_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool { + if (!$this->can_view_post_shell($user, $post)) { + return false; + } + $forum = $this->get_forum(); $forumrecord = $this->get_forum_record(); $discussionrecord = $this->get_discussion_record($discussion); @@ -332,6 +336,37 @@ public function can_view_post(stdClass $user, discussion_entity $discussion, pos return forum_user_can_see_post($forumrecord, $discussionrecord, $postrecord, $user, $coursemodule, false); } + /** + * Can the user view the post at all? + * In some situations the user can view the shell of a post without being able to view its content. + * + * @param stdClass $user The user to check + * @param post_entity $post The post the user wants to view + * @return bool + * + */ + public function can_view_post_shell(stdClass $user, post_entity $post) : bool { + if (empty($post->get_private_reply_recipient_id())) { + return true; + } + + if ($post->is_private_reply_intended_for_user($user)) { + return true; + } + + return $this->can_view_any_private_reply($user); + } + + /** + * Whether the user can view any private reply in the forum. + * + * @param stdClass $user The user to check + * @return bool + */ + public function can_view_any_private_reply(stdClass $user) : bool { + return has_capability('mod/forum:readprivatereplies', $this->get_context(), $user); + } + /** * Can the user edit the post in this discussion? * @@ -394,6 +429,11 @@ public function can_delete_post(stdClass $user, discussion_entity $discussion, p * @return bool */ public function can_split_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool { + if ($post->is_private_reply()) { + // It is not possible to create a private discussion. + return false; + } + return $this->can_split_discussions($user) && $post->has_parent(); } @@ -406,9 +446,30 @@ public function can_split_post(stdClass $user, discussion_entity $discussion, po * @return bool */ public function can_reply_to_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool { + if ($post->is_private_reply()) { + // It is not possible to reply to a private reply. + return false; + } + return $this->can_post_in_discussion($user, $discussion); } + /** + * Can the user reply privately to the specified post? + * + * @param stdClass $user The user to check + * @param post_entity $post The post the user wants to reply to + * @return bool + */ + public function can_reply_privately_to_post(stdClass $user, post_entity $post) : bool { + if ($post->is_private_reply()) { + // You cannot reply privately to a post which is, itself, a private reply. + return false; + } + + return has_capability('mod/forum:postprivatereply', $this->get_context(), $user); + } + /** * Can the user export (see portfolios) the post in this discussion? * diff --git a/mod/forum/classes/local/vaults/post.php b/mod/forum/classes/local/vaults/post.php index ef699fd89ee43..ff1fd4fce137c 100644 --- a/mod/forum/classes/local/vaults/post.php +++ b/mod/forum/classes/local/vaults/post.php @@ -96,16 +96,32 @@ protected function from_db_records(array $results) { /** * Get the post ids for the given discussion. * + * @param stdClass $user The user to check the unread count for * @param int $discussionid The discussion to load posts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @param string $orderby Order the results * @return post_entity[] */ - public function get_from_discussion_id(int $discussionid, string $orderby = 'created ASC') : array { + public function get_from_discussion_id( + stdClass $user, + int $discussionid, + bool $canseeprivatereplies, + string $orderby = 'created ASC' + ) : array { $alias = $this->get_table_alias(); - $wheresql = $alias . '.discussion = ?'; + + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); + + $wheresql = "{$alias}.discussion = :discussionid {$privatewhere}"; $orderbysql = $alias . '.' . $orderby; + $sql = $this->generate_get_records_sql($wheresql, $orderbysql); - $records = $this->get_db()->get_records_sql($sql, [$discussionid]); + $records = $this->get_db()->get_records_sql($sql, array_merge([ + 'discussionid' => $discussionid, + ], $privateparams)); return $this->transform_db_records_to_entities($records); } @@ -113,22 +129,28 @@ public function get_from_discussion_id(int $discussionid, string $orderby = 'cre /** * Get the list of posts for the given discussions. * + * @param stdClass $user The user to check the unread count for * @param int[] $discussionids The list of discussion ids to load posts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @return post_entity[] */ - public function get_from_discussion_ids(array $discussionids) : array { + public function get_from_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { if (empty($discussionids)) { return []; } $alias = $this->get_table_alias(); - list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids); + list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); - $wheresql = "{$alias}.discussion {$insql}"; + $wheresql = "{$alias}.discussion {$insql} {$privatewhere}"; $sql = $this->generate_get_records_sql($wheresql, ''); - $records = $this->get_db()->get_records_sql($sql, $params); + $records = $this->get_db()->get_records_sql($sql, array_merge($params, $privateparams)); return $this->transform_db_records_to_entities($records); } @@ -139,21 +161,43 @@ public function get_from_discussion_ids(array $discussionids) : array { * * The return value will be a flat array of posts in the requested order. * + * @param stdClass $user The user to check the unread count for * @param post_entity $post The post to load replies for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @param string $orderby How to order the replies * @return post_entity[] */ - public function get_replies_to_post(post_entity $post, string $orderby = 'created ASC') : array { + public function get_replies_to_post( + stdClass $user, + post_entity $post, + bool $canseeprivatereplies, + string $orderby = 'created ASC' + ) : array { $alias = $this->get_table_alias(); - $params = [$post->get_discussion_id(), $post->get_time_created(), $post->get_id()]; + + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); + + $params = array_merge([ + 'discussionid' => $post->get_discussion_id(), + 'created' => $post->get_time_created(), + 'excludepostid' => $post->get_id(), + ], $privateparams); + // Unfortunately the best we can do to filter down the query is ignore all posts // that were created before the given post (since they can't be replies). - $wheresql = "{$alias}.discussion = ? and {$alias}.created >= ? and {$alias}.id != ?"; + // We also filter to remove private replies if the user cannot vie them. + $wheresql = "{$alias}.discussion = :discussionid + AND {$alias}.created >= :created {$privatewhere} + AND {$alias}.id != :excludepostid"; $orderbysql = $alias . '.' . $orderby; $sql = $this->generate_get_records_sql($wheresql, $orderbysql); $records = $this->get_db()->get_records_sql($sql, $params); $posts = $this->transform_db_records_to_entities($records); $sorter = $this->get_entity_factory()->get_posts_sorter(); + // We need to sort all of the values into the replies tree in order to capture // the full list of descendants. $sortedposts = $sorter->sort_into_children($posts); @@ -196,18 +240,29 @@ public function get_replies_to_post(post_entity $post, string $orderby = 'create /** * Get a mapping of replies to the specified discussions. * + * @param stdClass $user The user to check the unread count for * @param int[] $discussionids The list of discussions to fetch counts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @return int[] The number of replies for each discussion returned in an associative array */ - public function get_reply_count_for_discussion_ids(array $discussionids) : array { + public function get_reply_count_for_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { if (empty($discussionids)) { return []; } - list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids); - $sql = "SELECT discussion, COUNT(1) FROM {" . self::TABLE . "} p " . - "WHERE p.discussion {$insql} AND p.parent > 0 GROUP BY discussion"; - return $this->get_db()->get_records_sql_menu($sql, $params); + list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); + + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); + + $sql = "SELECT discussion, COUNT(1) + FROM {" . self::TABLE . "} p + WHERE p.discussion {$insql} AND p.parent > 0 {$privatewhere} + GROUP BY discussion"; + + return $this->get_db()->get_records_sql_menu($sql, array_merge($params, $privateparams)); } /** @@ -215,20 +270,26 @@ public function get_reply_count_for_discussion_ids(array $discussionids) : array * * @param stdClass $user The user to fetch counts for * @param int[] $discussionids The list of discussions to fetch counts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @return int[] The count of unread posts for each discussion returned in an associative array */ - public function get_unread_count_for_discussion_ids(stdClass $user, array $discussionids) : array { + public function get_unread_count_for_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { global $CFG; if (empty($discussionids)) { return []; } + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); + $alias = $this->get_table_alias(); list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); $sql = "SELECT p.discussion, COUNT(p.id) FROM {" . self::TABLE . "} p LEFT JOIN {forum_read} r ON r.postid = p.id AND r.userid = :userid - WHERE p.discussion {$insql} AND p.modified > :cutofftime AND r.id IS NULL + WHERE p.discussion {$insql} AND p.modified > :cutofftime AND r.id IS NULL {$privatewhere} GROUP BY p.discussion"; $params['userid'] = $user->id; @@ -236,16 +297,18 @@ public function get_unread_count_for_discussion_ids(stdClass $user, array $discu ->sub(new \DateInterval("P{$CFG->forum_oldpostdays}D")) ->format('U') / 60) * 60; - return $this->get_db()->get_records_sql_menu($sql, $params); + return $this->get_db()->get_records_sql_menu($sql, array_merge($params, $privateparams)); } /** * Get a mapping of the most recent post in each discussion based on post creation time. * + * @param stdClass $user The user to fetch counts for * @param int[] $discussionids The list of discussions to fetch counts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not * @return int[] The post id of the most recent post for each discussions returned in an associative array */ - public function get_latest_post_id_for_discussion_ids(array $discussionids) : array { + public function get_latest_post_id_for_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { global $CFG; if (empty($discussionids)) { @@ -255,6 +318,11 @@ public function get_latest_post_id_for_discussion_ids(array $discussionids) : ar $alias = $this->get_table_alias(); list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); + [ + 'where' => $privatewhere, + 'params' => $privateparams, + ] = $this->get_private_reply_sql($user, $canseeprivatereplies); + $sql = " SELECT p.discussion, MAX(p.id) FROM {" . self::TABLE . "} p @@ -264,8 +332,31 @@ public function get_latest_post_id_for_discussion_ids(array $discussionids) : ar WHERE mp.discussion {$insql} GROUP BY mp.discussion ) lp ON lp.discussion = p.discussion AND lp.created = p.created + WHERE 1 = 1 {$privatewhere} GROUP BY p.discussion"; - return $this->get_db()->get_records_sql_menu($sql, $params); + return $this->get_db()->get_records_sql_menu($sql, array_merge($params, $privateparams)); + } + + /** + * Get the SQL where and additional parameters to use to restrict posts to private reply posts. + * + * @param stdClass $user The user to fetch counts for + * @param bool $canseeprivatereplies Whether this user can see all private replies or not + * @return array The SQL WHERE clause, and parameters to use in the SQL. + */ + private function get_private_reply_sql(stdClass $user, bool $canseeprivatereplies) { + $params = []; + $privatewhere = ''; + if (!$canseeprivatereplies) { + $privatewhere = ' AND (p.privatereplyto = :privatereplyto OR p.userid = :privatereplyfrom OR p.privatereplyto = 0)'; + $params['privatereplyto'] = $user->id; + $params['privatereplyfrom'] = $user->id; + } + + return [ + 'where' => $privatewhere, + 'params' => $params, + ]; } } diff --git a/mod/forum/classes/output/forum_post.php b/mod/forum/classes/output/forum_post.php index a31d3eae187a6..072f310436e58 100644 --- a/mod/forum/classes/output/forum_post.php +++ b/mod/forum/classes/output/forum_post.php @@ -152,7 +152,8 @@ public function export_for_template(\renderer_base $renderer, $plaintext = false * @return array Data ready for use in a mustache template */ protected function export_for_template_text(\mod_forum_renderer $renderer) { - return array( + $data = $this->export_for_template_shared($renderer); + return $data + array( 'id' => html_entity_decode($this->post->id), 'coursename' => html_entity_decode($this->get_coursename()), 'courselink' => html_entity_decode($this->get_courselink()), @@ -193,7 +194,8 @@ protected function export_for_template_text(\mod_forum_renderer $renderer) { * @return array Data ready for use in a mustache template */ protected function export_for_template_html(\mod_forum_renderer $renderer) { - return array( + $data = $this->export_for_template_shared($renderer); + return $data + array( 'id' => $this->post->id, 'coursename' => $this->get_coursename(), 'courselink' => $this->get_courselink(), @@ -207,7 +209,17 @@ protected function export_for_template_html(\mod_forum_renderer $renderer) { // Format some components according to the renderer. 'message' => $renderer->format_message_text($this->cm, $this->post), 'attachments' => $renderer->format_message_attachments($this->cm, $this->post), + ); + } + /** + * Export this data so it can be used as the context for a mustache template. + * + * @param \mod_forum_renderer $renderer The render to be used for formatting the message and attachments + * @return stdClass Data ready for use in a mustache template + */ + protected function export_for_template_shared(\mod_forum_renderer $renderer) { + return array( 'canreply' => $this->canreply, 'permalink' => $this->get_permalink(), 'firstpost' => $this->get_is_firstpost(), @@ -224,6 +236,8 @@ protected function export_for_template_html(\mod_forum_renderer $renderer) { 'authorpicture' => $this->get_author_picture($renderer), 'grouppicture' => $this->get_group_picture($renderer), + + 'isprivatereply' => !empty($this->post->privatereplyto), ); } diff --git a/mod/forum/classes/post_form.php b/mod/forum/classes/post_form.php index 0ade6fb92307e..40deb321b1993 100644 --- a/mod/forum/classes/post_form.php +++ b/mod/forum/classes/post_form.php @@ -93,6 +93,7 @@ function definition() { $subscribe = $this->_customdata['subscribe']; $edit = $this->_customdata['edit']; $thresholdwarning = $this->_customdata['thresholdwarning']; + $canreplyprivately = $this->_customdata['canreplyprivately']; $mform->addElement('header', 'general', '');//fill in the data depending on page params later using set_data @@ -147,6 +148,17 @@ function definition() { $mform->addElement('checkbox', 'mailnow', get_string('mailnow', 'forum')); } + if ((empty($post->id) && $canreplyprivately) || (!empty($post) && !empty($post->privatereplyto))) { + // Only shwo 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); diff --git a/mod/forum/classes/privacy/provider.php b/mod/forum/classes/privacy/provider.php index e75eb7498ca09..65bd3f43edc82 100644 --- a/mod/forum/classes/privacy/provider.php +++ b/mod/forum/classes/privacy/provider.php @@ -620,6 +620,7 @@ protected static function export_all_posts(int $userid, array $mappings) { WHERE f.id ${foruminsql} AND ( p.userid = :postuserid OR + p.privatereplyto = :privatereplyrecipient OR fr.id IS NOT NULL OR {$ratingsql->userwhere} ) @@ -629,6 +630,7 @@ protected static function export_all_posts(int $userid, array $mappings) { $params = [ 'postuserid' => $userid, 'readuserid' => $userid, + 'privatereplyrecipient' => $userid, ]; $params += $forumparams; $params += $ratingsql->params; @@ -666,11 +668,18 @@ protected static function export_all_posts_in_discussion(int $userid, \context $ LEFT JOIN {forum_read} fr ON fr.postid = p.id AND fr.userid = :readuserid {$ratingsql->join} AND {$ratingsql->userwhere} WHERE d.id = :discussionid + AND ( + p.privatereplyto = 0 + OR p.privatereplyto = :privatereplyrecipient + OR p.userid = :privatereplyauthor + ) "; $params = [ 'discussionid' => $discussionid, 'readuserid' => $userid, + 'privatereplyrecipient' => $userid, + 'privatereplyauthor' => $userid, ]; $params += $ratingsql->params; @@ -685,6 +694,7 @@ protected static function export_all_posts_in_discussion(int $userid, \context $ $post->hasdata = $post->hasdata || !empty($post->hasratings); $post->hasdata = $post->hasdata || $post->readflag; $post->hasdata = $post->hasdata || ($post->userid == $USER->id); + $post->hasdata = $post->hasdata || ($post->privatereplyto == $USER->id); if (0 == $post->parent) { $structure->children[$post->id] = $post; @@ -756,6 +766,10 @@ protected static function export_post_data(int $userid, \context $context, $post 'author_was_you' => transform::yesno($post->userid == $userid), ]; + if (!empty($post->privatereplyto)) { + $postdata->privatereply = transform::yesno(true); + } + $postdata->message = writer::with_context($context) ->rewrite_pluginfile_urls($postarea, 'mod_forum', 'post', $post->id, $post->message); diff --git a/mod/forum/db/access.php b/mod/forum/db/access.php index e1c4f4e9c5d4e..5e5244dbd6cf3 100644 --- a/mod/forum/db/access.php +++ b/mod/forum/db/access.php @@ -165,6 +165,26 @@ ) ), + 'mod/forum:postprivatereply' => array( + 'captype' => 'write', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => array( + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ) + ), + + 'mod/forum:readprivatereplies' => array( + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => array( + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ) + ), + 'mod/forum:createattachment' => array( 'riskbitmask' => RISK_SPAM, diff --git a/mod/forum/db/install.xml b/mod/forum/db/install.xml index 9c0c99bf7ffb7..3c3cf62407376 100644 --- a/mod/forum/db/install.xml +++ b/mod/forum/db/install.xml @@ -1,5 +1,5 @@ - @@ -81,6 +81,7 @@ + @@ -188,4 +189,4 @@ - \ No newline at end of file + diff --git a/mod/forum/db/services.php b/mod/forum/db/services.php index 90cd94185f9b1..3907220606653 100644 --- a/mod/forum/db/services.php +++ b/mod/forum/db/services.php @@ -37,6 +37,16 @@ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE) ), + 'mod_forum_get_discussion_posts' => array( + 'classname' => 'mod_forum_external', + 'methodname' => 'get_discussion_posts', + 'classpath' => 'mod/forum/externallib.php', + 'description' => 'Returns a list of forum posts for a discussion.', + 'type' => 'read', + 'capabilities' => 'mod/forum:viewdiscussion, mod/forum:viewqandawithoutposting', + 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE) + ), + 'mod_forum_get_forum_discussion_posts' => array( 'classname' => 'mod_forum_external', 'methodname' => 'get_forum_discussion_posts', diff --git a/mod/forum/db/upgrade.php b/mod/forum/db/upgrade.php index 0d4a5d4fd220c..978e8c95d74f6 100644 --- a/mod/forum/db/upgrade.php +++ b/mod/forum/db/upgrade.php @@ -104,5 +104,19 @@ function xmldb_forum_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2019031200) { + // Define field privatereplyto to be added to forum_posts. + $table = new xmldb_table('forum_posts'); + $field = new xmldb_field('privatereplyto', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'mailnow'); + + // Conditionally launch add field privatereplyto. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Forum savepoint reached. + upgrade_mod_savepoint(true, 2019031200, 'forum'); + } + return true; } diff --git a/mod/forum/deprecatedlib.php b/mod/forum/deprecatedlib.php index 8289cbf8c56a5..f030b7da5e230 100644 --- a/mod/forum/deprecatedlib.php +++ b/mod/forum/deprecatedlib.php @@ -1440,7 +1440,7 @@ function forum_print_latest_discussions($course, $forum, $maxdiscussions = -1, $ $getuserlastmodified = ($displayformat == 'header'); $discussions = forum_get_discussions($cm, $sort, $fullpost, null, $maxdiscussions, $getuserlastmodified, $page, $perpage); - if (!$$discussions) { + if (!$discussions) { echo '
'; if ($forum->type == 'news') { echo '('.get_string('nonews', 'forum').')'; diff --git a/mod/forum/discuss.php b/mod/forum/discuss.php index b8c6937053f82..67874028e55bc 100644 --- a/mod/forum/discuss.php +++ b/mod/forum/discuss.php @@ -302,7 +302,7 @@ $rendererfactory = mod_forum\local\container::get_renderer_factory(); $discussionrenderer = $rendererfactory->get_discussion_renderer($forum, $discussion, $displaymode); $orderpostsby = $displaymode == FORUM_MODE_FLATNEWEST ? 'created DESC' : 'created ASC'; -$replies = $postvault->get_replies_to_post($post, $orderpostsby); +$replies = $postvault->get_replies_to_post($USER, $post, $capabilitymanager->can_view_any_private_reply($USER), $orderpostsby); $postids = array_map(function($post) { return $post->get_id(); }, array_merge([$post], array_values($replies))); diff --git a/mod/forum/externallib.php b/mod/forum/externallib.php index c4ef4a026246f..3435877e3ce74 100644 --- a/mod/forum/externallib.php +++ b/mod/forum/externallib.php @@ -156,6 +156,103 @@ public static function get_forums_by_courses_returns() { ); } + /** + * Get the forum posts in the specified discussion. + * + * @param int $discussionid + * @param string $sortby + * @param string $sortdirection + * @return array + */ + public static function get_discussion_posts(int $discussionid, ?string $sortby, ?string $sortdirection) { + global $USER; + // Validate the parameter. + $params = self::validate_parameters(self::get_discussion_posts_parameters(), [ + 'discussionid' => $discussionid, + 'sortby' => $sortby, + 'sortdirection' => $sortdirection, + ]); + $warnings = []; + + $vaultfactory = mod_forum\local\container::get_vault_factory(); + + $discussionvault = $vaultfactory->get_discussion_vault(); + $discussion = $discussionvault->get_from_id($params['discussionid']); + + $forumvault = $vaultfactory->get_forum_vault(); + $forum = $forumvault->get_from_id($discussion->get_forum_id()); + + $sortby = $params['sortby']; + $sortdirection = $params['sortdirection']; + $sortallowedvalues = ['id', 'created', 'modified']; + $directionallowedvalues = ['ASC', 'DESC']; + + if (!in_array(strtolower($sortby), $sortallowedvalues)) { + throw new invalid_parameter_exception('Invalid value for sortby parameter (value: ' . $sortby . '),' . + 'allowed values are: ' . implode(', ', $sortallowedvalues)); + } + + $sortdirection = strtoupper($sortdirection); + if (!in_array($sortdirection, $directionallowedvalues)) { + throw new invalid_parameter_exception('Invalid value for sortdirection parameter (value: ' . $sortdirection . '),' . + 'allowed values are: ' . implode(',', $directionallowedvalues)); + } + + $managerfactory = mod_forum\local\container::get_manager_factory(); + $capabilitymanager = $managerfactory->get_capability_manager($forum); + + $postvault = $vaultfactory->get_post_vault(); + $posts = $postvault->get_from_discussion_id( + $USER, + $discussion->get_id(), + $capabilitymanager->can_view_any_private_reply($USER), + "{$sortby} {$sortdirection}" + ); + + $builderfactory = mod_forum\local\container::get_builder_factory(); + $postbuilder = $builderfactory->get_exported_posts_builder(); + + $legacydatamapper = mod_forum\local\container::get_legacy_data_mapper_factory(); + + return [ + 'posts' => $postbuilder->build($USER, [$forum], [$discussion], $posts), + 'ratinginfo' => \core_rating\external\util::get_rating_info( + $legacydatamapper->get_forum_data_mapper()->to_legacy_object($forum), + $forum->get_context(), + 'mod_forum', + 'post', + $legacydatamapper->get_post_data_mapper()->to_legacy_objects($posts) + ), + 'warnings' => $warnings, + ]; + } + + /** + * Describe the post parameters. + * + * @return external_function_parameters + */ + public static function get_discussion_posts_parameters() { + return new external_function_parameters ([ + 'discussionid' => new external_value(PARAM_INT, 'The ID of the discussion from which to fetch posts.', VALUE_REQUIRED), + 'sortby' => new external_value(PARAM_ALPHA, 'Sort by this element: id, created or modified', VALUE_DEFAULT, 'created'), + 'sortdirection' => new external_value(PARAM_ALPHA, 'Sort direction: ASC or DESC', VALUE_DEFAULT, 'DESC') + ]); + } + + /** + * Describe the post return format. + * + * @return external_single_structure + */ + public static function get_discussion_posts_returns() { + return new external_single_structure([ + 'posts' => new external_multiple_structure(\mod_forum\local\exporters\post::get_read_structure()), + 'ratinginfo' => \core_rating\external\util::external_ratings_structure(), + 'warnings' => new external_warnings() + ]); + } + /** * Describes the parameters for get_forum_discussion_posts. * @@ -267,6 +364,8 @@ public static function get_forum_discussion_posts($discussionid, $sortby = "crea $post->postread = true; } + $post->isprivatereply = !empty($post->privatereplyto); + $post->canreply = $canreply; if (!empty($post->children)) { $post->children = array_keys($post->children); @@ -364,6 +463,7 @@ public static function get_forum_discussion_posts_returns() { 'userfullname' => new external_value(PARAM_TEXT, 'Post author full name'), 'userpictureurl' => new external_value(PARAM_URL, 'Post author picture.', VALUE_OPTIONAL), 'deleted' => new external_value(PARAM_BOOL, 'This post has been removed.'), + 'isprivatereply' => new external_value(PARAM_BOOL, 'The post is a private reply'), ), 'post' ) ), @@ -373,6 +473,15 @@ public static function get_forum_discussion_posts_returns() { ); } + /** + * Mark the get_forum_discussion_posts web service as deprecated. + * + * @return bool + */ + public static function get_forum_discussion_posts_is_deprecated() { + return true; + } + /** * Describes the parameters for get_forum_discussions_paginated. * @@ -468,7 +577,8 @@ public static function get_forum_discussions_paginated($forumid, $sortby = 'time } } // The forum function returns the replies for all the discussions in a given forum. - $replies = forum_count_discussion_replies($forumid, $sort, -1, $page, $perpage); + $canseeprivatereplies = has_capability('mod/forum:readprivatereplies', $modcontext); + $replies = forum_count_discussion_replies($forumid, $sort, -1, $page, $perpage, $canseeprivatereplies); foreach ($alldiscussions as $discussion) { @@ -771,6 +881,7 @@ public static function add_discussion_post_parameters() { 'name' => new external_value(PARAM_ALPHANUM, 'The allowed keys (value format) are: discussionsubscribe (bool); subscribe to the discussion?, default to true + private (bool); make this reply private to the author of the parent post, default to false. inlineattachmentsid (int); the draft file area id for inline attachments attachmentsid (int); the draft file area id for attachments '), @@ -806,6 +917,7 @@ public static function add_discussion_post($postid, $subject, $message, $options 'options' => $options ) ); + $warnings = array(); if (!$parent = forum_get_post_full($params['postid'])) { @@ -826,6 +938,7 @@ public static function add_discussion_post($postid, $subject, $message, $options // Validate options. $options = array( 'discussionsubscribe' => true, + 'private' => false, 'inlineattachmentsid' => 0, 'attachmentsid' => null ); @@ -835,6 +948,9 @@ public static function add_discussion_post($postid, $subject, $message, $options case 'discussionsubscribe': $value = clean_param($option['value'], PARAM_BOOL); break; + case 'private': + $value = clean_param($option['value'], PARAM_BOOL); + break; case 'inlineattachmentsid': $value = clean_param($option['value'], PARAM_INT); break; @@ -868,6 +984,7 @@ public static function add_discussion_post($postid, $subject, $message, $options $post->messagetrust = trusttext_trusted($context); $post->itemid = $options['inlineattachmentsid']; $post->attachments = $options['attachmentsid']; + $post->isprivatereply = $options['private']; $post->deleted = 0; $fakemform = $post->attachments; if ($postid = forum_add_new_post($post, $fakemform)) { diff --git a/mod/forum/lang/en/forum.php b/mod/forum/lang/en/forum.php index c376f02fb9d12..b090c4ab90ee1 100644 --- a/mod/forum/lang/en/forum.php +++ b/mod/forum/lang/en/forum.php @@ -248,6 +248,8 @@ $string['forum:rate'] = 'Rate posts'; $string['forum:replynews'] = 'Reply to announcements'; $string['forum:replypost'] = 'Reply to posts'; +$string['forum:postprivatereply'] = 'Reply privately to posts'; +$string['forum:readprivatereplies'] = 'View private replies'; $string['forums'] = 'Forums'; $string['forum:splitdiscussions'] = 'Split discussions'; $string['forum:startdiscussion'] = 'Start new discussions'; @@ -406,6 +408,7 @@ $string['parent'] = 'Show parent'; $string['parentofthispost'] = 'Parent of this post'; $string['permalink'] = 'Permalink'; +$string['postisprivatereply'] = 'This post was made privately and is not visible to all users.'; $string['posttomygroups'] = 'Post a copy to all groups'; $string['posttomygroups_help'] = 'Posts a copy of this message to all groups you have access to. Participants in groups you do not have access to will not see this post'; $string['prevdiscussiona'] = 'Previous discussion: {$a}'; @@ -487,6 +490,8 @@ $string['privacy:request:delete:post:message'] = 'The content of this post has been deleted at the request of its author.'; $string['privacy:request:delete:post:subject'] = 'Delete at the request of the author'; $string['privacy:subscribedtoforum'] = 'You are subscribed to this forum.'; +$string['privatereply'] = 'Reply privately'; +$string['privatereply_help'] = 'A private reply can only be viewed by the author of the post being replied to, and any users with the capability to view private replies.'; $string['processingdigest'] = 'Processing email digest for user {$a}'; $string['processingpost'] = 'Processing post {$a}'; $string['prune'] = 'Split'; diff --git a/mod/forum/lib.php b/mod/forum/lib.php index b0f56ea32432f..441fbe1e674c6 100644 --- a/mod/forum/lib.php +++ b/mod/forum/lib.php @@ -813,6 +813,10 @@ function forum_print_recent_activity($course, $viewfullnames, $timestart) { } } + if (!forum_post_is_visible_privately($post, $cm)) { + continue; + } + // Check that the user can see the discussion. if (forum_is_user_group_discussion($cm, $post->groupid)) { $printposts[] = $post; @@ -1038,15 +1042,12 @@ function forum_get_post_full($postid) { /** * Gets all posts in discussion including top parent. * - * @global object - * @global object - * @global object - * @param int $discussionid - * @param string $sort - * @param bool $tracking does user track the forum? - * @return array of posts + * @param int $discussionid The Discussion to fetch. + * @param string $sort The sorting to apply. + * @param bool $tracking Whether the user tracks this forum. + * @return array The posts in the discussion. */ -function forum_get_all_discussion_posts($discussionid, $sort, $tracking=false) { +function forum_get_all_discussion_posts($discussionid, $sort, $tracking = false) { global $CFG, $DB, $USER; $tr_sel = ""; @@ -1525,17 +1526,17 @@ function forum_get_firstpost_from_discussion($discussionid) { /** * Returns an array of counts of replies to each discussion * - * @global object - * @global object - * @param int $forumid - * @param string $forumsort - * @param int $limit - * @param int $page - * @param int $perpage - * @return array + * @param int $forumid + * @param string $forumsort + * @param int $limit + * @param int $page + * @param int $perpage + * @param boolean $canseeprivatereplies Whether the current user can see private replies. + * @return array */ -function forum_count_discussion_replies($forumid, $forumsort="", $limit=-1, $page=-1, $perpage=0) { - global $CFG, $DB; +function forum_count_discussion_replies($forumid, $forumsort = "", $limit = -1, $page = -1, $perpage = 0, + $canseeprivatereplies = false) { + global $CFG, $DB, $USER; if ($limit > 0) { $limitfrom = 0; @@ -1559,21 +1560,33 @@ function forum_count_discussion_replies($forumid, $forumsort="", $limit=-1, $pag $groupby = str_replace('asc', '', $groupby); } + $params = ['forumid' => $forumid]; + + if (!$canseeprivatereplies) { + $privatewhere = ' AND (p.privatereplyto = :currentuser1 OR p.userid = :currentuser2 OR p.privatereplyto = 0)'; + $params['currentuser1'] = $USER->id; + $params['currentuser2'] = $USER->id; + } else { + $privatewhere = ''; + } + if (($limitfrom == 0 and $limitnum == 0) or $forumsort == "") { $sql = "SELECT p.discussion, COUNT(p.id) AS replies, MAX(p.id) AS lastpostid FROM {forum_posts} p JOIN {forum_discussions} d ON p.discussion = d.id - WHERE p.parent > 0 AND d.forum = ? + WHERE p.parent > 0 AND d.forum = :forumid + $privatewhere GROUP BY p.discussion"; - return $DB->get_records_sql($sql, array($forumid)); + return $DB->get_records_sql($sql, $params); } else { $sql = "SELECT p.discussion, (COUNT(p.id) - 1) AS replies, MAX(p.id) AS lastpostid FROM {forum_posts} p JOIN {forum_discussions} d ON p.discussion = d.id - WHERE d.forum = ? + WHERE d.forum = :forumid + $privatewhere GROUP BY p.discussion $groupby $orderby"; - return $DB->get_records_sql($sql, array($forumid), $limitfrom, $limitnum); + return $DB->get_records_sql($sql, $params, $limitfrom, $limitnum); } } @@ -3093,10 +3106,25 @@ function forum_add_new_post($post, $mform, $unused = null) { $forum = $DB->get_record('forum', array('id' => $discussion->forum)); $cm = get_coursemodule_from_instance('forum', $forum->id); $context = context_module::instance($cm->id); + $privatereplyto = 0; + + // Check whether private replies should be enabled for this post. + if ($post->parent) { + $parent = $DB->get_record('forum_posts', array('id' => $post->parent)); + + if (!empty($parent->privatereplyto)) { + throw new \coding_exception('It should not be possible to reply to a private reply'); + } + + if (!empty($post->isprivatereply) && forum_user_can_reply_privately($context, $parent)) { + $privatereplyto = $parent->userid; + } + } $post->created = $post->modified = time(); $post->mailed = FORUM_MAILED_PENDING; $post->userid = $USER->id; + $post->privatereplyto = $privatereplyto; $post->attachment = ""; if (!isset($post->totalscore)) { $post->totalscore = 0; @@ -3223,6 +3251,7 @@ function forum_add_discussion($discussion, $mform=null, $unused=null, $userid=nu $post = new stdClass(); $post->discussion = 0; $post->parent = 0; + $post->privatereplyto = 0; $post->userid = $userid; $post->created = $timenow; $post->modified = $timenow; @@ -3989,6 +4018,10 @@ function forum_user_can_see_post($forum, $discussion, $post, $user = null, $cm = return false; } + if (!forum_post_is_visible_privately($post, $cm)) { + return false; + } + if (isset($cm->uservisible)) { if (!$cm->uservisible) { return false; @@ -6541,3 +6574,44 @@ function mod_forum_get_completion_active_rule_descriptions($cm) { } return $descriptions; } + +/** + * Check whether the forum post is a private reply visible to this user. + * + * @param stdClass $post The post to check. + * @param cm_info $cm The context module instance. + * @return bool Whether the post is visible in terms of private reply configuration. + */ +function forum_post_is_visible_privately($post, $cm) { + global $USER; + + if (!empty($post->privatereplyto)) { + // Allow the user to see the private reply if: + // * they hold the permission; + // * they are the author; or + // * they are the intended recipient. + $cansee = false; + $cansee = $cansee || ($post->userid == $USER->id); + $cansee = $cansee || ($post->privatereplyto == $USER->id); + $cansee = $cansee || has_capability('mod/forum:readprivatereplies', context_module::instance($cm->id)); + return $cansee; + } + + return true; +} + +/** + * Check whether the user can reply privately to the parent post. + * + * @param \context_module $context + * @param \stdClass $parent + * @return bool + */ +function forum_user_can_reply_privately(\context_module $context, \stdClass $parent) : bool { + if ($parent->privatereplyto) { + // You cannot reply privately to a post which is, itself, a private reply. + return false; + } + + return has_capability('mod/forum:postprivatereply', $context); +} diff --git a/mod/forum/post.php b/mod/forum/post.php index 9a7c445f289ff..380bd13c42a8e 100644 --- a/mod/forum/post.php +++ b/mod/forum/post.php @@ -98,6 +98,8 @@ $entityfactory = mod_forum\local\container::get_entity_factory(); $vaultfactory = mod_forum\local\container::get_vault_factory(); +$isprivatereply = false; +$canreplyprivately = false; if (!empty($forum)) { // User is starting a new discussion in a forum. if (! $forum = $DB->get_record("forum", array("id" => $forum))) { @@ -215,6 +217,10 @@ print_error("activityiscurrentlyhidden"); } + if (!empty($parent->privatereplyto)) { + print_error('cannotreplytoprivatereply', 'forum'); + } + // Load up the $post variable. $post = new stdClass(); @@ -225,6 +231,8 @@ $post->subject = $parent->subject; $post->userid = $USER->id; $post->message = ''; + $post->parentpostauthor = $parent->userid; + $canreplyprivately = forum_user_can_reply_privately($modcontext, $parent); $post->groupid = ($discussion->groupid == -1) ? 0 : $discussion->groupid; @@ -275,12 +283,14 @@ print_error('cannoteditposts', 'forum'); } - // Load up the $post variable. $post->edit = $edit; $post->course = $course->id; $post->forum = $forum->id; $post->groupid = ($discussion->groupid == -1) ? 0 : $discussion->groupid; + if ($parent) { + $canreplyprivately = forum_user_can_reply_privately($modcontext, $parent); + } $post = trusttext_pre_edit($post, 'message', $modcontext); @@ -411,7 +421,12 @@ if (empty($post->edit)) { $postvault = $vaultfactory->get_post_vault(); - $replies = $postvault->get_replies_to_post($postentity, 'created ASC'); + $replies = $postvault->get_replies_to_post( + $USER, + $postentity, + $capabilitymanager->can_view_any_private_reply($USER), + 'created ASC' + ); $postentities = array_merge($postentities, $replies); } @@ -591,16 +606,18 @@ } $thresholdwarning = forum_check_throttling($forum, $cm); -$mformpost = new mod_forum_post_form('post.php', array('course' => $course, - 'cm' => $cm, - 'coursecontext' => $coursecontext, - 'modcontext' => $modcontext, - 'forum' => $forum, - 'post' => $post, - 'subscribe' => \mod_forum\subscriptions::is_subscribed($USER->id, $forum, - null, $cm), - 'thresholdwarning' => $thresholdwarning, - 'edit' => $edit), 'post', '', array('id' => 'mformforum')); +$mformpost = new mod_forum_post_form('post.php', [ + 'course' => $course, + 'cm' => $cm, + 'coursecontext' => $coursecontext, + 'modcontext' => $modcontext, + 'forum' => $forum, + 'post' => $post, + 'subscribe' => \mod_forum\subscriptions::is_subscribed($USER->id, $forum, null, $cm), + 'thresholdwarning' => $thresholdwarning, + 'edit' => $edit, + 'canreplyprivately' => $canreplyprivately, + ], 'post', '', array('id' => 'mformforum')); $draftitemid = file_get_submitted_draft_itemid('attachments'); $postid = empty($post->id) ? null : $post->id; @@ -1074,7 +1091,12 @@ if (empty($post->edit)) { if ($forum->type != 'qanda' || forum_user_can_see_discussion($forum, $discussion, $modcontext)) { $postvault = $vaultfactory->get_post_vault(); - $replies = $postvault->get_replies_to_post($postentity, 'created ASC'); + $replies = $postvault->get_replies_to_post( + $USER, + $postentity, + $capabilitymanager->can_view_any_private_reply($USER), + 'created ASC' + ); $postentities = array_merge($postentities, $replies); } } diff --git a/mod/forum/rsslib.php b/mod/forum/rsslib.php index 5b302432103be..e93acdea1f16c 100644 --- a/mod/forum/rsslib.php +++ b/mod/forum/rsslib.php @@ -204,6 +204,8 @@ function forum_rss_feed_discussions_sql($forum, $cm, $newsince=0) { * @return string the SQL query to be used to get the Post details from the forum table of the database */ function forum_rss_feed_posts_sql($forum, $cm, $newsince=0) { + global $USER; + $modcontext = context_module::instance($cm->id); // Get group enforcement SQL. @@ -224,6 +226,15 @@ function forum_rss_feed_posts_sql($forum, $cm, $newsince=0) { $newsince = ''; } + $canseeprivatereplies = has_capability('mod/forum:readprivatereplies', $modcontext); + if (!$canseeprivatereplies) { + $privatewhere = ' AND (p.privatereplyto = :currentuser1 OR p.userid = :currentuser2 OR p.privatereplyto = 0)'; + $params['currentuser1'] = $USER->id; + $params['currentuser2'] = $USER->id; + } else { + $privatewhere = ''; + } + $usernamefields = get_all_user_name_fields(true, 'u'); $sql = "SELECT p.id AS postid, d.id AS discussionid, @@ -245,6 +256,7 @@ function forum_rss_feed_posts_sql($forum, $cm, $newsince=0) { WHERE d.forum = {$forum->id} AND p.discussion = d.id AND p.deleted <> 1 AND u.id = p.userid $newsince + $privatewhere $groupselect ORDER BY p.created desc"; diff --git a/mod/forum/styles.css b/mod/forum/styles.css index e54ac14b1b219..297d60a4266e3 100644 --- a/mod/forum/styles.css +++ b/mod/forum/styles.css @@ -327,3 +327,7 @@ span.unread { .path-mod-forum article .nav .nav-link + .nav-link { border-left: 1px solid #ddd; } + +.privatereplyinfo { + font-size: 80%; +} diff --git a/mod/forum/templates/forum_discussion_post.mustache b/mod/forum/templates/forum_discussion_post.mustache index 322f629bf25f6..ce437f8732c24 100644 --- a/mod/forum/templates/forum_discussion_post.mustache +++ b/mod/forum/templates/forum_discussion_post.mustache @@ -58,6 +58,11 @@ {{{html.authorsubheading}}} {{/isdeleted}} + {{#isprivatereply}} +
+ {{#str}}postisprivatereply, forum{{/str}} +
+ {{/isprivatereply}}
{{{message}}} diff --git a/mod/forum/templates/forum_post_email_htmlemail_body.mustache b/mod/forum/templates/forum_post_email_htmlemail_body.mustache index 8bc3495b369cc..06a280dfdd7bf 100644 --- a/mod/forum/templates/forum_post_email_htmlemail_body.mustache +++ b/mod/forum/templates/forum_post_email_htmlemail_body.mustache @@ -49,6 +49,7 @@ * permalink * unsubscribeforumlink * unsubscribediscussionlink + * isprivatereply Example context (json): { @@ -92,6 +93,11 @@ "date": {{# quote }}{{ postdate }}{{/ quote }} } {{/ str }}
+ {{# isprivatereply }} +
+ {{# str }} postisprivatereply, forum {{/ str }} +
+ {{/ isprivatereply }} diff --git a/mod/forum/templates/forum_post_email_textemail.mustache b/mod/forum/templates/forum_post_email_textemail.mustache index 4c7ee5342beaa..0f954cd0752ae 100644 --- a/mod/forum/templates/forum_post_email_textemail.mustache +++ b/mod/forum/templates/forum_post_email_textemail.mustache @@ -41,6 +41,7 @@ * unsubscribeforumlink * unsubscribediscussionlink * forumindexlink + * isprivatereply }} {{{ coursename }}} -> {{# str }} forums, forum {{/ str }} -> {{{ forumname }}}{{# showdiscussionname }} -> {{{ discussionname }}} {{/ showdiscussionname }} {{ permalink }} @@ -49,6 +50,9 @@ "name": {{# quote }}{{{ authorfullname }}}{{/ quote }}, "date": {{# quote}}{{ postdate }}{{/ quote }} } {{/ str }} +{{# isprivatereply }} +{{# str }} postisprivatereply, forum {{/ str }} +{{/ isprivatereply }} --------------------------------------------------------------------- {{{ message }}} diff --git a/mod/forum/tests/behat/private_replies.feature b/mod/forum/tests/behat/private_replies.feature new file mode 100644 index 0000000000000..eb2d8eda5abc6 --- /dev/null +++ b/mod/forum/tests/behat/private_replies.feature @@ -0,0 +1,64 @@ +@mod @mod_forum +Feature: Forum posts can be replied to in private + In order to post feedback to my students + As a Teacher + I need to be able to reply privately to students + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | teacher2 | Teacher | 2 | teacher2@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Science 101 | C1 | 0 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | forum | Study discussions | Test forum description | C1 | forum | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | teacher2 | C1 | editingteacher | + | student1 | C1 | student | + | student2 | C1 | student | + And I log in as "student1" + And I am on "Science 101" course homepage + And I add a new discussion to "Study discussions" forum with: + | Subject | Answers to the homework | + | Message | Here are the answers to last night's homework. | + And I log out + And I log in as "teacher1" + And I am on "Science 101" course homepage + And I reply "Answers to the homework" post from "Study discussions" forum with: + | Message | How about you and I have a meeting after class about plagiarism? | + | Reply privately | 1 | + + Scenario: As a teacher I can see my own response + Given I follow "Answers to the homework" + Then I should see "How about you and I have a meeting after class about plagiarism?" + + Scenario: As a fellow teacher I can see the other teacher's response + Given I log out + And I log in as "teacher2" + And I am on "Science 101" course homepage + And I follow "Study discussions" + When I follow "Answers to the homework" + Then I should see "How about you and I have a meeting after class about plagiarism?" + + Scenario: As the intended recipient I can see my own response + Given I log out + And I log in as "student1" + And I am on "Science 101" course homepage + And I follow "Study discussions" + When I follow "Answers to the homework" + Then I should see "How about you and I have a meeting after class about plagiarism?" + + Scenario: As a non-privileged user I cannot see my own response + Given I log out + And I log in as "student2" + And I am on "Science 101" course homepage + And I follow "Study discussions" + When I follow "Answers to the homework" + Then I should not see "How about you and I have a meeting after class about plagiarism?" diff --git a/mod/forum/tests/coverage.php b/mod/forum/tests/coverage.php index 472bfe0c5096e..c8e9d8ebee1de 100644 --- a/mod/forum/tests/coverage.php +++ b/mod/forum/tests/coverage.php @@ -35,6 +35,7 @@ /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */ protected $whitelistfolders = [ 'classes/local', + 'externallib.php', ]; /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */ diff --git a/mod/forum/tests/entities_discussion_summary_test.php b/mod/forum/tests/entities_discussion_summary_test.php index 886dfa22be7b0..1cb03f67b88e6 100644 --- a/mod/forum/tests/entities_discussion_summary_test.php +++ b/mod/forum/tests/entities_discussion_summary_test.php @@ -89,6 +89,7 @@ public function test_entity() { false, 0, false, + false, false ); diff --git a/mod/forum/tests/entities_discussion_test.php b/mod/forum/tests/entities_discussion_test.php index 0db6b243e1024..20570b0f3784d 100644 --- a/mod/forum/tests/entities_discussion_test.php +++ b/mod/forum/tests/entities_discussion_test.php @@ -73,6 +73,7 @@ public function test_entity() { false, 0, false, + false, false ); $notfirstpost = new post_entity( @@ -90,6 +91,7 @@ public function test_entity() { false, 0, false, + false, false ); diff --git a/mod/forum/tests/entities_post_read_receipt_collection_test.php b/mod/forum/tests/entities_post_read_receipt_collection_test.php index 48950139c0d69..567d66a793fce 100644 --- a/mod/forum/tests/entities_post_read_receipt_collection_test.php +++ b/mod/forum/tests/entities_post_read_receipt_collection_test.php @@ -57,6 +57,7 @@ public function test_entity() { false, 0, false, + false, false ); $post = new post_entity( @@ -74,6 +75,7 @@ public function test_entity() { false, 0, false, + false, false ); $collection = new collection_entity([ diff --git a/mod/forum/tests/entities_post_test.php b/mod/forum/tests/entities_post_test.php index 19261f3c4e70d..62bdd8bc565f7 100644 --- a/mod/forum/tests/entities_post_test.php +++ b/mod/forum/tests/entities_post_test.php @@ -58,6 +58,7 @@ public function test_entity() { false, 0, false, + false, false ); diff --git a/mod/forum/tests/externallib_test.php b/mod/forum/tests/externallib_test.php index 7056d79b93891..65685bbf21e91 100644 --- a/mod/forum/tests/externallib_test.php +++ b/mod/forum/tests/externallib_test.php @@ -310,6 +310,7 @@ public function test_mod_forum_get_forum_discussion_posts() { 'userfullname' => fullname($user3), 'userpictureurl' => '', 'deleted' => false, + 'isprivatereply' => false, ); $expectedposts['posts'][] = array( @@ -346,6 +347,7 @@ public function test_mod_forum_get_forum_discussion_posts() { 'userfullname' => fullname($user2), 'userpictureurl' => '', 'deleted' => false, + 'isprivatereply' => false, ); // Test a discussion with two additional posts (total 3 posts). @@ -1301,4 +1303,98 @@ public function test_mod_forum_get_forum_access_information() { } } } + + /** + * Test add_discussion_post + */ + public function test_add_discussion_post_private() { + global $DB; + + $this->resetAfterTest(true); + + self::setAdminUser(); + + // Create course to add the module. + $course = self::getDataGenerator()->create_course(); + + // Standard forum. + $record = new stdClass(); + $record->course = $course->id; + $forum = self::getDataGenerator()->create_module('forum', $record); + $cm = get_coursemodule_from_id('forum', $forum->cmid, 0, false, MUST_EXIST); + $forumcontext = context_module::instance($forum->cmid); + $generator = self::getDataGenerator()->get_plugin_generator('mod_forum'); + + // Create an enrol users. + $student1 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); + $student2 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); + $teacher1 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($teacher1->id, $course->id, 'editingteacher'); + $teacher2 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($teacher2->id, $course->id, 'editingteacher'); + + // Add a new discussion to the forum. + self::setUser($student1); + $record = new stdClass(); + $record->course = $course->id; + $record->userid = $student1->id; + $record->forum = $forum->id; + $discussion = $generator->create_discussion($record); + + // Have the teacher reply privately. + self::setUser($teacher1); + $post = mod_forum_external::add_discussion_post($discussion->firstpost, 'some subject', 'some text here...', [ + [ + 'name' => 'private', + 'value' => true, + ], + ]); + $post = external_api::clean_returnvalue(mod_forum_external::add_discussion_post_returns(), $post); + $privatereply = $DB->get_record('forum_posts', array('id' => $post['postid'])); + $this->assertEquals($student1->id, $privatereply->privatereplyto); + // Bump the time of the private reply to ensure order. + $privatereply->created++; + $privatereply->modified = $privatereply->created; + $DB->update_record('forum_posts', $privatereply); + + // The teacher will receive their private reply. + self::setUser($teacher1); + $posts = mod_forum_external::get_forum_discussion_posts($discussion->id); + $posts = external_api::clean_returnvalue(mod_forum_external::get_forum_discussion_posts_returns(), $posts); + $this->assertEquals(2, count($posts['posts'])); + $this->assertTrue($posts['posts'][0]['isprivatereply']); + + // Another teacher on the course will also receive the private reply. + self::setUser($teacher2); + $posts = mod_forum_external::get_forum_discussion_posts($discussion->id); + $posts = external_api::clean_returnvalue(mod_forum_external::get_forum_discussion_posts_returns(), $posts); + $this->assertEquals(2, count($posts['posts'])); + $this->assertTrue($posts['posts'][0]['isprivatereply']); + + // The student will receive the private reply. + self::setUser($student1); + $posts = mod_forum_external::get_forum_discussion_posts($discussion->id); + $posts = external_api::clean_returnvalue(mod_forum_external::get_forum_discussion_posts_returns(), $posts); + $this->assertEquals(2, count($posts['posts'])); + $this->assertTrue($posts['posts'][0]['isprivatereply']); + + // Another student will not receive the private reply. + self::setUser($student2); + $posts = mod_forum_external::get_forum_discussion_posts($discussion->id); + $posts = external_api::clean_returnvalue(mod_forum_external::get_forum_discussion_posts_returns(), $posts); + $this->assertEquals(1, count($posts['posts'])); + $this->assertFalse($posts['posts'][0]['isprivatereply']); + + // A user cannot reply to a private reply. + self::setUser($teacher2); + $this->expectException('coding_exception'); + $post = mod_forum_external::add_discussion_post($privatereply->id, 'some subject', 'some text here...', [ + 'options' => [ + 'name' => 'private', + 'value' => false, + ], + ]); + } } diff --git a/mod/forum/tests/generator/lib.php b/mod/forum/tests/generator/lib.php index 1d71cf0b61b3a..597f64ce0d4c4 100644 --- a/mod/forum/tests/generator/lib.php +++ b/mod/forum/tests/generator/lib.php @@ -304,6 +304,10 @@ public function create_post($record = null) { $record['deleted'] = 0; } + if (!isset($record['privatereplyto'])) { + $record['privatereplyto'] = 0; + } + $record = (object) $record; // Add the post. diff --git a/mod/forum/tests/generator_trait.php b/mod/forum/tests/generator_trait.php index 4c536ab0b7f5e..4d856e99bab83 100644 --- a/mod/forum/tests/generator_trait.php +++ b/mod/forum/tests/generator_trait.php @@ -70,7 +70,7 @@ protected function helper_post_to_forum($forum, $author, $fields = array()) { // Retrieve the post which was created by create_discussion. $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id)); - return array($discussion, $post); + return [$discussion, $post]; } /** @@ -108,24 +108,26 @@ protected function helper_update_subscription_time($user, $discussion, $factor) * @param stdClass $forum The forum to post in * @param stdClass $discussion The discussion to post in * @param stdClass $author The author to post as + * @param array $options Additional options to pass to `create_post` * @return stdClass The forum post */ - protected function helper_post_to_discussion($forum, $discussion, $author) { + protected function helper_post_to_discussion($forum, $discussion, $author, array $options = []) { global $DB; $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum'); // Add a post to the discussion. - $record = new stdClass(); - $record->course = $forum->course; $strre = get_string('re', 'forum'); - $record->subject = $strre . ' ' . $discussion->subject; - $record->userid = $author->id; - $record->forum = $forum->id; - $record->discussion = $discussion->id; - $record->mailnow = 1; + $record = array_merge([ + 'course' => $forum->course, + 'subject' => "{$strre} {$discussion->subject}", + 'userid' => $author->id, + 'forum' => $forum->id, + 'discussion' => $discussion->id, + 'mailnow' => 1, + ], $options); - $post = $generator->create_post($record); + $post = $generator->create_post((object) $record); return $post; } @@ -135,25 +137,38 @@ protected function helper_post_to_discussion($forum, $discussion, $author) { * * @param stdClass $parent The post being replied to * @param stdClass $author The author to post as + * @param array $options Additional options to pass to `create_post` * @return stdClass The forum post */ - protected function helper_reply_to_post($parent, $author) { + protected function helper_reply_to_post($parent, $author, array $options = []) { global $DB; $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum'); // Add a post to the discussion. $strre = get_string('re', 'forum'); - $record = (object) [ + $record = (object) array_merge([ 'discussion' => $parent->discussion, 'parent' => $parent->id, 'userid' => $author->id, 'mailnow' => 1, 'subject' => $strre . ' ' . $parent->subject, - ]; + ], $options); $post = $generator->create_post($record); return $post; } + + /** + * Gets the role id from it's shortname. + * + * @param string $roleshortname + * @return int + */ + protected function get_role_id($roleshortname) { + global $DB; + + return $DB->get_field('role', 'id', ['shortname' => $roleshortname]); + } } diff --git a/mod/forum/tests/lib_test.php b/mod/forum/tests/lib_test.php index 2bac2101b300a..a6a8c8a4f9a31 100644 --- a/mod/forum/tests/lib_test.php +++ b/mod/forum/tests/lib_test.php @@ -1712,6 +1712,81 @@ public function test_count_discussion_replies_limited_sorted_small_reverse() { $this->assertEquals($discussionid, $row->discussion); } } + + /** + * Test the reply count when used with private replies. + */ + public function test_forum_count_discussion_replies_private() { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id)); + $context = context_module::instance($forum->cmid); + $cm = get_coursemodule_from_instance('forum', $forum->id); + + $student = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($student->id, $course->id); + + $teacher = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($teacher->id, $course->id); + + $privilegeduser = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($privilegeduser->id, $course->id, 'editingteacher'); + + $otheruser = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($otheruser->id, $course->id); + + $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum'); + + // Create a discussion with some replies. + $record = new stdClass(); + $record->course = $forum->course; + $record->forum = $forum->id; + $record->userid = $student->id; + $discussion = $generator->create_discussion($record); + $replycount = 5; + $replyto = $DB->get_record('forum_posts', array('discussion' => $discussion->id)); + + // Create a couple of standard replies. + $post = new stdClass(); + $post->userid = $student->id; + $post->discussion = $discussion->id; + $post->parent = $replyto->id; + + for ($i = 0; $i < $replycount; $i++) { + $post = $generator->create_post($post); + } + + // Create a private reply post from the teacher back to the student. + $reply = new stdClass(); + $reply->userid = $teacher->id; + $reply->discussion = $discussion->id; + $reply->parent = $replyto->id; + $reply->privatereplyto = $replyto->userid; + $generator->create_post($reply); + + // The user is the author of the private reply. + $this->setUser($teacher->id); + $counts = forum_count_discussion_replies($forum->id); + $this->assertEquals($replycount + 1, $counts[$discussion->id]->replies); + + // The user is the intended recipient. + $this->setUser($student->id); + $counts = forum_count_discussion_replies($forum->id); + $this->assertEquals($replycount + 1, $counts[$discussion->id]->replies); + + // The user is not the author or recipient, but does have the readprivatereplies capability. + $this->setUser($privilegeduser->id); + $counts = forum_count_discussion_replies($forum->id, "", -1, -1, 0, true); + $this->assertEquals($replycount + 1, $counts[$discussion->id]->replies); + + // The user is not allowed to view this post. + $this->setUser($otheruser->id); + $counts = forum_count_discussion_replies($forum->id); + $this->assertEquals($replycount, $counts[$discussion->id]->replies); + } + public function test_discussion_pinned_sort() { list($forum, $discussionids) = $this->create_multiple_discussions_with_replies(10, 5); $cm = get_coursemodule_from_instance('forum', $forum->id); @@ -3519,4 +3594,49 @@ public function test_mod_forum_completion_get_active_rule_descriptions() { $this->assertEquals(mod_forum_get_completion_active_rule_descriptions($moddefaults), $activeruledescriptions); $this->assertEquals(mod_forum_get_completion_active_rule_descriptions(new stdClass()), []); } + + /** + * Test the forum_post_is_visible_privately function used in private replies. + */ + public function test_forum_post_is_visible_privately() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id)); + $context = context_module::instance($forum->cmid); + $cm = get_coursemodule_from_instance('forum', $forum->id); + + $author = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($author->id, $course->id); + + $recipient = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($recipient->id, $course->id); + + $privilegeduser = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($privilegeduser->id, $course->id, 'editingteacher'); + + $otheruser = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($otheruser->id, $course->id); + + // Fake a post - this does not need to be persisted to the DB. + $post = new \stdClass(); + $post->userid = $author->id; + $post->privatereplyto = $recipient->id; + + // The user is the author. + $this->setUser($author->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + + // The user is the intended recipient. + $this->setUser($recipient->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + + // The user is not the author or recipient, but does have the readprivatereplies capability. + $this->setUser($privilegeduser->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + + // The user is not allowed to view this post. + $this->setUser($otheruser->id); + $this->assertFalse(forum_post_is_visible_privately($post, $cm)); + } } diff --git a/mod/forum/tests/mail_test.php b/mod/forum/tests/mail_test.php index 0e18529cd0b19..2b9fa6b75e3dc 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -331,6 +331,56 @@ public function test_automatic() { $this->send_notifications_and_assert($recipient, [$post]); } + /** + * Ensure that private replies are not sent to users with an automatic subscription unless they are an expected + * recipient. + */ + public function test_automatic_with_private_reply() { + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + 'forcesubscribe' => FORUM_INITIALSUBSCRIBE, + ]); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + + [$discussion, $post] = $this->helper_post_to_forum($forum, $student); + $reply = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + // The private reply is queued to all messages as reply visibility may change between queueing, and sending. + $expect = [ + (object) [ + 'userid' => $student->id, + 'messages' => 2, + ], + (object) [ + 'userid' => $otherstudent->id, + 'messages' => 2, + ], + (object) [ + 'userid' => $teacher->id, + 'messages' => 2, + ], + (object) [ + 'userid' => $otherteacher->id, + 'messages' => 2, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // The actual messages sent will respect private replies. + $this->send_notifications_and_assert($student, [$post, $reply]); + $this->send_notifications_and_assert($teacher, [$post, $reply]); + $this->send_notifications_and_assert($otherteacher, [$post, $reply]); + $this->send_notifications_and_assert($otherstudent, [$post]); + } + public function test_optional() { $this->resetAfterTest(true); diff --git a/mod/forum/tests/privacy_provider_test.php b/mod/forum/tests/privacy_provider_test.php index 7c98df6d86ebc..d90dbf4bf0e50 100644 --- a/mod/forum/tests/privacy_provider_test.php +++ b/mod/forum/tests/privacy_provider_test.php @@ -394,6 +394,93 @@ public function test_user_has_posted_reply() { $this->assertEquals(0, $post->deleted); } + /** + * Test private reply in a range of scenarios. + */ + public function test_user_private_reply() { + global $DB; + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $context = \context_module::instance($cm->id); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + + [$discussion, $post] = $this->helper_post_to_forum($forum, $student); + $reply = $this->helper_reply_to_post($post, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + // Testing as user $student. + $this->setUser($student); + + // Retrieve all contexts - only this context should be returned. + $contextlist = $this->get_contexts_for_userid($student->id, 'mod_forum'); + $this->assertCount(1, $contextlist); + $this->assertEquals($context, $contextlist->current()); + + // Export all of the data for the context. + $this->export_context_data_for_user($student->id, $context, 'mod_forum'); + $writer = \core_privacy\local\request\writer::with_context($context); + $this->assertTrue($writer->has_any_data()); + + // The initial post and reply will be included. + $this->assert_post_data($post, $writer->get_data($this->get_subcontext($forum, $discussion, $post)), $writer); + $this->assert_post_data($reply, $writer->get_data($this->get_subcontext($forum, $discussion, $reply)), $writer); + + // Testing as user $teacher. + \core_privacy\local\request\writer::reset(); + $this->setUser($teacher); + + // Retrieve all contexts - only this context should be returned. + $contextlist = $this->get_contexts_for_userid($teacher->id, 'mod_forum'); + $this->assertCount(1, $contextlist); + $this->assertEquals($context, $contextlist->current()); + + // Export all of the data for the context. + $this->export_context_data_for_user($teacher->id, $context, 'mod_forum'); + $writer = \core_privacy\local\request\writer::with_context($context); + $this->assertTrue($writer->has_any_data()); + + // The reply will be included. + $this->assert_post_data($post, $writer->get_data($this->get_subcontext($forum, $discussion, $post)), $writer); + $this->assert_post_data($reply, $writer->get_data($this->get_subcontext($forum, $discussion, $reply)), $writer); + + // Testing as user $otherteacher. + // The user was not involved in any of the conversation. + \core_privacy\local\request\writer::reset(); + $this->setUser($otherteacher); + + // Retrieve all contexts - only this context should be returned. + $contextlist = $this->get_contexts_for_userid($otherteacher->id, 'mod_forum'); + $this->assertCount(0, $contextlist); + + // Export all of the data for the context. + $this->export_context_data_for_user($otherteacher->id, $context, 'mod_forum'); + $writer = \core_privacy\local\request\writer::with_context($context); + + // The user has none of the discussion. + $this->assertEmpty($writer->get_data($this->get_subcontext($forum, $discussion))); + + // Testing as user $otherstudent. + // The user was not involved in any of the conversation. + \core_privacy\local\request\writer::reset(); + $this->setUser($otherstudent); + + // Retrieve all contexts - only this context should be returned. + $contextlist = $this->get_contexts_for_userid($otherstudent->id, 'mod_forum'); + $this->assertCount(0, $contextlist); + + // Export all of the data for the context. + $this->export_context_data_for_user($otherstudent->id, $context, 'mod_forum'); + $writer = \core_privacy\local\request\writer::with_context($context); + + // The user has none of the discussion. + $this->assertEmpty($writer->get_data($this->get_subcontext($forum, $discussion))); + } + /** * Test that the rating of another users content will have only the * rater's information returned. diff --git a/mod/forum/tests/private_replies_test.php b/mod/forum/tests/private_replies_test.php new file mode 100644 index 0000000000000..27a47ce664b99 --- /dev/null +++ b/mod/forum/tests/private_replies_test.php @@ -0,0 +1,242 @@ +. + +/** + * Tests for private reply functionality. + * + * @package mod_forum + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/forum/lib.php'); +require_once($CFG->dirroot . '/mod/forum/locallib.php'); +require_once(__DIR__ . '/generator_trait.php'); + +/** + * Tests for private reply functionality. + * + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class private_replies extends advanced_testcase { + + use mod_forum_tests_generator_trait; + + /** + * Setup before tests. + */ + public function setUp() { + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + } + + /** + * Tear down after tests. + */ + public function tearDown() { + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + } + + /** + * Ensure that the forum_post_is_visible_privately function reports that a post is visible to a user when another + * user wrote the post, and it is not private. + */ + public function test_forum_post_is_visible_privately_not_private() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $teacher); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher); + + $this->setUser($student); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + } + + /** + * Ensure that the forum_post_is_visible_privately function reports that a post is visible to a user when another + * user wrote the post, and the user under test is the intended recipient. + */ + public function test_forum_post_is_visible_privately_private_to_user() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $teacher); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + $this->setUser($student); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + } + + /** + * Ensure that the forum_post_is_visible_privately function reports that a post is visible to a user when another + * user wrote the post, and the user under test is a role with the view capability. + */ + public function test_forum_post_is_visible_privately_private_to_user_view_as_teacher() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $teacher); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + $this->setUser($otherteacher); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + } + + /** + * Ensure that the forum_post_is_visible_privately function reports that a post is not visible to a user when + * another user wrote the post, and the user under test is a role without the view capability. + */ + public function test_forum_post_is_visible_privately_private_to_user_view_as_other_student() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $teacher); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + $this->setUser($otherstudent); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $this->assertFalse(forum_post_is_visible_privately($post, $cm)); + } + + /** + * Ensure that the forum_post_is_visible_privately function reports that a post is visible to a user who wrote a + * private reply, but not longer holds the view capability. + */ + public function test_forum_post_is_visible_privately_private_to_user_view_as_author() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $teacher); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + unassign_capability('mod/forum:readprivatereplies', $this->get_role_id('teacher')); + + $this->setUser($teacher); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $this->assertTrue(forum_post_is_visible_privately($post, $cm)); + } + + /** + * Ensure that the forum_user_can_reply_privately returns true for a teacher replying to a forum post. + */ + public function test_forum_user_can_reply_privately_as_teacher() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [, $post] = $this->helper_post_to_forum($forum, $student); + + $this->setUser($teacher); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $context = \context_module::instance($cm->id); + $this->assertTrue(forum_user_can_reply_privately($context, $post)); + } + + /** + * Ensure that the forum_user_can_reply_privately returns true for a teacher replying to a forum post. + */ + public function test_forum_user_can_reply_privately_as_student() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [, $post] = $this->helper_post_to_forum($forum, $student); + + $this->setUser($otherstudent); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $context = \context_module::instance($cm->id); + $this->assertFalse(forum_user_can_reply_privately($context, $post)); + } + + /** + * Ensure that the forum_user_can_reply_privately returns false where the parent post is already a private reply. + */ + public function test_forum_user_can_reply_privately_parent_is_already_private() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student] = $this->helper_create_users($course, 1, 'student'); + [$teacher] = $this->helper_create_users($course, 1, 'teacher'); + [$discussion] = $this->helper_post_to_forum($forum, $student); + $post = $this->helper_post_to_discussion($forum, $discussion, $teacher, ['privatereplyto' => $student->id]); + + $this->setUser($teacher); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $context = \context_module::instance($cm->id); + $this->assertFalse(forum_user_can_reply_privately($context, $post)); + } +} diff --git a/mod/forum/tests/vaults_post_test.php b/mod/forum/tests/vaults_post_test.php index 44961fce5ee74..f877bc73575fe 100644 --- a/mod/forum/tests/vaults_post_test.php +++ b/mod/forum/tests/vaults_post_test.php @@ -91,17 +91,63 @@ public function test_get_from_discussion_id() { $post3 = $this->helper_reply_to_post($post1, $user); [$discussion2, $post4] = $this->helper_post_to_forum($forum, $user); - $entities = array_values($this->vault->get_from_discussion_id($discussion1->id)); + $entities = array_values($this->vault->get_from_discussion_id($user, $discussion1->id, false)); $this->assertCount(3, $entities); $this->assertEquals($post1->id, $entities[0]->get_id()); $this->assertEquals($post2->id, $entities[1]->get_id()); $this->assertEquals($post3->id, $entities[2]->get_id()); - $entities = array_values($this->vault->get_from_discussion_id($discussion1->id + 1000)); + $entities = array_values($this->vault->get_from_discussion_id($user, $discussion1->id + 1000, false)); $this->assertCount(0, $entities); } + /** + * Ensure that selecting posts in a discussion only returns posts that the user can see, when considering private + * replies. + * + * @covers ::get_from_discussion_id + * @covers :: + */ + public function test_get_from_discussion_id_private_replies() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + [$discussion, $post] = $this->helper_post_to_forum($forum, $teacher); + $reply = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + + // The user is the author. + $entities = array_values($this->vault->get_from_discussion_id($teacher, $discussion->id, true)); + $this->assertCount(2, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + + // The user is the intended recipient. + $entities = array_values($this->vault->get_from_discussion_id($student, $discussion->id, false)); + $this->assertCount(2, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + + // The user is another teacher.. + $entities = array_values($this->vault->get_from_discussion_id($otherteacher, $discussion->id, true)); + $this->assertCount(2, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + + // The user is a different student. + $entities = array_values($this->vault->get_from_discussion_id($otherstudent, $discussion->id, false)); + $this->assertCount(1, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + } + /** * Test get_from_discussion_ids when no discussion ids were provided. * @@ -115,7 +161,7 @@ public function test_get_from_discussion_ids_empty() { $course = $datagenerator->create_course(); $forum = $datagenerator->create_module('forum', ['course' => $course->id]); - $this->assertEquals([], $this->vault->get_from_discussion_ids([])); + $this->assertEquals([], $this->vault->get_from_discussion_ids($user, [], false)); } /** @@ -136,7 +182,7 @@ public function test_get_from_discussion_ids() { $post3 = $this->helper_reply_to_post($post1, $user); [$discussion2, $post4] = $this->helper_post_to_forum($forum, $user); - $entities = array_values($this->vault->get_from_discussion_ids([$discussion1->id])); + $entities = array_values($this->vault->get_from_discussion_ids($user, [$discussion1->id], false)); usort($entities, function($a, $b) { return $a <=> $b; }); @@ -145,7 +191,7 @@ public function test_get_from_discussion_ids() { $this->assertEquals($post2->id, $entities[1]->get_id()); $this->assertEquals($post3->id, $entities[2]->get_id()); - $entities = array_values($this->vault->get_from_discussion_ids([$discussion1->id, $discussion2->id])); + $entities = array_values($this->vault->get_from_discussion_ids($user, [$discussion1->id, $discussion2->id], false)); usort($entities, function($a, $b) { return $a <=> $b; }); @@ -156,6 +202,59 @@ public function test_get_from_discussion_ids() { $this->assertEquals($post4->id, $entities[3]->get_id()); } + /** + * Ensure that selecting posts in a discussion only returns posts that the user can see, when considering private + * replies. + * + * @covers ::get_from_discussion_ids + * @covers :: + */ + public function test_get_from_discussion_ids_private_replies() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + [$discussion, $post] = $this->helper_post_to_forum($forum, $teacher); + $reply = $this->helper_post_to_discussion($forum, $discussion, $teacher, [ + 'privatereplyto' => $student->id, + ]); + [$otherdiscussion, $otherpost] = $this->helper_post_to_forum($forum, $teacher); + + // The user is the author. + $entities = array_values($this->vault->get_from_discussion_ids($teacher, [$discussion->id, $otherdiscussion->id], true)); + $this->assertCount(3, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + $this->assertEquals($otherpost->id, $entities[2]->get_id()); + + // The user is the intended recipient. + $entities = array_values($this->vault->get_from_discussion_ids($student, [$discussion->id, $otherdiscussion->id], false)); + $this->assertCount(3, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + $this->assertEquals($otherpost->id, $entities[2]->get_id()); + + // The user is another teacher.. + $entities = array_values( + $this->vault->get_from_discussion_ids($otherteacher, [$discussion->id, $otherdiscussion->id], true)); + $this->assertCount(3, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($reply->id, $entities[1]->get_id()); + $this->assertEquals($otherpost->id, $entities[2]->get_id()); + + // The user is a different student. + $entities = array_values( + $this->vault->get_from_discussion_ids($otherstudent, [$discussion->id, $otherdiscussion->id], false)); + $this->assertCount(2, $entities); + $this->assertEquals($post->id, $entities[0]->get_id()); + $this->assertEquals($otherpost->id, $entities[1]->get_id()); + } + /** * Test get_replies_to_post. * @@ -191,20 +290,181 @@ public function test_get_replies_to_post() { $post3 = $entityfactory->get_post_from_stdclass($post3); $post4 = $entityfactory->get_post_from_stdclass($post4); - $entities = $this->vault->get_replies_to_post($post1); + $entities = $this->vault->get_replies_to_post($user, $post1, false); $this->assertCount(3, $entities); $this->assertEquals($post2->get_id(), $entities[0]->get_id()); $this->assertEquals($post3->get_id(), $entities[1]->get_id()); $this->assertEquals($post4->get_id(), $entities[2]->get_id()); - $entities = $this->vault->get_replies_to_post($post2); + $entities = $this->vault->get_replies_to_post($user, $post2, false); $this->assertCount(1, $entities); $this->assertEquals($post4->get_id(), $entities[0]->get_id()); - $entities = $this->vault->get_replies_to_post($post3); + $entities = $this->vault->get_replies_to_post($user, $post3, false); $this->assertCount(0, $entities); } + /** + * Test get_replies_to_post with private replies. + * + * @covers ::get_replies_to_post + * @covers :: + */ + public function test_get_replies_to_post_private_replies() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + ]); + + // Generate a structure: + // Initial post p [student] + // -> Reply pa [otherstudent] + // ---> Reply paa [student] + // ---> Private Reply pab [teacher] + // -> Private Reply pb [teacher] + // -> Reply pc [otherstudent] + // ---> Reply pca [student] + // -----> Reply pcaa [otherstudent] + // -------> Private Reply pcaaa [teacher] + + [$student, $otherstudent] = $this->helper_create_users($course, 2, 'student'); + [$teacher, $otherteacher] = $this->helper_create_users($course, 2, 'teacher'); + + [$discussion, $p] = $this->helper_post_to_forum($forum, $student); + + $pa = $this->helper_reply_to_post($p, $otherstudent); + $paa = $this->helper_reply_to_post($pa, $student); + $pab = $this->helper_reply_to_post($pa, $teacher, ['privatereplyto' => $otherstudent->id]); + + $pb = $this->helper_reply_to_post($p, $teacher, ['privatereplyto' => $student->id]); + + $pc = $this->helper_reply_to_post($p, $otherteacher); + $pca = $this->helper_reply_to_post($pc, $student); + $pcaa = $this->helper_reply_to_post($pca, $otherstudent); + $pcaaa = $this->helper_reply_to_post($pcaa, $teacher, ['privatereplyto' => $otherstudent->id]); + + $entityfactory = \mod_forum\local\container::get_entity_factory(); + $ep = $entityfactory->get_post_from_stdclass($p); + $epa = $entityfactory->get_post_from_stdclass($pa); + $epaa = $entityfactory->get_post_from_stdclass($paa); + $epab = $entityfactory->get_post_from_stdclass($pab); + $epb = $entityfactory->get_post_from_stdclass($pb); + $epc = $entityfactory->get_post_from_stdclass($pc); + $epca = $entityfactory->get_post_from_stdclass($pca); + $epcaa = $entityfactory->get_post_from_stdclass($pcaa); + $epcaaa = $entityfactory->get_post_from_stdclass($pcaaa); + + // As `student`, you should see all public posts, plus all private replies intended for you. + $entities = $this->vault->get_replies_to_post($student, $ep, false); + $this->assertCount(6, $entities); + $this->assertEquals($epa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epb->get_id(), $entities[2]->get_id()); + $this->assertEquals($epc->get_id(), $entities[3]->get_id()); + $this->assertEquals($epca->get_id(), $entities[4]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[5]->get_id()); + + $entities = $this->vault->get_replies_to_post($student, $epa, false); + $this->assertCount(1, $entities); + $this->assertEquals($epaa->get_id(), $entities[0]->get_id()); + + $this->assertEmpty($this->vault->get_replies_to_post($student, $epaa, false)); + $this->assertEmpty($this->vault->get_replies_to_post($student, $epab, false)); + $this->assertEmpty($this->vault->get_replies_to_post($student, $epb, false)); + $this->assertEmpty($this->vault->get_replies_to_post($student, $epcaa, false)); + $this->assertEmpty($this->vault->get_replies_to_post($student, $epcaaa, false)); + + $entities = $this->vault->get_replies_to_post($student, $epc, false); + $this->assertCount(2, $entities); + $this->assertEquals($epca->get_id(), $entities[0]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[1]->get_id()); + + // As `otherstudent`, you should see all public posts, plus all private replies intended for you. + $entities = $this->vault->get_replies_to_post($otherstudent, $ep, false); + $this->assertCount(7, $entities); + $this->assertEquals($epa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epab->get_id(), $entities[2]->get_id()); + $this->assertEquals($epc->get_id(), $entities[3]->get_id()); + $this->assertEquals($epca->get_id(), $entities[4]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[5]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[6]->get_id()); + + $entities = $this->vault->get_replies_to_post($otherstudent, $epa, false); + $this->assertCount(2, $entities); + $this->assertEquals($epaa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epab->get_id(), $entities[1]->get_id()); + + $this->assertEmpty($this->vault->get_replies_to_post($otherstudent, $epaa, false)); + $this->assertEmpty($this->vault->get_replies_to_post($otherstudent, $epab, false)); + $this->assertEmpty($this->vault->get_replies_to_post($otherstudent, $epb, false)); + $this->assertEmpty($this->vault->get_replies_to_post($otherstudent, $epcaaa, false)); + + $entities = $this->vault->get_replies_to_post($otherstudent, $epc, false); + $this->assertCount(3, $entities); + $this->assertEquals($epca->get_id(), $entities[0]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[2]->get_id()); + + // The teacher who authored the private replies can see all. + $entities = $this->vault->get_replies_to_post($teacher, $ep, true); + $this->assertCount(8, $entities); + $this->assertEquals($epa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epab->get_id(), $entities[2]->get_id()); + $this->assertEquals($epb->get_id(), $entities[3]->get_id()); + $this->assertEquals($epc->get_id(), $entities[4]->get_id()); + $this->assertEquals($epca->get_id(), $entities[5]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[6]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[7]->get_id()); + + $entities = $this->vault->get_replies_to_post($teacher, $epa, true); + $this->assertCount(2, $entities); + $this->assertEquals($epaa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epab->get_id(), $entities[1]->get_id()); + + $this->assertEmpty($this->vault->get_replies_to_post($teacher, $epaa, true)); + $this->assertEmpty($this->vault->get_replies_to_post($teacher, $epab, true)); + $this->assertEmpty($this->vault->get_replies_to_post($teacher, $epb, true)); + $this->assertEmpty($this->vault->get_replies_to_post($teacher, $epcaaa, true)); + + $entities = $this->vault->get_replies_to_post($teacher, $epc, true); + $this->assertCount(3, $entities); + $this->assertEquals($epca->get_id(), $entities[0]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[2]->get_id()); + + // Any other teacher can also see all. + $entities = $this->vault->get_replies_to_post($otherteacher, $ep, true); + $this->assertCount(8, $entities); + $this->assertEquals($epa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epab->get_id(), $entities[2]->get_id()); + $this->assertEquals($epb->get_id(), $entities[3]->get_id()); + $this->assertEquals($epc->get_id(), $entities[4]->get_id()); + $this->assertEquals($epca->get_id(), $entities[5]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[6]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[7]->get_id()); + + $entities = $this->vault->get_replies_to_post($otherteacher, $epa, true); + $this->assertCount(2, $entities); + $this->assertEquals($epaa->get_id(), $entities[0]->get_id()); + $this->assertEquals($epab->get_id(), $entities[1]->get_id()); + + $this->assertEmpty($this->vault->get_replies_to_post($otherteacher, $epaa, true)); + $this->assertEmpty($this->vault->get_replies_to_post($otherteacher, $epab, true)); + $this->assertEmpty($this->vault->get_replies_to_post($otherteacher, $epb, true)); + $this->assertEmpty($this->vault->get_replies_to_post($otherteacher, $epcaaa, true)); + + $entities = $this->vault->get_replies_to_post($otherteacher, $epc, true); + $this->assertCount(3, $entities); + $this->assertEquals($epca->get_id(), $entities[0]->get_id()); + $this->assertEquals($epcaa->get_id(), $entities[1]->get_id()); + $this->assertEquals($epcaaa->get_id(), $entities[2]->get_id()); + } + /** * Test get_reply_count_for_discussion_ids when no discussion ids were provided. * @@ -218,8 +478,7 @@ public function test_get_reply_count_for_discussion_ids_empty() { $course = $datagenerator->create_course(); $forum = $datagenerator->create_module('forum', ['course' => $course->id]); - $counts = $this->vault->get_reply_count_for_discussion_ids([]); - $this->assertCount(0, $counts); + $this->assertCount(0, $this->vault->get_reply_count_for_discussion_ids($user, [], false)); } /** @@ -243,26 +502,26 @@ public function test_get_reply_count_for_discussion_ids() { $post6 = $this->helper_reply_to_post($post5, $user); [$discussion3, $post7] = $this->helper_post_to_forum($forum, $user); - $counts = $this->vault->get_reply_count_for_discussion_ids([$discussion1->id]); + $counts = $this->vault->get_reply_count_for_discussion_ids($user, [$discussion1->id], false); $this->assertCount(1, $counts); $this->assertEquals(3, $counts[$discussion1->id]); - $counts = $this->vault->get_reply_count_for_discussion_ids([$discussion1->id, $discussion2->id]); + $counts = $this->vault->get_reply_count_for_discussion_ids($user, [$discussion1->id, $discussion2->id], false); $this->assertCount(2, $counts); $this->assertEquals(3, $counts[$discussion1->id]); $this->assertEquals(1, $counts[$discussion2->id]); - $counts = $this->vault->get_reply_count_for_discussion_ids([$discussion1->id, $discussion2->id, $discussion3->id]); + $counts = $this->vault->get_reply_count_for_discussion_ids($user, [$discussion1->id, $discussion2->id, $discussion3->id], false); $this->assertCount(2, $counts); $this->assertEquals(3, $counts[$discussion1->id]); $this->assertEquals(1, $counts[$discussion2->id]); - $counts = $this->vault->get_reply_count_for_discussion_ids([ + $counts = $this->vault->get_reply_count_for_discussion_ids($user, [ $discussion1->id, $discussion2->id, $discussion3->id, $discussion3->id + 1000 - ]); + ], false); $this->assertCount(2, $counts); $this->assertEquals(3, $counts[$discussion1->id]); $this->assertEquals(1, $counts[$discussion2->id]); @@ -305,11 +564,11 @@ public function test_get_unread_count_for_discussion_ids() { forum_tp_add_read_record($user->id, $post4->id); $CFG->forum_oldpostdays = 1; - $counts = $this->vault->get_unread_count_for_discussion_ids($user, [$discussion1->id]); + $counts = $this->vault->get_unread_count_for_discussion_ids($user, [$discussion1->id], false); $this->assertCount(1, $counts); $this->assertEquals(2, $counts[$discussion1->id]); - $counts = $this->vault->get_unread_count_for_discussion_ids($user, [$discussion1->id, $discussion2->id]); + $counts = $this->vault->get_unread_count_for_discussion_ids($user, [$discussion1->id, $discussion2->id], false); $this->assertCount(2, $counts); $this->assertEquals(2, $counts[$discussion1->id]); $this->assertEquals(2, $counts[$discussion2->id]); @@ -318,12 +577,12 @@ public function test_get_unread_count_for_discussion_ids() { $discussion1->id, $discussion2->id, $discussion2->id + 1000 - ]); + ], false); $this->assertCount(2, $counts); $this->assertEquals(2, $counts[$discussion1->id]); $this->assertEquals(2, $counts[$discussion2->id]); - $counts = $this->vault->get_unread_count_for_discussion_ids($otheruser, [$discussion1->id, $discussion2->id]); + $counts = $this->vault->get_unread_count_for_discussion_ids($otheruser, [$discussion1->id, $discussion2->id], false); $this->assertCount(2, $counts); $this->assertEquals(4, $counts[$discussion1->id]); $this->assertEquals(2, $counts[$discussion2->id]); @@ -366,27 +625,27 @@ public function test_get_latest_post_id_for_discussion_ids() { $post6 = $this->helper_reply_to_post($post5, $user); [$discussion3, $post7] = $this->helper_post_to_forum($forum, $user); - $ids = $this->vault->get_latest_post_id_for_discussion_ids([$discussion1->id]); + $ids = $this->vault->get_latest_post_id_for_discussion_ids($user, [$discussion1->id], false); $this->assertCount(1, $ids); $this->assertEquals($post4->id, $ids[$discussion1->id]); - $ids = $this->vault->get_latest_post_id_for_discussion_ids([$discussion1->id, $discussion2->id]); + $ids = $this->vault->get_latest_post_id_for_discussion_ids($user, [$discussion1->id, $discussion2->id], false); $this->assertCount(2, $ids); $this->assertEquals($post4->id, $ids[$discussion1->id]); $this->assertEquals($post6->id, $ids[$discussion2->id]); - $ids = $this->vault->get_latest_post_id_for_discussion_ids([$discussion1->id, $discussion2->id, $discussion3->id]); + $ids = $this->vault->get_latest_post_id_for_discussion_ids($user, [$discussion1->id, $discussion2->id, $discussion3->id], false); $this->assertCount(3, $ids); $this->assertEquals($post4->id, $ids[$discussion1->id]); $this->assertEquals($post6->id, $ids[$discussion2->id]); $this->assertEquals($post7->id, $ids[$discussion3->id]); - $ids = $this->vault->get_latest_post_id_for_discussion_ids([ + $ids = $this->vault->get_latest_post_id_for_discussion_ids($user, [ $discussion1->id, $discussion2->id, $discussion3->id, $discussion3->id + 1000 - ]); + ], false); $this->assertCount(3, $ids); $this->assertEquals($post4->id, $ids[$discussion1->id]); $this->assertEquals($post6->id, $ids[$discussion2->id]); @@ -407,6 +666,6 @@ public function test_get_latest_post_id_for_discussion_ids_empty() { $course = $datagenerator->create_course(); $forum = $datagenerator->create_module('forum', ['course' => $course->id]); - $this->assertEquals([], $this->vault->get_latest_post_id_for_discussion_ids([])); + $this->assertEquals([], $this->vault->get_latest_post_id_for_discussion_ids($user, [], false)); } } diff --git a/mod/forum/upgrade.txt b/mod/forum/upgrade.txt index 3f301ab27139b..b0a6c7107fe4c 100644 --- a/mod/forum/upgrade.txt +++ b/mod/forum/upgrade.txt @@ -6,8 +6,10 @@ information provided here is intended especially for developers. * Added new forum entities, factories, exporters, renderers, and vaults in the local namespace to better encapsulate the forum data. * Deprecated all of the forum_print_* functions in lib.php. * The forum_print_latest_discussions function has been deprecated and will not be replaced. + * The get_forum_discussion_posts web service has been deprecated in favour of get_discussion_posts. === 3.6 === + * forum_print_post should be surrounded with calls to forum_print_post_start and forum_print_post_end to create the proper HTML structure for the post. === 3.4 === diff --git a/mod/forum/version.php b/mod/forum/version.php index 2eb0507e4eab9..52f7fce43dd53 100644 --- a/mod/forum/version.php +++ b/mod/forum/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2018120302; // The current module version (Date: YYYYMMDDXX) +$plugin->version = 2019031300; // 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) diff --git a/mod/forum/view.php b/mod/forum/view.php index 8f0d37f341897..76dda7f7fd8a4 100644 --- a/mod/forum/view.php +++ b/mod/forum/view.php @@ -146,7 +146,12 @@ $hasmultiplediscussions, $displaymode); $post = $postvault->get_from_id($discussion->get_first_post_id()); $orderpostsby = $displaymode == FORUM_MODE_FLATNEWEST ? 'created DESC' : 'created ASC'; - $replies = $postvault->get_replies_to_post($post, $orderpostsby); + $replies = $postvault->get_replies_to_post( + $USER, + $post, + $capabilitymanager->can_view_any_private_reply($USER), + $orderpostsby + ); echo $discussionsrenderer->render($USER, $post, $replies); break; case 'blog':