From abed8cddbf3703b4040fc8af5f3ec939eaccab1d Mon Sep 17 00:00:00 2001 From: Safat Date: Thu, 4 Jan 2024 12:48:25 +1100 Subject: [PATCH] MDL-78551 core_communication: Add hook listeners --- communication/classes/api.php | 177 ++++- communication/classes/constants.php | 45 ++ communication/classes/helper.php | 547 +++++++++++++++ communication/classes/hook_listener.php | 634 ++++++++++++++++++ communication/classes/processor.php | 11 +- communication/tests/api_test.php | 194 ++++++ .../tests/communication_test_helper_trait.php | 5 +- communication/tests/helper_test.php | 223 ++++++ communication/tests/hook_listener_test.php | 461 +++++++++++++ lib/outputrenderers.php | 13 +- 10 files changed, 2285 insertions(+), 25 deletions(-) create mode 100644 communication/classes/constants.php create mode 100644 communication/classes/helper.php create mode 100644 communication/classes/hook_listener.php create mode 100644 communication/tests/helper_test.php create mode 100644 communication/tests/hook_listener_test.php diff --git a/communication/classes/api.php b/communication/classes/api.php index 8dee56b0954..8e02e9a15cf 100644 --- a/communication/classes/api.php +++ b/communication/classes/api.php @@ -118,9 +118,9 @@ class api { /** * Return the underlying communication processor object. * - * @return processor + * @return ?processor */ - public function get_processor(): processor { + public function get_processor(): ?processor { return $this->communication; } @@ -405,6 +405,114 @@ class api { return $this->communication->get_provider(); } + /** + * Configure the room and membership by provider selected for the communication instance. + * + * This method will add a task to the queue to configure the room and membership by comparing the change of provider. + * There are some major cases to consider for this method to allow minimum duplication when this api is used. + * Some of the major cases are: + * 1. If the communication instance is not created at all, then create it and add members. + * 2. If the current provider is none and the new provider is also none, then nothing to do. + * 3. If the current and existing provider is the same, don't need to do anything. + * 4. If provider set to none, remove all the members. + * 5. If previous provider was not none and current provider is not none, but a different provider, remove members and add + * for the new one. + * 6. If previous provider was none and current provider is not none, don't need to remove, just + * update the selected provider and add users to that provider. Do not queue the task to add members to room as the room + * might not have created yet. The add room task adds the task to add members to room anyway. + * 7. If it's a new provider, never used/created, now create the room after considering all these cases for a new provider. + * + * @param string $provider The provider name + * @param \stdClass $instance The instance object + * @param string $communicationroomname The communication room name + * @param array $users The user ids to add to the room + * @param null|\stored_file $instanceimage The stored file for the avatar + */ + public function configure_room_and_membership_by_provider( + string $provider, + stdClass $instance, + string $communicationroomname, + array $users, + ?\stored_file $instanceimage = null, + ): void { + // If the current provider is inactive and the new provider is also none, then nothing to do. + if ( + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_INACTIVE && + $provider === processor::PROVIDER_NONE + ) { + return; + } + + // If provider set to none, remove all the members. + if ( + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && + $provider === processor::PROVIDER_NONE + ) { + $this->remove_all_members_from_room(); + $this->update_room( + active: processor::PROVIDER_INACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + return; + } + + if ( + // If previous provider was active and not none and current provider is not none, but a different provider, + // remove members and de-activate the previous provider. + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && + $provider !== $this->get_provider() + ) { + $this->remove_all_members_from_room(); + // Now deactivate the previous provider. + $this->update_room( + active: processor::PROVIDER_INACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + } + + // Now re-init the constructor for the new provider. + $this->__construct( + context: $this->context, + component: $this->component, + instancetype: $this->instancetype, + instanceid: $this->instanceid, + provider: $provider, + ); + + // If it's a new provider, never used/created, now create the room. + if ($this->communication === null) { + $this->create_and_configure_room( + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + $queue = false; + } else { + // Otherwise update the room. + $this->update_room( + active: processor::PROVIDER_ACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + $queue = true; + } + + // Now add the members. + $this->add_members_to_room( + userids: $users, + queue: $queue, + ); + + } + /** * Create a communication ad-hoc task for create operation. * This method will add a task to the queue to create the room. @@ -412,16 +520,17 @@ class api { * @param string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object + * @param bool $queue Whether to queue the task or not */ public function create_and_configure_room( string $communicationroomname, ?\stored_file $avatar = null, ?\stdClass $instance = null, + bool $queue = true, ): void { if ($this->provider === processor::PROVIDER_NONE || $this->provider === '') { return; } - // Create communication record. $this->communication = processor::create_instance( context: $this->context, @@ -442,6 +551,11 @@ class api { $this->set_avatar($avatar); } + // Nothing else to do if the queue is false. + if (!$queue) { + return; + } + // Add ad-hoc task to create the provider room. create_and_configure_room_task::queue( $this->communication, @@ -456,13 +570,19 @@ class api { * @param null|string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object + * @param bool $queue Whether to queue the task or not */ public function update_room( ?int $active = null, ?string $communicationroomname = null, ?\stored_file $avatar = null, ?\stdClass $instance = null, + bool $queue = true, ): void { + if (!$this->communication) { + return; + } + // If the provider is none, we don't need to do anything from room point of view. if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; @@ -503,6 +623,11 @@ class api { // If the value is `null`, then unset the avatar. $this->set_avatar($avatar); + // Nothing else to do if the queue is false. + if (!$queue) { + return; + } + // Always queue a room update, even if none of the above standard fields have changed. // It is possible for providers to have custom fields that have been updated. update_room_task::queue( @@ -613,6 +738,35 @@ class api { } } + /** + * Remove all users from the room. + * + * @param bool $queue Whether to queue the task or not + */ + public function remove_all_members_from_room(bool $queue = true): void { + // No communication object? something not done right. + if (!$this->communication) { + return; + } + + if ($this->communication->get_provider() === processor::PROVIDER_NONE) { + return; + } + + // This provider does not manage users? No action required. + if (!$this->communication->supports_user_features()) { + return; + } + + $this->communication->add_delete_user_flag($this->communication->get_all_userids_for_instance()); + + if ($queue) { + remove_members_from_room::queue( + $this->communication + ); + } + } + /** * Display the communication room status notification. */ @@ -626,20 +780,23 @@ class api { return; } - $roomstatus = $this->get_communication_room_url() ? 'ready' : 'pending'; + $roomstatus = $this->get_communication_room_url() + ? constants::COMMUNICATION_STATUS_READY + : constants::COMMUNICATION_STATUS_PENDING; $pluginname = get_string('pluginname', $this->get_provider()); $message = get_string('communicationroom' . $roomstatus, 'communication', $pluginname); + // We only show the ready notification once per user. + // We check this with a custom user preference. + $roomreadypreference = "{$this->component}_{$this->instancetype}_{$this->instanceid}_room_ready"; + switch ($roomstatus) { - case 'pending': + case constants::COMMUNICATION_STATUS_PENDING: \core\notification::add($message, \core\notification::INFO); + unset_user_preference($roomreadypreference); break; - case 'ready': - // We only show the ready notification once per user. - // We check this with a custom user preference. - $roomreadypreference = "{$this->component}_{$this->instancetype}_{$this->instanceid}_room_ready"; - + case constants::COMMUNICATION_STATUS_READY: if (empty(get_user_preferences($roomreadypreference))) { \core\notification::add($message, \core\notification::SUCCESS); set_user_preference($roomreadypreference, true); diff --git a/communication/classes/constants.php b/communication/classes/constants.php new file mode 100644 index 00000000000..323ad6c8c6e --- /dev/null +++ b/communication/classes/constants.php @@ -0,0 +1,45 @@ +. + +namespace core_communication; + +/** + * Constants for communication api. + * + * @package core_communication + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class constants { + + /** @var string GROUP_COMMUNICATION_INSTANCETYPE The group communication instance type. */ + public const GROUP_COMMUNICATION_INSTANCETYPE = 'groupcommunication'; + + /** @var string GROUP_COMMUNICATION_COMPONENT The group communication component. */ + public const GROUP_COMMUNICATION_COMPONENT = 'core_group'; + + /** @var string COURSE_COMMUNICATION_INSTANCETYPE The course communication instance type. */ + public const COURSE_COMMUNICATION_INSTANCETYPE = 'coursecommunication'; + + /** @var string COURSE_COMMUNICATION_COMPONENT The course communication component. */ + public const COURSE_COMMUNICATION_COMPONENT = 'core_course'; + + /** @var string COMMUNICATION_STATUS_PENDING The communication status pending. */ + public const COMMUNICATION_STATUS_PENDING = 'pending'; + + /** @var string COMMUNICATION_STATUS_READY The communication status sent. */ + public const COMMUNICATION_STATUS_READY = 'ready'; +} diff --git a/communication/classes/helper.php b/communication/classes/helper.php new file mode 100644 index 00000000000..f1ea4a99050 --- /dev/null +++ b/communication/classes/helper.php @@ -0,0 +1,547 @@ +. + +namespace core_communication; + +use context; +use stdClass; + +/** + * Helper method for communication. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Load the communication instance for group id. + * + * @param int $groupid The group id + * @param context $context The context, to make sure any instance using group can load the communication instance + * @return api The communication instance. + */ + public static function load_by_group(int $groupid, context $context): api { + return \core_communication\api::load_by_instance( + context: $context, + component: constants::GROUP_COMMUNICATION_COMPONENT, + instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, + instanceid: $groupid, + ); + } + + /** + * Load the communication instance for course id. + * + * @param int $courseid The course id + * @param \context $context The context + * @param string|null $provider The provider name + * @return api The communication instance + */ + public static function load_by_course( + int $courseid, + \context $context, + ?string $provider = null, + ): api { + return \core_communication\api::load_by_instance( + context: $context, + component: constants::COURSE_COMMUNICATION_COMPONENT, + instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, + instanceid: $courseid, + provider: $provider, + ); + } + + /** + * Communication api call to create room for a group if course has group mode enabled. + * + * @param int $courseid The course id. + * @return stdClass + */ + public static function get_course(int $courseid): stdClass { + global $DB; + return $DB->get_record( + table: 'course', + conditions: ['id' => $courseid], + strictness: MUST_EXIST, + ); + } + + /** + * Is group mode enabled for the course. + * + * @param stdClass $course The course object + */ + public static function is_group_mode_enabled_for_course(stdClass $course): bool { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return false; + } + + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + return (int)$groupmode !== NOGROUPS; + } + + /** + * Helper to update room membership according to action passed. + * This method will help reduce a large amount of duplications of code in different places in core. + * + * @param \stdClass $course The course object. + * @param array $userids The user ids to add to the communication room. + * @param string $memberaction The action to perform on the communication room. + */ + public static function update_course_communication_room_membership( + \stdClass $course, + array $userids, + string $memberaction, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Validate communication api action. + $roomuserprovider = new \ReflectionClass(room_user_provider::class); + if (!$roomuserprovider->hasMethod($memberaction)) { + throw new \coding_exception('Invalid action provided.'); + } + + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set, then just handle the update normally for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->$memberaction($userids); + } else { + // If group mode is set, then handle the update for these users with repect to the group they are in. + $coursegroups = groups_get_all_groups(courseid: $course->id); + + $usershandled = []; + + // Filter all the users that have the capability to access all groups. + $allaccessgroupusers = self::get_users_has_access_to_all_groups( + userids: $userids, + courseid: $course->id, + ); + + foreach ($coursegroups as $coursegroup) { + + // Get all group members. + $groupmembers = groups_get_members(groupid: $coursegroup->id, fields: 'u.id'); + $groupmembers = array_column($groupmembers, 'id'); + + // Find the common user ids between the group members and incoming userids. + $groupuserstohandle = array_intersect( + $groupmembers, + $userids, + ); + + // Add users who have the capability to access this group (and haven't been added already). + foreach ($allaccessgroupusers as $allaccessgroupuser) { + if (!in_array($allaccessgroupuser, $groupuserstohandle, true)) { + $groupuserstohandle[] = $allaccessgroupuser; + } + } + + // Keep track of the users we have handled already. + $usershandled = array_merge($usershandled, $groupuserstohandle); + + // Let's check if we need to add/remove members from room because of a role change. + // First, get all the instance users for this group. + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $instanceusers = $communication->get_processor()->get_all_userids_for_instance(); + + // The difference between the instance users and the group members are the ones we want to check. + $roomuserstocheck = array_diff( + $instanceusers, + $groupmembers + ); + + if (!empty($roomuserstocheck)) { + // Check if they still have the capability to keep their access in the room. + $userslostcaps = array_diff( + $roomuserstocheck, + self::get_users_has_access_to_all_groups( + userids: $roomuserstocheck, + courseid: $course->id, + ), + ); + // Remove users who no longer have the capability. + if (!empty($userslostcaps)) { + $communication->remove_members_from_room(userids: $userslostcaps); + } + } + + // Check if we have to add any room members who have gained the capability. + $usersgainedcaps = array_diff( + $allaccessgroupusers, + $instanceusers, + ); + + // If we have users, add them to the room. + if (!empty($usersgainedcaps)) { + $communication->add_members_to_room(userids: $usersgainedcaps); + } + + // Finally, trigger the update task for the users who need to be handled. + $communication->$memberaction($groupuserstohandle); + } + + // If the user was not in any group, but an update/remove action was requested for the user, + // then the user must have had a role with the capablity, but made a regular user. + $usersnothandled = array_diff($userids, $usershandled); + + // These users are not handled and not in any group, so logically these users lost their permission to stay in the room. + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->remove_members_from_room(userids: $usersnothandled); + } + } + } + + /** + * Get users with the capability to access all groups. + * + * @param array $userids user ids to check the permission + * @param int $courseid course id + * @return array of userids + */ + public static function get_users_has_access_to_all_groups( + array $userids, + int $courseid + ): array { + $allgroupsusers = []; + $context = \context_course::instance(courseid: $courseid); + + foreach ($userids as $userid) { + if ( + has_capability( + capability: 'moodle/site:accessallgroups', + context: $context, + user: $userid, + ) + ) { + $allgroupsusers[] = $userid; + } + } + + return $allgroupsusers; + } + + /** + * Get the course communication url according to course setup. + * + * @param stdClass $course The course object. + * @return string The communication room url. + */ + public static function get_course_communication_url(stdClass $course): string { + // If it's called from site context, then just return. + if ($course->id === SITEID) { + return ''; + } + + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return ''; + } + + $url = ''; + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $url = $communication->get_communication_room_url(); + } else { + // If group mode is set then handle the group communication rooms for these users. + $coursegroups = groups_get_all_groups(courseid: $course->id); + $numberofgroups = count($coursegroups); + + // If no groups available, nothing to show. + if ($numberofgroups === 0) { + return ''; + } + + $readygroups = []; + + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; + if ($roomstatus === 'ready') { + $readygroups[$communication->get_processor()->get_id()] = $communication->get_communication_room_url(); + } + } + if (!empty($readygroups)) { + $highestkey = max(array_keys($readygroups)); + $url = $readygroups[$highestkey]; + } + } + + return empty($url) ? '' : $url; + } + + /** + * Get the enrolled users for course. + * + * @param stdClass $course The course object. + * @return array + */ + public static function get_enrolled_users_for_course(stdClass $course): array { + global $CFG; + require_once($CFG->libdir . '/enrollib.php'); + return array_column( + enrol_get_course_users(courseid: $course->id), + 'id', + ); + } + + /** + * Get the course communication status notification for course. + * + * @param \stdClass $course The course object. + */ + public static function get_course_communication_status_notification(\stdClass $course): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->show_communication_room_status_notification(); + } else { + // If group mode is set then handle the group communication rooms for these users. + $coursegroups = groups_get_all_groups(courseid: $course->id); + $numberofgroups = count($coursegroups); + + // If no groups available, nothing to show. + if ($numberofgroups === 0) { + return; + } + + $numberofreadygroups = 0; + + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; + switch ($roomstatus) { + case 'ready': + $numberofreadygroups ++; + break; + case 'pending': + $pendincommunicationobject = $communication; + break; + } + } + + if ($numberofgroups === $numberofreadygroups) { + $communication->show_communication_room_status_notification(); + } else { + $pendincommunicationobject->show_communication_room_status_notification(); + } + } + } + + /** + * Update course communication according to course data. + * Course can have course or group rooms. Group mode enabling will create rooms for groups. + * + * @param stdClass $course The course data + * @param bool $changesincoursecat Whether the course moved to a different category + */ + public static function update_course_communication_instance( + stdClass $course, + bool $changesincoursecat + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Check if provider is selected. + $provider = $course->selectedcommunication ?? null; + // If the course moved to hidden category, set provider to none. + if ($changesincoursecat && empty($course->visible)) { + $provider = processor::PROVIDER_NONE; + } + + // Get the course context. + $coursecontext = \context_course::instance(courseid: $course->id); + // Get the course image. + $courseimage = course_get_courseimage(course: $course); + // Get the course communication instance. + $coursecommunication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + // Attempt to get the communication provider if it wasn't provided in the data. + if (empty($provider)) { + $provider = $coursecommunication->get_provider(); + } + + // This nasty logic is here because of hide course doesn't pass anything in the data object. + if (!empty($course->communicationroomname)) { + $coursecommunicationroomname = $course->communicationroomname; + } else { + $coursecommunicationroomname = $course->fullname ?? get_course($course->id)->fullname; + } + + // List of enrolled users for course communication. + $enrolledusers = self::get_enrolled_users_for_course(course: $course); + + // Check for group mode, we will have to get the course data again as the group info is not always in the object. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + + // If group mode is disabled, get the communication information for creating room for a course. + if ((int)$groupmode === NOGROUPS) { + // Remove all the members from active group rooms if there is any. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + // Remove the members from the group room. + $communication->remove_all_members_from_room(); + // Now delete the group room. + $communication->update_room(active: processor::PROVIDER_INACTIVE); + } + + // Now create/update the course room. + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $coursecommunicationroomname, + users: $enrolledusers, + instanceimage: $courseimage, + ); + } else { + // Update the group communication instances. + self::update_group_communication_instances_for_course( + course: $course, + provider: $provider, + ); + + // Remove all the members for the course room if instance available. + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + provider: $provider === processor::PROVIDER_NONE ? null : $provider, + ); + + if ($communication->get_processor() === null) { + // If a course communication instance is not created, create one. + $communication->create_and_configure_room( + communicationroomname: $coursecommunicationroomname, + avatar: $courseimage, + instance: $course, + queue: false, + ); + } else { + $communication->remove_all_members_from_room(); + // Now update the course communication instance with the latest changes. + // We are not making room for this instance as it is a group mode enabled course. + // If provider is none, then we will make the room inactive, otherwise always active in group mode. + $communication->update_room( + active: $provider === processor::PROVIDER_NONE ? processor::PROVIDER_INACTIVE : processor::PROVIDER_ACTIVE, + communicationroomname: $coursecommunicationroomname, + avatar: $courseimage, + instance: $course, + queue: false, + ); + } + } + } + + /** + * Update the group communication instances. + * + * @param stdClass $course The course object. + * @param string $provider The provider name. + */ + public static function update_group_communication_instances_for_course( + stdClass $course, + string $provider, + ): void { + $coursegroups = groups_get_all_groups(courseid: $course->id); + $coursecontext = \context_course::instance(courseid: $course->id); + $allaccessgroupusers = self::get_users_has_access_to_all_groups( + userids: self::get_enrolled_users_for_course(course: $course), + courseid: $course->id, + ); + + foreach ($coursegroups as $coursegroup) { + $groupuserstoadd = array_column( + groups_get_members(groupid: $coursegroup->id), + 'id', + ); + + foreach ($allaccessgroupusers as $allaccessgroupuser) { + if (!in_array($allaccessgroupuser, $groupuserstoadd, true)) { + $groupuserstoadd[] = $allaccessgroupuser; + } + } + + // Now create/update the group room. + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $coursegroup->name, + users: $groupuserstoadd, + ); + } + } +} diff --git a/communication/classes/hook_listener.php b/communication/classes/hook_listener.php new file mode 100644 index 00000000000..29b03293e0b --- /dev/null +++ b/communication/classes/hook_listener.php @@ -0,0 +1,634 @@ +. + +namespace core_communication; + +use context_course; +use core\hook\access\after_role_assigned; +use core\hook\access\after_role_unassigned; +use core_enrol\hook\before_enrol_instance_delete; +use core_enrol\hook\after_enrol_instance_status_updated; +use core_enrol\hook\after_user_enrolled; +use core_enrol\hook\before_user_enrolment_update; +use core_enrol\hook\before_user_enrolment_remove; +use core_course\hook\after_course_created; +use core_course\hook\before_course_delete; +use core_course\hook\after_course_updated; +use core_group\hook\after_group_created; +use core_group\hook\after_group_deleted; +use core_group\hook\after_group_membership_added; +use core_group\hook\after_group_membership_removed; +use core_group\hook\after_group_updated; +use core_user\hook\before_user_deleted; +use core_user\hook\before_user_update; + +/** + * Hook listener for communication api. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_listener { + + /** + * Get the course and group object for the group hook. + * + * @param mixed $hook The hook object. + * @return array + */ + protected static function get_group_and_course_data_for_group_hook(mixed $hook): array { + $group = $hook->groupinstance; + $course = helper::get_course( + courseid: $group->courseid, + ); + + return [ + $group, + $course, + ]; + } + + /** + * Communication api call to create room for a group if course has group mode enabled. + * + * @param after_group_created $hook The group created hook. + */ + public static function create_group_communication( + after_group_created $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $coursecontext = \context_course::instance(courseid: $course->id); + // Get the course communication instance to set the provider. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + $communication = api::load_by_instance( + context: $coursecontext, + component: constants::GROUP_COMMUNICATION_COMPONENT, + instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, + instanceid: $group->id, + provider: $coursecommunication->get_provider(), + ); + + $communication->create_and_configure_room( + communicationroomname: $group->name, + instance: $course, + ); + + // As it's a new group, we need to add the users with all access group role to the room. + $enrolledusers = helper::get_enrolled_users_for_course(course: $course); + $userstoadd = helper::get_users_has_access_to_all_groups( + userids: $enrolledusers, + courseid: $course->id, + ); + $communication->add_members_to_room( + userids: $userstoadd, + queue: false, + ); + } + + /** + * Communication api call to update room for a group if course has group mode enabled. + * + * @param after_group_updated $hook The group updated hook. + */ + public static function update_group_communication( + after_group_updated $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $coursecontext = \context_course::instance(courseid: $course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $coursecontext, + ); + + // If the name didn't change, then we don't need to update the room. + if ($group->name === $communication->get_room_name()) { + return; + } + + $communication->update_room( + active: processor::PROVIDER_ACTIVE, + communicationroomname: $group->name, + instance: $course, + ); + } + + /** + * Delete the communication room for a group if course has group mode enabled. + * + * @param after_group_deleted $hook The group deleted hook. + */ + public static function delete_group_communication( + after_group_deleted $hook + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->delete_room(); + } + + /** + * Add members to group room when a new member is added to the group. + * + * @param after_group_membership_added $hook The group membership added hook. + */ + public static function add_members_to_group_room( + after_group_membership_added $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->add_members_to_room( + userids: $hook->userids, + ); + } + + /** + * Remove members from the room when a member is removed from group room. + * + * @param after_group_membership_removed $hook The group membership removed hook. + */ + public static function remove_members_from_group_room( + after_group_membership_removed $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->remove_members_from_room( + userids: $hook->userids, + ); + } + + /** + * Create course communication instance. + * + * @param after_course_created $hook The course created hook. + */ + public static function create_course_communication( + after_course_created $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $course = $hook->course; + + // Check for default provider config setting. + $defaultprovider = get_config( + plugin: 'moodlecourse', + name: 'coursecommunicationprovider', + ); + $provider = $course->selectedcommunication ?? $defaultprovider; + + if (empty($provider) && $provider === processor::PROVIDER_NONE) { + return; + } + + // Check for group mode, we will have to get the course data again as the group info is not always in the object. + $createcourseroom = true; + $creategrouprooms = false; + $coursedata = get_course(courseid: $course->id); + $groupmode = $course->groupmode ?? $coursedata->groupmode; + if ((int)$groupmode !== NOGROUPS) { + $createcourseroom = false; + $creategrouprooms = true; + } + + // Prepare the communication api data. + $courseimage = course_get_courseimage(course: $course); + $communicationroomname = !empty($course->communicationroomname) ? $course->communicationroomname : $coursedata->fullname; + $coursecontext = \context_course::instance(courseid: $course->id); + // Communication api call for course communication. + $communication = \core_communication\api::load_by_instance( + context: $coursecontext, + component: constants::COURSE_COMMUNICATION_COMPONENT, + instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, + instanceid: $course->id, + provider: $provider, + ); + $communication->create_and_configure_room( + communicationroomname: $communicationroomname, + avatar: $courseimage, + instance: $course, + queue: $createcourseroom, + ); + + // Communication api call for group communication. + if ($creategrouprooms) { + helper::update_group_communication_instances_for_course( + course: $course, + provider: $provider, + ); + } else { + $enrolledusers = helper::get_enrolled_users_for_course(course: $course); + $communication->add_members_to_room( + userids: $enrolledusers, + queue: false, + ); + } + } + + /** + * Update the course communication instance. + * + * @param after_course_updated $hook The course updated hook. + */ + public static function update_course_communication( + after_course_updated $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + $course = $hook->course; + $oldcourse = $hook->oldcourse; + $changeincoursecat = $hook->changeincoursecat; + $groupmode = $course->groupmode ?? get_course($course->id)->groupmode; + if ($changeincoursecat || $groupmode !== $oldcourse->groupmode) { + helper::update_course_communication_instance( + course: $course, + changesincoursecat: $changeincoursecat, + ); + } + } + + /** + * Delete course communication data and remove members. + * Course can have communication data if it is a group or a course. + * This action is important to perform even if the experimental feature is disabled. + * + * @param before_course_delete $hook The course deleted hook. + */ + public static function delete_course_communication( + before_course_delete $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $course = $hook->course; + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication room. + if ((int)$groupmode === NOGROUPS) { + $communication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->delete_room(); + } else { + // If group mode is set then handle the group communication rooms. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = helper::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->delete_room(); + } + } + } + + /** + * Update the room membership for the user updates. + * + * @param before_user_update $hook The user updated hook. + */ + public static function update_user_room_memberships( + before_user_update $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $user = $hook->user; + $currentuserrecord = $hook->currentuserdata; + + // Get the user courses. + $usercourses = enrol_get_users_courses(userid: $user->id); + + // If the user is suspended then remove the user from all the rooms. + // Otherwise add the user to all the rooms for the courses the user enrolled in. + if (!empty($currentuserrecord) && isset($user->suspended) && $currentuserrecord->suspended !== $user->suspended) { + // Decide the action for the communication api for the user. + $memberaction = ($user->suspended === 0) ? 'add_members_to_room' : 'remove_members_from_room'; + foreach ($usercourses as $usercourse) { + helper::update_course_communication_room_membership( + course: $usercourse, + userids: [$user->id], + memberaction: $memberaction, + ); + } + } + } + + /** + * Delete all room memberships for a user. + * + * @param before_user_deleted $hook The user deleted hook. + */ + public static function delete_user_room_memberships( + before_user_deleted $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $user = $hook->user; + + foreach (enrol_get_users_courses(userid: $user->id) as $course) { + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + if ((int)$groupmode === NOGROUPS) { + $communication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); + $communication->get_processor()->delete_instance_user_mapping(userids: [$user->id]); + } else { + // If group mode is set then handle the group communication rooms. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = helper::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); + $communication->get_processor()->delete_instance_user_mapping(userids: [$user->id]); + } + } + } + } + + /** + * Update the room membership of the user for role assigned in a course. + * + * @param after_role_assigned|after_role_unassigned $hook + */ + public static function update_user_membership_for_role_changes( + after_role_assigned|after_role_unassigned $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $context = $hook->context; + if ($coursecontext = $context->get_course_context(strict: false)) { + helper::update_course_communication_room_membership( + course: get_course(courseid: $coursecontext->instanceid), + userids: [$hook->userid], + memberaction: 'update_room_membership', + ); + } + } + + /** + * Update the communication memberships for enrol status change. + * + * @param after_enrol_instance_status_updated $hook The enrol status updated hook. + */ + public static function update_communication_memberships_for_enrol_status_change( + after_enrol_instance_status_updated $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + $newstatus = $hook->newstatus; + // Check if a valid status is given. + if ( + $newstatus !== ENROL_INSTANCE_ENABLED || + $newstatus !== ENROL_INSTANCE_DISABLED + ) { + return; + } + + // Check if the status provided is valid. + switch ($newstatus) { + case ENROL_INSTANCE_ENABLED: + $action = 'add_members_to_room'; + break; + case ENROL_INSTANCE_DISABLED: + $action = 'remove_members_from_room'; + break; + default: + return; + } + + global $DB; + $instanceusers = $DB->get_records( + table: 'user_enrolments', + conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], + ); + $enrolledusers = array_column($instanceusers, 'userid'); + helper::update_course_communication_room_membership( + course: get_course(courseid: $enrolinstance->courseid), + userids: $enrolledusers, + memberaction: $action, + ); + } + + /** + * Remove the communication instance memberships when an enrolment instance is deleted. + * + * @param before_enrol_instance_delete $hook The enrol instance deleted hook. + */ + public static function remove_communication_memberships_for_enrol_instance_deletion( + before_enrol_instance_delete $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + global $DB; + $instanceusers = $DB->get_records( + table: 'user_enrolments', + conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], + ); + $enrolledusers = array_column($instanceusers, 'userid'); + helper::update_course_communication_room_membership( + course: get_course(courseid: $enrolinstance->courseid), + userids: $enrolledusers, + memberaction: 'remove_members_from_room', + ); + } + + /** + * Add communication instance membership for an enrolled user. + * + * @param after_user_enrolled $hook The user enrolled hook. + */ + public static function add_communication_membership_for_enrolled_user( + after_user_enrolled $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: 'add_members_to_room', + ); + } + + /** + * Update the communication instance membership for the user enrolment updates. + * + * @param before_user_enrolment_update $hook The user enrolment updated hook. + */ + public static function update_communication_membership_for_updated_user_enrolment( + before_user_enrolment_update $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + $userenrolmentinstance = $hook->userenrolmentinstance; + $statusmodified = $hook->statusmodified; + $timeendmodified = $hook->timeendmodified; + + if ( + ($statusmodified && ((int) $userenrolmentinstance->status === 1)) || + ($timeendmodified && $userenrolmentinstance->timeend !== 0 && (time() > $userenrolmentinstance->timeend)) + ) { + $action = 'remove_members_from_room'; + } else { + $action = 'add_members_to_room'; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: $action, + ); + } + + /** + * Remove communication instance membership for an enrolled user. + * + * @param before_user_enrolment_remove $hook The user unenrolled hook. + */ + public static function remove_communication_membership_for_unenrolled_user( + before_user_enrolment_remove $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: 'remove_members_from_room', + ); + } +} diff --git a/communication/classes/processor.php b/communication/classes/processor.php index e4beaf4ffb4..99089469649 100644 --- a/communication/classes/processor.php +++ b/communication/classes/processor.php @@ -111,7 +111,7 @@ class processor { /** * Update the communication instance with any changes. * - * @param null|int $active Active state of the instance (processor::PROVIDER_ACTIVE or processor::PROVIDER_INACTIVE) + * @param null|string $active Active state of the instance (processor::PROVIDER_ACTIVE or processor::PROVIDER_INACTIVE) * @param null|string $roomname The room name */ public function update_instance( @@ -489,6 +489,15 @@ class processor { return $this->instancedata->roomname; } + /** + * Get provider active status. + * + * @return int + */ + public function get_provider_status(): int { + return $this->instancedata->active; + } + /** * Get communication instance id. * diff --git a/communication/tests/api_test.php b/communication/tests/api_test.php index c4be5b94b5f..54d35e9986f 100644 --- a/communication/tests/api_test.php +++ b/communication/tests/api_test.php @@ -16,9 +16,13 @@ namespace core_communication; +use core_communication\task\add_members_to_room_task; +use core_communication\task\create_and_configure_room_task; use communication_matrix\matrix_test_helper_trait; use core_communication\task\synchronise_provider_task; use core_communication\task\synchronise_providers_task; +use core_communication\task\remove_members_from_room; +use core_communication\task\update_room_task; defined('MOODLE_INTERNAL') || die(); @@ -351,4 +355,194 @@ class api_test extends \advanced_testcase { $adhoctask = \core\task\manager::get_adhoc_tasks(synchronise_provider_task::class); $this->assertCount(2, $adhoctask); } + + /** + * Test the removal of all members from the room. + * + * @covers ::remove_all_members_from_room + */ + public function test_remove_all_members_from_room(): void { + $course = $this->get_course(); + $userid = $this->get_user()->id; + $communication = \core_communication\api::load_by_instance( + context: \core\context\course::instance($course->id), + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id, + ); + $communication->add_members_to_room([$userid]); + + // Now test the removing members from a room. + $communication->remove_all_members_from_room(); + + // Test the remove members tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test the configuration of room changes as well as the membership with the change of provider. + * + * @covers ::configure_room_and_membership_by_provider + */ + public function test_configure_room_and_membership_by_provider(): void { + global $DB; + + $course = $this->get_course('Sampleroom', 'none'); + $userid = $this->get_user()->id; + $provider = 'communication_matrix'; + + $communication = \core_communication\api::load_by_instance( + context: \core\context\course::instance($course->id), + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id, + ); + + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test that the task to create a room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test that no update tasks are added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + // Test that the task to add members to room is not added, as we are adding the user mapping not the task. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now disable the provider by setting none. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test that the task to delete a room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test that the task to remove members from room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now try to set the same none provider again. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + + // Test that no communicaiton task is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(0, $adhoctask); + + // Now let's change it back to matrix and test the update task is added. + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test create task is not added because communication has been created in the past. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(0, $adhoctask); + + // Test an update task added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test add membership task is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now change the provider to another one. + $communication->configure_room_and_membership_by_provider( + provider: 'communication_customlink', + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Remove membership and update room task for the previous provider. + // Create room task for new one. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now disable the provider. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Should have one update and one remove task. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // This provider doesn't have any membership, so no remove task. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(0, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now enable the same provider again. + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + + // Now it should have one update and one add task. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(1, $adhoctask); + } } diff --git a/communication/tests/communication_test_helper_trait.php b/communication/tests/communication_test_helper_trait.php index fb53f5b52fa..049b38a0726 100644 --- a/communication/tests/communication_test_helper_trait.php +++ b/communication/tests/communication_test_helper_trait.php @@ -52,7 +52,8 @@ trait communication_test_helper_trait { */ protected function get_course( string $roomname = 'Sampleroom', - string $provider = 'communication_matrix' + string $provider = 'communication_matrix', + array $extrafields = [], ): \stdClass { $this->setup_communication_configs(); @@ -61,7 +62,7 @@ trait communication_test_helper_trait { 'communicationroomname' => $roomname, ]; - return $this->getDataGenerator()->create_course($records); + return $this->getDataGenerator()->create_course(array_merge($records, $extrafields)); } /** diff --git a/communication/tests/helper_test.php b/communication/tests/helper_test.php new file mode 100644 index 00000000000..b0a24e40256 --- /dev/null +++ b/communication/tests/helper_test.php @@ -0,0 +1,223 @@ +. + +namespace core_communication; + +use communication_matrix\matrix_test_helper_trait; +use core_communication\processor as communication_processor; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Test communication helper methods. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_communication\helper + */ +class helper_test extends \advanced_testcase { + use communication_test_helper_trait; + use matrix_test_helper_trait; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->setup_communication_configs(); + $this->initialise_mock_server(); + } + + /** + * Test load_by_group. + */ + public function test_load_by_group(): void { + + // As communication is created by default. + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $context = \context_course::instance(courseid: $course->id); + + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + } + + /** + * Test load_by_course. + */ + public function test_load_by_course(): void { + // As communication is created by default. + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + } + + /** + * Test get_access_to_all_group_cap_users. + */ + public function test_get_users_has_access_to_all_groups(): void { + global $DB; + // Set up the data with course, group, user etc. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $allgroupaccessusers = helper::get_users_has_access_to_all_groups( + userids: [$user1->id, $user2->id], + courseid: $course->id, + ); + $this->assertContains( + needle: $user1->id, + haystack: $allgroupaccessusers, + ); + $this->assertNotContains( + needle: $user2->id, + haystack: $allgroupaccessusers, + ); + } + + /** + * Test update_communication_room_membership. + */ + public function test_update_communication_room_membership(): void { + global $DB; + + // Set up the data with course, group, user etc. + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid:$user->id, + contextid: $coursecontext->id, + ); + + // Now remove members from room. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'remove_members_from_room', + ); + + // Now test that there is communication instances for the course and the user removed from that instance. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now add members to room. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'add_members_to_room', + ); + + $coursecommunication->reload(); + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_instance_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now update membership. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'update_room_membership', + ); + + $coursecommunication->reload(); + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_instance_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now try using invalid action. + $this->expectException('coding_exception'); + $this->expectExceptionMessage('Invalid action provided.'); + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'a_funny_action', + ); + } +} diff --git a/communication/tests/hook_listener_test.php b/communication/tests/hook_listener_test.php new file mode 100644 index 00000000000..81f9225f64a --- /dev/null +++ b/communication/tests/hook_listener_test.php @@ -0,0 +1,461 @@ +. + +namespace core_communication; + +use communication_matrix\matrix_test_helper_trait; +use core_communication\task\add_members_to_room_task; +use core_communication\task\create_and_configure_room_task; +use core_communication\task\delete_room_task; +use core_communication\task\update_room_membership_task; +use core_communication\task\update_room_task; +use core_communication\processor as communication_processor; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Test communication hook listeners. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_communication\hook_listener + */ +class hook_listener_test extends \advanced_testcase { + + use communication_test_helper_trait; + use matrix_test_helper_trait; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->setup_communication_configs(); + $this->initialise_mock_server(); + } + + /** + * Test create_group_communication. + */ + public function test_create_update_delete_group_communication(): void { + global $DB; + + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $coursecontext = \context_course::instance(courseid: $course->id); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $context = \context_course::instance($course->id); + + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + + $this->assertEquals( + expected: $group->id, + actual: $groupcommunication->get_processor()->get_instance_id(), + ); + + // Task to create room should be added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Task to add members to room should not be there as the room is yet to be created. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + // Only users with access to all groups should be added to the room at this point. + $groupcommunicationusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); + $this->assertEquals( + expected: [$user1->id], + actual: $groupcommunicationusers, + ); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now cann the update group but don't change the group name. + groups_update_group($group); + + // No task should be added as nothing changed. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + // Now change the group name. + $changedgroupname = 'Changedgroupname'; + $group->name = $changedgroupname; + groups_update_group($group); + + // Now one task should be there to update the group room name. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $groupcommunication->reload(); + $this->assertEquals( + expected: $changedgroupname, + actual: $groupcommunication->get_processor()->get_room_name(), + ); + + // Now delete the group. + groups_delete_group($group->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test add_members_to_group_room. + */ + public function test_add_members_to_group_room(): void { + global $DB; + + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $coursecontext = \context_course::instance(courseid: $course->id); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + + // Now check if the teacher is added to the group room as the teacher has access to all groups. + $context = \context_course::instance($course->id); + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + + // Now the communication instance should not have the student added yet. + $this->assertNotContains( + needle: $user2->id, + haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), + ); + + groups_add_member( + grouporid: $group, + userorid: $user2, + ); + + // Now it should have the student. + $this->assertContains( + needle: $user2->id, + haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), + ); + } + + /** + * Test if the course instances are created properly for course default provider. + */ + public function test_course_default_provider(): void { + $defaultprovider = 'communication_matrix'; + // Set the default communication for course. + set_config( + name: 'coursecommunicationprovider', + value: $defaultprovider, + plugin: 'moodlecourse', + ); + + // Test that the default communication is created for course mode. + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertEquals( + expected: $defaultprovider, + actual: $coursecommunication->get_provider(), + ); + $this->assertEquals( + expected: 'core_course', + actual: $coursecommunication->get_processor()->get_component(), + ); + $this->assertEquals( + expected: $course->id, + actual: $coursecommunication->get_processor()->get_instance_id(), + ); + } + + /** + * Test update_course_communication. + */ + public function test_update_course_communication(): void { + global $DB; + + // Set up the data with course, group, user etc. + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $group = $this->getDataGenerator()->create_group(record: ['courseid' => $course->id]); + $coursecontext = \context_course::instance(courseid: $course->id); + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'teacher'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user->id, + contextid: $coursecontext->id, + ); + groups_add_member( + grouporid: $group->id, + userorid: $user->id, + ); + + // Now test that there is communication instances for the course and the user added for that instance. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Group should not have any instance yet. + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $coursecontext, + ); + $this->assertNull(actual: $groupcommunication->get_processor()); + + // Now update the course. + $course->groupmode = SEPARATEGROUPS; + $course->selectedcommunication = 'communication_matrix'; + update_course(data: $course); + + // Now there should be a group communication instance. + $groupcommunication->reload(); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + + // The course communication instance must be active. + $coursecommunication->reload(); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + + // All the course instance users must be marked as deleted. + $coursecommunication->reload(); + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Group instance should have the user. + $groupusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); + $groupusers = reset($groupusers); + $this->assertEquals( + expected: $user->id, + actual: $groupusers, + ); + + // Now disable the communication instance for the course. + $course->selectedcommunication = communication_processor::PROVIDER_NONE; + update_course(data: $course); + + // Now both course and group instance should be disabled. + $coursecommunication->reload(); + $this->assertNull(actual: $coursecommunication->get_processor()); + + $groupcommunication->reload(); + $this->assertNull(actual: $groupcommunication->get_processor()); + } + + /** + * Test create_course_communication_instance. + */ + public function test_create_course_communication_instance(): void { + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + $processor = $coursecommunication->get_processor(); + $this->assertEquals( + expected: 'communication_matrix', + actual: $processor->get_provider(), + ); + $this->assertEquals( + expected: 'Sampleroom', + actual: $processor->get_room_name(), + ); + } + + /** + * Test delete_course_communication. + */ + public function test_delete_course_communication(): void { + $course = $this->get_course(); + delete_course( + courseorid: $course, + showfeedback: false, + ); + + $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test update of room membership when user changes occur. + */ + public function test_update_user_room_memberships(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + $coursecommunication = helper::load_by_course($course->id, $coursecontext); + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $courseusers = reset($courseusers); + $this->assertEquals($user->id, $courseusers); + + $user->suspended = 1; + user_update_user($user, false); + + $coursecommunication->reload(); + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals($user->id, $courseusers); + } + + /** + * Test deletion of user room memberships when a user is deleted. + */ + public function test_delete_user_room_memberships(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + delete_user($user); + $coursecommunication = helper::load_by_course($course->id, $coursecontext); + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $this->assertEmpty($courseusers); + } + + /** + * Test user room membership updates with role changes in a course. + */ + public function test_update_user_membership_for_role_changes(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); + $this->assertCount(1, $adhoctask); + + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); + $this->assertCount(2, $adhoctask); + } +} + diff --git a/lib/outputrenderers.php b/lib/outputrenderers.php index 5852068f61b..bd8925f53f7 100644 --- a/lib/outputrenderers.php +++ b/lib/outputrenderers.php @@ -4444,18 +4444,7 @@ EOD; */ public function communication_url(): string { global $COURSE; - $url = ''; - if ($COURSE->id !== SITEID) { - $comm = \core_communication\api::load_by_instance( - context: \core\context\course::instance($COURSE->id), - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $COURSE->id, - ); - $url = $comm->get_communication_room_url(); - } - - return !empty($url) ? $url : ''; + return \core_communication\helper::get_course_communication_url($COURSE); } /**