From ca1e5d0beb9671a42f71ad267d4aa2a7032d8903 Mon Sep 17 00:00:00 2001 From: Safat Date: Fri, 27 Jan 2023 05:00:40 +1100 Subject: [PATCH] MDL-77576 core_communication: Add core communication api This commit will implement the base api for core communication. This will include the room creation, room membership, room access url and all associated api and related interfaces. Originally implemented as MDL-76702, MDL-76703 and MDL-76705. Co-Authored-By: David Woloszyn Co-Authored-By: Safat Shahin --- communication/classes/api.php | 402 ++++++++++++ .../classes/communication_provider.php | 38 ++ communication/classes/processor.php | 563 +++++++++++++++++ communication/classes/room_chat_provider.php | 51 ++ communication/classes/room_user_provider.php | 40 ++ .../classes/task/add_members_to_room_task.php | 64 ++ .../task/create_and_configure_room_task.php | 76 +++ .../classes/task/delete_room_task.php | 72 +++ .../classes/task/remove_members_from_room.php | 67 ++ .../classes/task/update_room_task.php | 65 ++ communication/classes/user_provider.php | 34 + communication/lib.php | 61 ++ communication/tests/api_test.php | 315 ++++++++++ .../tests/communication_processor_test.php | 587 ++++++++++++++++++ .../tests/communication_test_helper_trait.php | 82 +++ communication/tests/fixtures/moodle_logo.jpg | Bin 0 -> 76664 bytes communication/tests/processor_test.php | 398 ++++++++++++ lang/en/communication.php | 5 + lib/db/install.xml | 29 + lib/db/upgrade.php | 46 ++ version.php | 2 +- 21 files changed, 2996 insertions(+), 1 deletion(-) create mode 100644 communication/classes/api.php create mode 100644 communication/classes/communication_provider.php create mode 100644 communication/classes/processor.php create mode 100644 communication/classes/room_chat_provider.php create mode 100644 communication/classes/room_user_provider.php create mode 100644 communication/classes/task/add_members_to_room_task.php create mode 100644 communication/classes/task/create_and_configure_room_task.php create mode 100644 communication/classes/task/delete_room_task.php create mode 100644 communication/classes/task/remove_members_from_room.php create mode 100644 communication/classes/task/update_room_task.php create mode 100644 communication/classes/user_provider.php create mode 100644 communication/lib.php create mode 100644 communication/tests/api_test.php create mode 100644 communication/tests/communication_processor_test.php create mode 100644 communication/tests/communication_test_helper_trait.php create mode 100644 communication/tests/fixtures/moodle_logo.jpg create mode 100644 communication/tests/processor_test.php diff --git a/communication/classes/api.php b/communication/classes/api.php new file mode 100644 index 00000000000..ab7f74220c5 --- /dev/null +++ b/communication/classes/api.php @@ -0,0 +1,402 @@ +. + +namespace core_communication; + +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\remove_members_from_room; +use core_communication\task\update_room_task; +use stdClass; + +/** + * Class api is the public endpoint of the communication api. This class is the point of contact for api usage. + * + * Communication api allows to add ad-hoc tasks to the queue to perform actions on the communication providers. This api will + * not allow any immediate actions to be performed on the communication providers. It will only add the tasks to the queue. The + * exception has been made for deletion of members in case of deleting the user. This is because the user will not be available. + * The member management api part allows run actions immediately if required. + * + * Communication api does allow to have form elements related to communication api in the required forms. This is done by using + * the form_definition method. This method will add the form elements to the form. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api { + + /** + * @var null|processor $communication The communication settings object + */ + private ?processor $communication; + + /** + * Communication handler constructor to manage and handle all communication related actions. + * + * This class is the entrypoint for all kinda usages. + * It will be used by the other api to manage the communication providers. + * + * @param string $component The component of the item for the instance + * @param string $instancetype The type of the item for the instance + * @param int $instanceid The id of the instance + * + */ + private function __construct( + private string $component, + private string $instancetype, + private int $instanceid + ) { + $this->communication = processor::load_by_instance( + $this->component, + $this->instancetype, + $this->instanceid, + ); + } + + /** + * Get the communication processor object. + * + * @param string $component The component of the item for the instance + * @param string $instancetype The type of the item for the instance + * @param int $instanceid The id of the instance + * @return api + */ + public static function load_by_instance( + string $component, + string $instancetype, + int $instanceid + ): self { + return new self($component, $instancetype, $instanceid); + } + + /** + * Check if the communication api is enabled. + */ + public static function is_available(): bool { + return (bool) get_config('core', 'enablecommunicationsubsystem'); + } + + /** + * Get the communication room url. + * + * @return string|null + */ + public function get_communication_room_url(): ?string { + return $this->communication?->get_room_url(); + } + + /** + * Get the list of plugins for form selection. + * + * @return array + */ + public static function get_communication_plugin_list_for_form(): array { + // Add the option to have communication disabled. + $selection[processor::PROVIDER_NONE] = get_string('nocommunicationselected', 'communication'); + $communicationplugins = \core\plugininfo\communication::get_enabled_plugins(); + foreach ($communicationplugins as $pluginname => $notusing) { + $selection['communication_' . $pluginname] = get_string('pluginname', 'communication_'. $pluginname); + } + return $selection; + } + + /** + * Define the form elements for the communication api. + * This method will be called from the form definition method of the instance. + * + * @param \MoodleQuickForm $mform The form element + */ + public function form_definition(\MoodleQuickForm $mform): void { + $mform->addElement('header', 'communication', get_string('communication', 'communication')); + + // List the communication providers. + $communicationproviders = self::get_communication_plugin_list_for_form(); + $mform->addElement( + 'select', + 'selectedcommunication', + get_string('seleccommunicationprovider', 'communication'), + $communicationproviders); + $mform->addHelpButton('selectedcommunication', 'seleccommunicationprovider', 'communication'); + $mform->setDefault('selectedcommunication', processor::PROVIDER_NONE); + + // Room name for the communication provider. + $mform->addElement('text', + 'communicationroomname', + get_string('communicationroomname', 'communication'), + 'maxlength="100" size="20"'); + $mform->addHelpButton('communicationroomname', 'communicationroomname', 'communication'); + $mform->setType('communicationroomname', PARAM_TEXT); + $mform->hideIf( + 'communicationroomname', + 'selectedcommunication', + 'eq', + processor::PROVIDER_NONE); + } + + /** + * Get the avatar file record for the avatar for filesystem. + * + * @param string $filename The filename of the avatar + * @return stdClass + */ + public function get_avatar_filerecord(string $filename): stdClass { + return (object) [ + 'contextid' => \context_system::instance()->id, + 'component' => 'core_communication', + 'filearea' => 'avatar', + 'filename' => $filename, + 'filepath' => '/', + 'itemid' => $this->communication->get_id(), + ]; + } + + /** + * + * Get the avatar file. + * + * If null is set, then delete the old area file and set the avatarfilename to null. + * This will make sure the plugin api deletes the avatar from the room. + * + * @param string|null $datauri The datauri of the avatar + * @return bool + */ + public function set_avatar_from_datauri_or_filepath(?string $datauri): bool { + global $DB; + + $currentfilename = $DB->get_field('communication', 'avatarfilename', ['id' => $this->communication->get_id()]); + if (empty($datauri) && empty($currentfilename)) { + return false; + } + + $currentfilerecord = $this->communication->get_avatar(); + if (!empty($datauri) && !empty($currentfilerecord)) { + $currentfilehash = $currentfilerecord->get_contenthash(); + $updatedfilehash = \file_storage::hash_from_string(file_get_contents($datauri)); + + // No update required. + if ($currentfilehash === $updatedfilehash) { + return false; + } + } + + $context = \context_system::instance(); + $filename = null; + + $fs = get_file_storage(); + $fs->delete_area_files( + $context->id, + 'core_communication', + 'avatar', + $this->communication->get_id() + ); + + if (!empty($datauri)) { + $filename = "avatar.svg"; + $fs->create_file_from_string($this->get_avatar_filerecord($filename), file_get_contents($datauri)); + } + + $DB->set_field('communication', 'avatarfilename', $filename, ['id' => $this->communication->get_id()]); + return true; + } + + /** + * Set the form data if the data is already available. + * + * @param \stdClass $instance The instance object + */ + public function set_data(\stdClass $instance): void { + if (!empty($instance->id) && $this->communication) { + $instance->selectedcommunication = $this->communication->get_provider(); + $instance->communicationroomname = $this->communication->get_room_name(); + } + } + + /** + * Get the communication provider. + * + * @return string + */ + public function get_provider(): string { + if (!$this->communication) { + return ''; + } + return $this->communication->get_provider(); + } + + /** + * Create a communication ad-hoc task for create operation. + * This method will add a task to the queue to create the room. + * + * @param string $selectedcommunication The selected communication provider + * @param string $communicationroomname The communication room name + * @param string|null $avatarurl The avatar url + */ + public function create_and_configure_room( + string $selectedcommunication, + string $communicationroomname, + ?string $avatarurl = null, + ): void { + + if ($selectedcommunication !== processor::PROVIDER_NONE && $selectedcommunication !== '') { + // Create communication record. + $this->communication = processor::create_instance( + $selectedcommunication, + $this->instanceid, + $this->component, + $this->instancetype, + $communicationroomname, + ); + + // Set the avatar. + if (!empty($avatarurl)) { + $this->set_avatar_from_datauri_or_filepath($avatarurl); + } + + // Add ad-hoc task to create the provider room. + create_and_configure_room_task::queue( + $this->communication, + ); + } + } + + /** + * Create a communication ad-hoc task for update operation. + * This method will add a task to the queue to update the room. + * + * @param string $selectedprovider The selected communication provider + * @param string $communicationroomname The communication room name + * @param string|null $avatarurl The avatar url + */ + public function update_room( + string $selectedprovider, + string $communicationroomname, + ?string $avatarurl = null, + ): void { + + // Existing object found, let's update the communication record and associated actions. + if ($this->communication !== null) { + // Get the previous data to compare for update. + $previousroomname = $this->communication->get_room_name(); + $previousprovider = $this->communication->get_provider(); + + // Update communication record. + $this->communication->update_instance($selectedprovider, $communicationroomname); + + // Update the avatar. + $imageupdaterequired = $this->set_avatar_from_datauri_or_filepath($avatarurl); + + // 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; + } + + // Add ad-hoc task to update the provider room if the room name changed. + if ( + $previousprovider === $selectedprovider && + ($previousroomname !== $communicationroomname || $imageupdaterequired) + ) { + update_room_task::queue( + $this->communication, + ); + } else if ( + $previousprovider !== $selectedprovider + ) { + // Add ad-hoc task to create the provider room. + create_and_configure_room_task::queue( + $this->communication, + ); + } + } else { + // The instance didn't have any communication record, so create one. + $this->create_and_configure_room($selectedprovider, $communicationroomname, $avatarurl); + } + } + + /** + * Create a communication ad-hoc task for delete operation. + * This method will add a task to the queue to delete the room. + */ + public function delete_room(): void { + if ($this->communication !== null) { + // Add the ad-hoc task to remove the room data from the communication table and associated provider actions. + delete_room_task::queue( + $this->communication, + ); + } + } + + /** + * Create a communication ad-hoc task for add members operation and add the user mapping. + * + * This method will add a task to the queue to add the room users. + * + * @param array $userids The user ids to add to the room + * @param bool $queue Whether to queue the task or not + */ + public function add_members_to_room(array $userids, bool $queue = true): void { + // No communication object? something not done right. + if (!$this->communication) { + return; + } + + // No userids? don't bother doing anything. + if (empty($userids)) { + return; + } + + $this->communication->create_instance_user_mapping($userids); + + if ($queue) { + add_members_to_room_task::queue( + $this->communication + ); + } + } + + /** + * Create a communication ad-hoc task for remove members operation or action immediately. + * + * This method will add a task to the queue to remove the room users. + * + * @param array $userids The user ids to remove from the room + * @param bool $queue Whether to queue the task or not + */ + public function remove_members_from_room(array $userids, bool $queue = true): void { + // No communication object? something not done right. + if (!$this->communication) { + return; + } + + if ($this->communication->get_provider() === processor::PROVIDER_NONE) { + return; + } + + // No user ids? don't bother doing anything. + if (empty($userids)) { + return; + } + + $this->communication->add_delete_user_flag($userids); + + if ($queue) { + remove_members_from_room::queue( + $this->communication + ); + } + } +} diff --git a/communication/classes/communication_provider.php b/communication/classes/communication_provider.php new file mode 100644 index 00000000000..3fa7127a18d --- /dev/null +++ b/communication/classes/communication_provider.php @@ -0,0 +1,38 @@ +. + +namespace core_communication; + +/** + * A base communication provider. + * + * This interface should be used to declare support for the instantiation method for communication providers. + * + * Every communication provider must, as a minimum, implement this provider. + * + * @package core_communication + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface communication_provider { + + /** + * A base communication provider. + * + * @param processor $communication The communication object + */ + public static function load_for_instance(processor $communication): self; +} diff --git a/communication/classes/processor.php b/communication/classes/processor.php new file mode 100644 index 00000000000..576717a6722 --- /dev/null +++ b/communication/classes/processor.php @@ -0,0 +1,563 @@ +. + +namespace core_communication; + +use stdClass; +use stored_file; + +/** + * Class processor to manage the base operations of the providers. + * + * This class is responsible for creating, updating, deleting and loading the communication instance, associated actions. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class processor { + + /** @var string The magic 'none' provider */ + public const PROVIDER_NONE = 'none'; + + /** @var int The provider active flag */ + public const PROVIDER_ACTIVE = 1; + + /** @var int The provider inactive flag */ + public const PROVIDER_INACTIVE = 0; + + /** @var null|communication_provider|user_provider|room_chat_provider|room_user_provider The provider class */ + private communication_provider|user_provider|room_chat_provider|room_user_provider|null $provider = null; + + /** + * Communication processor constructor. + * + * @param stdClass $instancedata The instance data object + */ + protected function __construct( + private stdClass $instancedata, + ) { + $providercomponent = $this->instancedata->provider; + if (!\core\plugininfo\communication::is_plugin_enabled($providercomponent)) { + throw new \moodle_exception('communicationproviderdisabled', 'core_communication', '', $providercomponent); + } + $providerclass = $this->get_classname_for_provider($providercomponent); + if (!class_exists($providerclass)) { + throw new \moodle_exception('communicationproviderclassnotfound', 'core_communication', '', $providerclass); + } + + if (!is_a($providerclass, communication_provider::class, true)) { + // At the moment we only have one communication provider interface. + // In the future, we may have others, at which point we will support the newest first and + // emit a debugging notice for older ones. + throw new \moodle_exception('communicationproviderclassinvalid', 'core_communication', '', $providerclass); + } + + $this->provider = $providerclass::load_for_instance($this); + } + + /** + * Create communication instance. + * + * @param string $provider The communication provider + * @param int $instanceid The instance id + * @param string $component The component name + * @param string $instancetype The instance type + * @param string $roomname The room name + * @return processor|null + */ + public static function create_instance( + string $provider, + int $instanceid, + string $component, + string $instancetype, + string $roomname, + ): ?self { + global $DB; + + if ($provider === self::PROVIDER_NONE) { + return null; + } + $record = (object) [ + 'provider' => $provider, + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype, + 'roomname' => $roomname, + 'avatarfilename' => null, + 'active' => self::PROVIDER_ACTIVE, + ]; + $record->id = $DB->insert_record('communication', $record); + + return new self($record); + } + + /** + * Update communication instance. + * + * @param string $provider The communication provider + * @param string $roomname The room name + */ + public function update_instance( + string $provider, + string $roomname, + ): void { + + global $DB; + if ($provider === self::PROVIDER_NONE) { + $this->instancedata->active = self::PROVIDER_INACTIVE; + } else { + $this->instancedata->provider = $provider; + $this->instancedata->active = self::PROVIDER_ACTIVE; + } + $this->instancedata->roomname = $roomname; + + $DB->update_record('communication', $this->instancedata); + } + + /** + * Delete communication data. + */ + public function delete_instance(): void { + global $DB; + $DB->delete_records('communication', ['id' => $this->instancedata->id]); + } + + /** + * Get non synced instance user ids for the instance. + * + * @param bool $synced The synced status + * @param bool $deleted The deleted status + * @return array + */ + public function get_instance_userids(bool $synced = false, bool $deleted = false): array { + global $DB; + return $DB->get_fieldset_select( + 'communication_user', + 'userid', + 'commid = ? AND synced = ? AND deleted = ?', + [$this->instancedata->id, (int) $synced, (int) $deleted] + ); + } + + /** + * Get existing instance user ids. + * + * @return array + */ + public function get_all_userids_for_instance(): array { + global $DB; + return $DB->get_fieldset_select( + 'communication_user', + 'userid', + 'commid = ?', + [$this->instancedata->id] + ); + } + + /** + * Create communication user record for mapping and sync. + * + * @param array $userids The user ids + */ + public function create_instance_user_mapping(array $userids): void { + global $DB; + + // Check if user ids exits in existing user ids. + $useridstoadd = array_diff($userids, $this->get_all_userids_for_instance()); + + foreach ($useridstoadd as $userid) { + $record = (object) [ + 'commid' => $this->instancedata->id, + 'userid' => $userid, + ]; + $DB->insert_record('communication_user', $record); + } + $this->mark_users_as_not_deleted($userids); + } + + /** + * Mark users as not deleted for the instance. + * + * @param array $userids The user ids + */ + public function mark_users_as_not_deleted(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->set_field_select( + 'communication_user', + 'deleted', + 0, + 'commid = ? AND userid IN (' . implode(',', $userids) . ')', + [$this->instancedata->id] + ); + } + + /** + * Mark users as synced for the instance. + * + * @param array $userids The user ids + */ + public function mark_users_as_synced(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->set_field_select( + 'communication_user', + 'synced', + 1, + 'commid = ? AND userid IN (' . implode(',', $userids) . ')', + [$this->instancedata->id] + ); + } + + /** + * Reset users sync flag for the instance. + * + * @param array $userids The user ids + */ + public function reset_users_sync_flag(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->set_field_select( + 'communication_user', + 'synced', + 0, + 'commid = ? AND userid IN (' . implode(',', $userids) . ')', + [$this->instancedata->id] + ); + } + + /** + * Delete users flag for the instance users. + * + * @param array $userids The user ids + */ + public function add_delete_user_flag(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->set_field_select( + 'communication_user', + 'deleted', + 1, + 'commid = ? AND userid IN (' . implode(',', $userids) . ')', + [$this->instancedata->id] + ); + } + + /** + * Delete communication user record for userid. + * + * @param array $userids The user ids + */ + public function delete_instance_user_mapping(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->delete_records_select( + 'communication_user', + 'commid = ? AND userid IN (' . implode(',', $userids) . ')', + [$this->instancedata->id] + ); + } + + /** + * Delete communication user record for userid who are not synced. + * + * @param array $userids The user ids + */ + public function delete_instance_non_synced_user_mapping(array $userids): void { + global $DB; + + if (empty($userids)) { + return; + } + + $DB->delete_records_select( + 'communication_user', + 'commid = ? AND userid IN (' . implode(',', $userids) . ') AND synced = ?' , + [$this->instancedata->id, 0] + ); + } + + /** + * Delete communication user record for instance. + */ + public function delete_user_mappings_for_instance(): void { + global $DB; + $DB->delete_records('communication_user', [ + 'commid' => $this->instancedata->id, + ]); + } + + /** + * Load communication instance by id. + * + * @param int $id The communication instance id + * @return processor|null + */ + public static function load_by_id(int $id): ?self { + global $DB; + + if ($record = $DB->get_record('communication', ['id' => $id])) { + return new self($record); + } + + return null; + } + + /** + * Load communication instance by instance id. + * + * @param string $component The component name + * @param string $instancetype The instance type + * @param int $instanceid The instance id + * @return processor|null + */ + public static function load_by_instance( + string $component, + string $instancetype, + int $instanceid + ): ?self { + + global $DB; + + $record = $DB->get_record('communication', [ + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype, + ]); + + if ($record) { + return new self($record); + } + + return null; + } + + /** + * Check if communication instance is active. + * + * @return bool + */ + public function is_instance_active(): bool { + return $this->instancedata->active; + } + + /** + * Get communication provider class name. + * + * @param string $component The component name. + * @return string + */ + private function get_classname_for_provider(string $component): string { + return "{$component}\\communication_feature"; + } + + /** + * Get communication instance id after creating the instance in communication table. + * + * @return int + */ + public function get_id(): int { + return $this->instancedata->id; + } + + /** + * Get communication instance id. + * + * @return string + */ + public function get_component(): string { + return $this->instancedata->component; + } + + /** + * Get communication provider. + * + * @return string|null + */ + public function get_provider(): ?string { + // var_dump($this->instancedata);die; + if ((int)$this->instancedata->active === self::PROVIDER_ACTIVE) { + return $this->instancedata->provider; + } + return self::PROVIDER_NONE; + } + + /** + * Get room name. + * + * @return string|null + */ + public function get_room_name(): ?string { + return $this->instancedata->roomname; + } + + /** + * Get communication instance id. + * + * @return room_chat_provider + */ + public function get_room_provider(): room_chat_provider { + $this->require_api_enabled(); + $this->require_room_features(); + return $this->provider; + } + + /** + * Get communication instance id. + * + * @return user_provider + */ + public function get_user_provider(): user_provider { + $this->require_api_enabled(); + $this->require_user_features(); + return $this->provider; + } + + /** + * Get communication instance id. + * + * @return room_user_provider + */ + public function get_room_user_provider(): room_user_provider { + $this->require_api_enabled(); + $this->require_room_features(); + $this->require_room_user_features(); + return $this->provider; + } + + /** + * Get communication instance id. + * + * @return bool + */ + public function supports_user_features(): bool { + return ($this->provider instanceof user_provider); + } + + /** + * Get communication instance id. + * + * @return bool + */ + public function supports_room_user_features(): bool { + if (!$this->supports_user_features()) { + return false; + } + + if (!$this->supports_room_features()) { + return false; + } + + return ($this->provider instanceof room_user_provider); + } + + /** + * Get communication instance id. + */ + public function require_user_features(): void { + if (!$this->supports_user_features()) { + throw new \coding_exception('User features are not supported by the provider'); + } + } + + /** + * Get communication instance id. + * + * @return bool + */ + public function supports_room_features(): bool { + return ($this->provider instanceof room_chat_provider); + } + + /** + * Check if communication api is enabled. + */ + public function require_api_enabled(): void { + if (!api::is_available()) { + throw new \coding_exception('Communication API is not enabled, please enable it from experimental features'); + } + } + + /** + * Get communication instance id. + */ + public function require_room_features(): void { + if (!$this->supports_room_features()) { + throw new \coding_exception('room features are not supported by the provider'); + } + } + + /** + * Get communication instance id. + */ + public function require_room_user_features(): void { + if (!$this->supports_room_user_features()) { + throw new \coding_exception('room features are not supported by the provider'); + } + } + + /** + * Get communication instance id. + * + * @return bool|\stored_file + */ + public function get_avatar(): ?stored_file { + $fs = get_file_storage(); + $file = $fs->get_file( + (\context_system::instance())->id, + 'core_communication', + 'avatar', + $this->instancedata->id, + '/', + $this->instancedata->avatarfilename, + ); + + return $file ? $file : null; + } + + /** + * Get a room url. + * + * @return string|null + */ + public function get_room_url(): ?string { + if ($this->provider && $this->is_instance_active()) { + return $this->get_room_provider()->get_chat_room_url(); + } + return null; + } +} diff --git a/communication/classes/room_chat_provider.php b/communication/classes/room_chat_provider.php new file mode 100644 index 00000000000..234b1c2fc8b --- /dev/null +++ b/communication/classes/room_chat_provider.php @@ -0,0 +1,51 @@ +. + +namespace core_communication; + +/** + * Class communication_room_base to manage the room operations of communication providers. + * + * Every plugin that supports room operation must implement/extend this class in the plugin. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface room_chat_provider { + + /** + * Create a provider room when a instance is created. + */ + public function create_chat_room(): bool; + + /** + * Update a provider room when a instance is updated. + */ + public function update_chat_room(): bool; + + /** + * Delete a provider room when a instance is deleted. + */ + public function delete_chat_room(): bool; + + /** + * Generate a room url if there is a room. + * + * @return string|null + */ + public function get_chat_room_url(): ?string; +} diff --git a/communication/classes/room_user_provider.php b/communication/classes/room_user_provider.php new file mode 100644 index 00000000000..f9a04e48a4b --- /dev/null +++ b/communication/classes/room_user_provider.php @@ -0,0 +1,40 @@ +. + +namespace core_communication; + +/** + * Class communication_user_base to manage communication provider users. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface room_user_provider { + /** + * Add members to communication room. + * + * @param array $userids The user ids to be added + */ + public function add_members_to_room(array $userids): void; + + /** + * Remove members from room. + * + * @param array $userids The user ids to be removed + */ + public function remove_members_from_room(array $userids): void; +} diff --git a/communication/classes/task/add_members_to_room_task.php b/communication/classes/task/add_members_to_room_task.php new file mode 100644 index 00000000000..b49931c6b18 --- /dev/null +++ b/communication/classes/task/add_members_to_room_task.php @@ -0,0 +1,64 @@ +. + +namespace core_communication\task; + +use core\task\adhoc_task; +use core_communication\processor; + +/** + * Class add_members_to_room_task to add the task to add members to the room and execute the task to action the addition. + * + * @package core_communication + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class add_members_to_room_task extends adhoc_task { + + public function execute() { + // Initialize the custom data operation to be used for the action. + $data = $this->get_custom_data(); + + // Call the communication api to action the operation. + $communication = processor::load_by_id($data->id); + + if ($communication === null) { + mtrace("Skipping room creation because the instance does not exist"); + return; + } + + $communication->get_room_user_provider()->add_members_to_room($communication->get_instance_userids()); + } + + /** + * Queue the task for the next run. + * + * @param processor $communication The communication processor to perform the action on + */ + public static function queue( + processor $communication + ): void { + + // Add ad-hoc task to update the provider room. + $task = new self(); + $task->set_custom_data([ + 'id' => $communication->get_id() + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/communication/classes/task/create_and_configure_room_task.php b/communication/classes/task/create_and_configure_room_task.php new file mode 100644 index 00000000000..1327e4e723d --- /dev/null +++ b/communication/classes/task/create_and_configure_room_task.php @@ -0,0 +1,76 @@ +. + +namespace core_communication\task; + +use core\task\adhoc_task; +use core_communication\processor; + +/** + * Class create_and_configure_room_task to add a task to create a room and execute the task to action the creation. + * + * this task will be queued by the communication api and will use the communication handler api to action the creation. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class create_and_configure_room_task extends adhoc_task { + + public function execute() { + $data = $this->get_custom_data(); + + // Call the communication api to action the operation. + $communication = processor::load_by_id($data->id); + + if ($communication === null) { + mtrace("Skipping room creation because the instance does not exist"); + return; + } + + if (!$communication->is_instance_active()) { + mtrace("Skipping room creation because the instance is not active"); + return; + } + + // If the room is created successfully, add members to the room. + if ($communication->get_room_provider()->create_chat_room()) { + add_members_to_room_task::queue( + $communication + ); + } + + } + + /** + * Queue the task for the next run. + * + * @param processor $communication The communication processor to perform the action on + */ + public static function queue( + processor $communication, + ): void { + + // Add ad-hoc task to update the provider room. + $task = new self(); + $task->set_custom_data([ + 'id' => $communication->get_id() + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/communication/classes/task/delete_room_task.php b/communication/classes/task/delete_room_task.php new file mode 100644 index 00000000000..fea70c04a68 --- /dev/null +++ b/communication/classes/task/delete_room_task.php @@ -0,0 +1,72 @@ +. + +namespace core_communication\task; + +use core\task\adhoc_task; +use core_communication\processor; + +/** + * Class delete_room_task to add a task to delete a room and execute the task to action the deletion. + * + * this task will be queued by the communication api and will use the communication handler api to action the deletion. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_room_task extends adhoc_task { + + public function execute() { + $data = $this->get_custom_data(); + + $communication = processor::load_by_id($data->id); + + if ($communication === null) { + mtrace("Skipping room creation because the instance does not exist"); + return; + } + + // First remove the members from the room. + $communication->get_room_user_provider()->remove_members_from_room($communication->get_instance_userids(true, true)); + // Now remove any mapping for users who are not in the room. + $communication->delete_instance_non_synced_user_mapping($communication->get_instance_userids(false, true)); + + // Now delete the room. + if ($communication->get_room_provider()->delete_chat_room()) { + $communication->delete_instance(); + } + } + + /** + * Queue the task for the next run. + * + * @param processor $communication The communication processor to perform the action on + */ + public static function queue( + processor $communication, + ): void { + + // Add ad-hoc task to update the provider room. + $task = new self(); + $task->set_custom_data([ + 'id' => $communication->get_id() + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/communication/classes/task/remove_members_from_room.php b/communication/classes/task/remove_members_from_room.php new file mode 100644 index 00000000000..e681f949e01 --- /dev/null +++ b/communication/classes/task/remove_members_from_room.php @@ -0,0 +1,67 @@ +. + +namespace core_communication\task; + +use core\task\adhoc_task; +use core_communication\processor; + +/** + * Class remove_members_from_room to add the task to remove members to the room and execute the task to action the removal. + * + * @package core_communication + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class remove_members_from_room extends adhoc_task { + + public function execute() { + // Initialize the custom data operation to be used for the action. + $data = $this->get_custom_data(); + + // Call the communication api to action the operation. + $communication = processor::load_by_id($data->id); + + if ($communication === null) { + mtrace("Skipping room creation because the instance does not exist"); + return; + } + + $communication->get_room_user_provider()->remove_members_from_room($communication->get_instance_userids(true, true)); + + // Now remove any mapping for users who are not in the room. + $communication->delete_instance_non_synced_user_mapping($communication->get_instance_userids(false, true)); + } + + /** + * Queue the task for the next run. + * + * @param processor $communication The communication processor to perform the action on + */ + public static function queue( + processor $communication + ): void { + + // Add ad-hoc task to update the provider room. + $task = new self(); + $task->set_custom_data([ + 'id' => $communication->get_id() + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/communication/classes/task/update_room_task.php b/communication/classes/task/update_room_task.php new file mode 100644 index 00000000000..b487e28e4d9 --- /dev/null +++ b/communication/classes/task/update_room_task.php @@ -0,0 +1,65 @@ +. + +namespace core_communication\task; + +use core\task\adhoc_task; +use core_communication\processor; + +/** + * Class update_room_task to add a task to update a room and execute the task to action the update. + * + * this task will be queued by the communication api and will use the communication handler api to action the updates. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class update_room_task extends adhoc_task { + + public function execute() { + $data = $this->get_custom_data(); + + // Call the communication api to action the operation. + $communication = processor::load_by_id($data->id); + + if ($communication === null) { + mtrace("Skipping room creation because the instance does not exist"); + return; + } + + $communication->get_room_provider()->update_chat_room(); + } + + /** + * Queue the task for the next run. + * + * @param processor $communication The communication processor to perform the action on + */ + public static function queue( + processor $communication, + ): void { + + // Add ad-hoc task to update the provider room. + $task = new self(); + $task->set_custom_data([ + 'id' => $communication->get_id() + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/communication/classes/user_provider.php b/communication/classes/user_provider.php new file mode 100644 index 00000000000..d59df679f61 --- /dev/null +++ b/communication/classes/user_provider.php @@ -0,0 +1,34 @@ +. + +namespace core_communication; + +/** + * Class communication_user_base to manage communication provider users. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface user_provider { + + /** + * Create members. + * + * @param array $userid The users ids to be created + */ + public function create_members(array $userid): void; +} diff --git a/communication/lib.php b/communication/lib.php new file mode 100644 index 00000000000..84ce24ed542 --- /dev/null +++ b/communication/lib.php @@ -0,0 +1,61 @@ +. + +/** + * Callback and library methods for core communication. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Serve the files from the core_communication file areas. + * + * @param stdClass $course the course object + * @param stdClass $cm the course module object + * @param context $context the context + * @param string $filearea the name of the file area + * @param array $args extra arguments (itemid, path) + * @param bool $forcedownload whether force download + * @param array $options additional options affecting the file serving + */ +function core_communication_pluginfile( + $course, + $cm, + $context, + $filearea, + $args, + $forcedownload, + array $options = [] +): void { + + if ($filearea !== 'avatar') { + return; + } + + $itemid = array_shift($args); + $filename = array_pop($args); + + // Retrieve the file from the Files API. + $fs = get_file_storage(); + $file = $fs->get_file($context->id, 'core_communication', $filearea, $itemid, '/', $filename); + if (!$file) { + return; + } + + send_file($file, $filename); +} diff --git a/communication/tests/api_test.php b/communication/tests/api_test.php new file mode 100644 index 00000000000..6dda30881be --- /dev/null +++ b/communication/tests/api_test.php @@ -0,0 +1,315 @@ +. + +namespace core_communication; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Class api_test to test the communication public api and its associated methods. + * + * @package core_communication + * @category test + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_communication\api + */ +class api_test extends \advanced_testcase { + + use communication_test_helper_trait; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->setup_communication_configs(); + } + + /** + * Test the communication plugin list for the form element returns the correct number of plugins. + * + * @covers ::get_communication_plugin_list_for_form + */ + public function test_get_communication_plugin_list_for_form(): void { + $communicationplugins = \core_communication\api::get_communication_plugin_list_for_form(); + // Get the communication plugins. + $plugins = \core_component::get_plugin_list('communication'); + // Check the number of plugins matches plus 1 as we have none in the selection. + $this->assertCount(count($plugins) + 1, $communicationplugins); + } + + /** + * Test set data to the instance. + * + * @covers ::set_data + */ + public function test_set_data(): void { + $course = $this->get_course(); + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + // Sample data. + $roomname = 'Sampleroom'; + $provider = 'communication_matrix'; + + // Set the data. + $communication->set_data($course); + + // Test the set data. + $this->assertEquals($roomname, $course->communicationroomname); + $this->assertEquals($provider, $course->selectedcommunication); + } + + /** + * Test get_current_communication_provider method. + * + * @covers ::get_provider + */ + public function test_get_provider(): void { + $course = $this->get_course(); + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('communication_matrix', $communication->get_provider()); + } + + /** + * Test get_avatar_filerecord method. + * + * @covers ::get_avatar_filerecord + */ + public function test_get_avatar_filerecord(): void { + $course = $this->get_course(); + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $filerecord = $communication->get_avatar_filerecord('avatar.svg'); + + $this->assertEquals('avatar.svg', $filerecord->filename); + $this->assertEquals('core_communication', $filerecord->component); + $this->assertEquals('avatar', $filerecord->filearea); + } + + /** + * Test set_avatar_from_datauri method. + * + * @covers ::set_avatar_from_datauri_or_filepath + * @covers ::get_avatar_filerecord + */ + public function test_set_avatar_from_datauri_or_filepath(): void { + global $CFG; + $course = $this->get_course('Sampleroom', 'none'); + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $avatarurl = $CFG->dirroot . '/communication/tests/fixtures/moodle_logo.jpg'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname, $avatarurl); + + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertNotNull($communicationprocessor->get_avatar()); + } + + /** + * Test the create_and_configure_room method to add/create tasks. + * + * @covers ::create_and_configure_room + */ + public function test_create_and_configure_room(): void { + // Get the course by disabling communication so that we can create it manually calling the api. + $course = $this->get_course('Sampleroom', 'none'); + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\create_and_configure_room_task'); + $this->assertCount(1, $adhoctask); + + $adhoctask = reset($adhoctask); + $this->assertInstanceOf('\\core_communication\\task\\create_and_configure_room_task', $adhoctask); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); + $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); + } + + /** + * Test the create_and_configure_room method to add/create tasks when no communication provider selected. + * + * @covers ::create_and_configure_room + */ + public function test_create_and_configure_room_without_communication_provider_selected(): void { + // Get the course by disabling communication so that we can create it manually calling the api. + $course = $this->getDataGenerator()->create_course(); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\create_and_configure_room_task'); + $this->assertCount(0, $adhoctask); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertNull($communicationprocessor); + } + + /** + * Test update operation. + * + * @covers ::update_room + */ + public function test_update_room(): void { + $course = $this->get_course(); + + // Sample data. + $communicationroomname = 'Sampleroomupdated'; + $selectedcommunication = 'communication_matrix'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->update_room($selectedcommunication, $communicationroomname); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\update_room_task'); + // Should be 2 as one for create, another for update. + $this->assertCount(1, $adhoctask); + + $adhoctask = reset($adhoctask); + $this->assertInstanceOf('\\core_communication\\task\\update_room_task', $adhoctask); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); + $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); + } + + /** + * Test delete operation. + * + * @covers ::delete_room + */ + public function test_delete_room(): void { + $course = $this->get_course(); + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); + $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->delete_room(); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\delete_room_task'); + // Should be 2 as one for create, another for update. + $this->assertCount(1, $adhoctask); + + $adhoctask = reset($adhoctask); + $this->assertInstanceOf('\\core_communication\\task\\delete_room_task', $adhoctask); + } + + /** + * Test the update_room_membership for adding adn removing members. + * + * @covers ::add_members_to_room + * @covers ::remove_members_from_room + */ + public function test_update_room_membership(): void { + $course = $this->get_course(); + $userid = $this->get_user()->id; + + // First test the adding members to a room. + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->add_members_to_room([$userid]); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\add_members_to_room_task'); + $this->assertCount(1, $adhoctask); + + // Now test the removing members from a room. + $communication->remove_members_from_room([$userid]); + + // Test the tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\remove_members_from_room'); + $this->assertCount(1, $adhoctask); + } +} diff --git a/communication/tests/communication_processor_test.php b/communication/tests/communication_processor_test.php new file mode 100644 index 00000000000..72940d049c7 --- /dev/null +++ b/communication/tests/communication_processor_test.php @@ -0,0 +1,587 @@ +. + +namespace core_communication; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Class communication_processor_test to test the communication internal api and its associated methods. + * + * @package core_communication + * @category test + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_communication\communication_processor + */ +class communication_processor_test extends \advanced_testcase { + + use communication_test_helper_trait; + + /** + * Test create instance. + * + * @covers ::create_instance + */ + public function test_create_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = communication_processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', + ['instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype]); + + // Test against the set data. + $this->assertNotEmpty($communicationrecord); + $this->assertEquals($instanceid, $communicationrecord->instanceid); + $this->assertEquals($component, $communicationrecord->component); + $this->assertEquals($selectedcommunication, $communicationrecord->provider); + $this->assertEquals($communicationroomname, $communicationrecord->roomname); + $this->assertEquals($instancetype, $communicationrecord->instancetype); + + // Test against the object. + $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); + $this->assertEquals($communicationprocessor->get_provider(), $communicationrecord->provider); + $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); + } + + /** + * Test update instance. + * + * @covers ::update_instance + */ + public function test_update_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = communication_processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + $selectedcommunication = 'none'; + $communicationroomname = 'communicationroomedited'; + + $communicationprocessor->update_instance($selectedcommunication, $communicationroomname); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', [ + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype + ]); + + // Test against the set data. + $this->assertNotEmpty($communicationrecord); + $this->assertEquals($instanceid, $communicationrecord->instanceid); + $this->assertEquals($component, $communicationrecord->component); + $this->assertEquals($selectedcommunication, $communicationrecord->provider); + $this->assertEquals($communicationroomname, $communicationrecord->roomname); + $this->assertEquals($instancetype, $communicationrecord->instancetype); + + // Test against the object. + $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); + $this->assertEquals($communicationprocessor->get_provider(), $communicationrecord->provider); + $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); + } + + /** + * Test delete instance. + * + * @covers ::delete_instance + */ + public function test_delete_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = communication_processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + $communicationprocessor->delete_instance(); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', [ + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype + ]); + + // Test against the set data. + $this->assertEmpty($communicationrecord); + + // Test against the object. + $communicationprocessor = communication_processor::load_by_instance( + $component, + $instancetype, + $instanceid); + $this->assertNull($communicationprocessor); + } + + /** + * Test create instance user mapping. + * + * @covers ::create_instance_user_mapping + * @covers ::get_all_userids_for_instance + */ + public function test_create_instance_user_mapping(): void { + $this->resetAfterTest(); + + global $DB; + $course = $this->get_course('Sampleroom', 'none'); + $userid = $this->get_user()->id; + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + + // First test the adding members to a room. + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname); + $communication->add_members_to_room([$userid]); + + // Test against the object. + $communicationprocessor = communication_processor::load_by_instance( + $component, + $instancetype, + $course->id + ); + + $this->assertEquals([$userid], $communicationprocessor->get_all_userids_for_instance()); + + // Test against the database. + $communicationuserrecord = $DB->get_record('communication_user', [ + 'commid' => $communicationprocessor->get_id(), + 'userid' => $userid + ]); + + $this->assertEquals($communicationuserrecord->userid, $userid); + $this->assertEquals($communicationuserrecord->commid, $communicationprocessor->get_id()); + } + + /** + * Test update instance user mapping. + * + * @covers ::get_all_userids_for_instance + */ + public function test_update_instance_user_mapping(): void { + $this->resetAfterTest(); + + global $DB; + $course = $this->get_course(); + $userid = $this->get_user()->id; + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->update_room($selectedcommunication, $communicationroomname); + $communication->add_members_to_room([$userid]); + + // Test against the object. + $communicationprocessor = communication_processor::load_by_instance( + $component, + $instancetype, + $course->id + ); + + $this->assertnotEmpty($communicationprocessor->get_instance_userids_by_synced()); + + // Test against the database. + $communicationuserrecord = $DB->get_record('communication_user', [ + 'commid' => $communicationprocessor->get_id(), + 'userid' => $userid + ]); + + $this->assertEquals($communicationuserrecord->userid, $userid); + $this->assertEquals($communicationuserrecord->commid, $communicationprocessor->get_id()); + + // Now add again. + $communicationprocessor->delete_instance_user_mapping([$userid]); + + // Test against the database. + $communicationuserrecord = $DB->get_record('communication_user', [ + 'commid' => $communicationprocessor->get_id(), + 'userid' => $userid + ]); + + $this->assertEmpty($communicationuserrecord); + + + } + + /** + * Test delete instance user mapping. + * + * @covers ::delete_instance_user_mapping + * @covers ::get_all_userids_for_instance + */ + public function test_delete_instance_user_mapping(): void { + $this->resetAfterTest(); + + global $DB; + $course = $this->get_course('Sampleroom', 'none'); + $userid = $this->get_user()->id; + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + + // First test the adding members to a room. + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname); + $communication->add_members_to_room([$userid]); + + // Test against the object. + $communicationprocessor = communication_processor::load_by_instance( + $component, + $instancetype, + $course->id + ); + + $this->assertEquals([$userid], $communicationprocessor->get_all_userids_for_instance()); + + // Delete the user mapping. + $communicationprocessor->delete_instance_user_mapping([$userid]); + + $this->assertEmpty($communicationprocessor->get_all_userids_for_instance()); + + // Test against the database. + $communicationuserrecord = $DB->get_record('communication_user', [ + 'commid' => $communicationprocessor->get_id(), + 'userid' => $userid + ]); + + $this->assertEmpty($communicationuserrecord); + } + + /** + * Test delete user mappings for instance. + * + * @covers ::delete_user_mappings_for_instance + * @covers ::get_all_userids_for_instance + */ + public function test_delete_user_mappings_for_instance(): void { + $this->resetAfterTest(); + + global $DB; + $course = $this->get_course('Sampleroom', 'none'); + $userid = $this->get_user()->id; + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + + // First test the adding members to a room. + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname); + $communication->add_members_to_room([$userid]); + + // Test against the object. + $communicationprocessor = communication_processor::load_by_instance( + $component, + $instancetype, + $course->id + ); + + $this->assertEquals([$userid], $communicationprocessor->get_all_userids_for_instance()); + + // Delete the user mapping. + $communicationprocessor->delete_user_mappings_for_instance(); + + $this->assertEmpty($communicationprocessor->get_all_userids_for_instance()); + + // Test against the database. + $communicationuserrecord = $DB->get_record('communication_user', [ + 'commid' => $communicationprocessor->get_id(), + 'userid' => $userid + ]); + + $this->assertEmpty($communicationuserrecord); + } + + /** + * Test load by id. + * + * @covers ::load_by_instance + */ + public function test_load_by_instance(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertNotNull($communicationprocessor); + $this->assertInstanceOf(communication_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test load by id. + * + * @covers ::load_by_id + */ + public function test_load_by_id(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $communicationprocessorbyid = communication_processor::load_by_id($communicationprocessor->get_id()); + + $this->assertNotNull($communicationprocessorbyid); + $this->assertInstanceOf(communication_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(room_user_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(user_provider::class, $communicationprocessorbyid->get_room_provider()); + } + + /** + * Test get component. + * + * @covers ::get_component + */ + public function test_get_component(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('core_course', $communicationprocessor->get_component()); + } + + /** + * Test get provider. + * + * @covers ::get_provider + */ + public function test_get_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('communication_matrix', $communicationprocessor->get_provider()); + } + + /** + * Test get room name. + * + * @covers ::get_room_name + */ + public function test_get_room_name(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('Sampleroom', $communicationprocessor->get_room_name()); + } + + /** + * Test get room provider. + * + * @covers ::get_room_provider + * @covers ::require_room_features + * @covers ::supports_room_features + */ + public function test_get_room_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test get user provider. + * + * @covers ::get_user_provider + * @covers ::require_user_features + * @covers ::supports_user_features + */ + public function test_get_user_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test get room user provider. + * + * @covers ::get_room_user_provider + * @covers ::require_room_features + * @covers ::require_room_user_features + * @covers ::supports_room_user_features + * @covers ::supports_room_features + */ + public function test_get_room_user_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_user_provider()); + } + + /** + * Test get avatar. + * + * @covers ::get_avatar + */ + public function test_get_avatar(): void { + $this->resetAfterTest(); + + global $CFG; + $course = $this->get_course('Sampleroom', 'none'); + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $avatarurl = $CFG->dirroot . '/communication/tests/fixtures/moodle_logo.jpg'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname, $avatarurl); + + $communicationprocessor = communication_processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $avatar = $communicationprocessor->get_avatar(); + + $this->assertNotNull($avatar); + $this->assertEquals($avatar->get_component(), 'core_communication'); + $this->assertEquals($avatar->get_filearea(), 'avatar'); + $this->assertEquals($avatar->get_itemid(), $communicationprocessor->get_id()); + $this->assertEquals($avatar->get_filepath(), '/'); + $this->assertEquals($avatar->get_filearea(), 'avatar'); + } +} diff --git a/communication/tests/communication_test_helper_trait.php b/communication/tests/communication_test_helper_trait.php new file mode 100644 index 00000000000..2fb398881fa --- /dev/null +++ b/communication/tests/communication_test_helper_trait.php @@ -0,0 +1,82 @@ +. + +namespace core_communication; + +/** + * Trait communication_test_helper_trait to generate initial setup for communication providers. + * + * @package core_communication + * @category test + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait communication_test_helper_trait { + + /** + * Setup necessary configs for communication subsystem. + * + * @return void + */ + protected function setup_communication_configs(): void { + set_config('enablecommunicationsubsystem', 1); + } + + /** + * Get or create course if it does not exist + * + * @param string $roomname The room name for the communication api + * @param string $provider The selected provider + * @return \stdClass + */ + protected function get_course( + string $roomname = 'Sampleroom', + string $provider = 'communication_matrix' + ): \stdClass { + + $this->setup_communication_configs(); + $records = [ + 'selectedcommunication' => $provider, + 'communicationroomname' => $roomname, + ]; + + return $this->getDataGenerator()->create_course($records); + } + + /** + * Get or create user if it does not exist. + * + * @param string $firstname The user's firstname for the communication api + * @param string $lastname The user's lastname for the communication api + * @param string $username The user's username for the communication api + * @return \stdClass + */ + protected function get_user( + string $firstname = 'Samplefn', + string $lastname = 'Sampleln', + string $username = 'sampleun' + ): \stdClass { + + $this->setup_communication_configs(); + $records = [ + 'firstname' => $firstname, + 'lastname' => $lastname, + 'username' => $username + ]; + + return $this->getDataGenerator()->create_user($records); + } +} diff --git a/communication/tests/fixtures/moodle_logo.jpg b/communication/tests/fixtures/moodle_logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f451615312041c97fb3da637aaa10ddd0d463269 GIT binary patch literal 76664 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 UHva(Gx5@tit2@TDQ9vL6*&a#rrvLx| literal 0 HcmV?d00001 diff --git a/communication/tests/processor_test.php b/communication/tests/processor_test.php new file mode 100644 index 00000000000..eb3263fbe65 --- /dev/null +++ b/communication/tests/processor_test.php @@ -0,0 +1,398 @@ +. + +namespace core_communication; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Class processor_test to test the communication internal api and its associated methods. + * + * @package core_communication + * @category test + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_communication\processor + */ +class processor_test extends \advanced_testcase { + + use communication_test_helper_trait; + + /** + * Test create instance. + * + * @covers ::create_instance + * @covers ::get_id + * @covers ::get_instance + * @covers ::get_room_name + */ + public function test_create_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', + ['instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype]); + + // Test against the set data. + $this->assertNotEmpty($communicationrecord); + $this->assertEquals($instanceid, $communicationrecord->instanceid); + $this->assertEquals($component, $communicationrecord->component); + $this->assertEquals($selectedcommunication, $communicationrecord->provider); + $this->assertEquals($communicationroomname, $communicationrecord->roomname); + $this->assertEquals($instancetype, $communicationrecord->instancetype); + + // Test against the object. + $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); + $this->assertEquals($communicationprocessor->get_provider(), $communicationrecord->provider); + $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); + } + + /** + * Test update instance. + * + * @covers ::update_instance + * @covers ::is_instance_active + * @covers ::get_id + * @covers ::get_room_name + */ + public function test_update_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + $selectedcommunication = 'none'; + $communicationroomname = 'communicationroomedited'; + + $communicationprocessor->update_instance($selectedcommunication, $communicationroomname); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', [ + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype + ]); + + // Test against the set data. + $this->assertNotEmpty($communicationrecord); + $this->assertEquals($instanceid, $communicationrecord->instanceid); + $this->assertEquals($component, $communicationrecord->component); + $this->assertEquals(processor::PROVIDER_INACTIVE, $communicationrecord->active); + $this->assertEquals($communicationroomname, $communicationrecord->roomname); + $this->assertEquals($instancetype, $communicationrecord->instancetype); + + // Test against the object. + $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); + $this->assertEquals($communicationprocessor->is_instance_active(), $communicationrecord->active); + $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); + } + + /** + * Test delete instance. + * + * @covers ::delete_instance + * @covers ::create_instance + * @covers ::load_by_instance + */ + public function test_delete_instance(): void { + global $DB; + $this->resetAfterTest(); + + // Sameple test data. + $instanceid = 10; + $component = 'core_course'; + $instancetype = 'coursecommunication'; + $selectedcommunication = 'communication_matrix'; + $communicationroomname = 'communicationroom'; + + $communicationprocessor = processor::create_instance( + $selectedcommunication, + $instanceid, + $component, + $instancetype, + $communicationroomname, + ); + + $communicationprocessor->delete_instance(); + + // Now test the record against the database. + $communicationrecord = $DB->get_record('communication', [ + 'instanceid' => $instanceid, + 'component' => $component, + 'instancetype' => $instancetype + ]); + + // Test against the set data. + $this->assertEmpty($communicationrecord); + + // Test against the object. + $communicationprocessor = processor::load_by_instance( + $component, + $instancetype, + $instanceid); + $this->assertNull($communicationprocessor); + } + + /** + * Test load by id. + * + * @covers ::load_by_instance + * @covers ::get_room_provider + */ + public function test_load_by_instance(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertNotNull($communicationprocessor); + $this->assertInstanceOf(communication_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_provider()); + $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test load by id. + * + * @covers ::load_by_id + * @covers ::get_room_provider + * @covers ::load_by_instance + */ + public function test_load_by_id(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $communicationprocessorbyid = processor::load_by_id($communicationprocessor->get_id()); + + $this->assertNotNull($communicationprocessorbyid); + $this->assertInstanceOf(communication_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(room_user_provider::class, $communicationprocessorbyid->get_room_provider()); + $this->assertInstanceOf(user_provider::class, $communicationprocessorbyid->get_room_provider()); + } + + /** + * Test get component. + * + * @covers ::get_component + * @covers ::load_by_instance + */ + public function test_get_component(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('core_course', $communicationprocessor->get_component()); + } + + /** + * Test get provider. + * + * @covers ::get_provider + * @covers ::load_by_instance + */ + public function test_get_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('communication_matrix', $communicationprocessor->get_provider()); + } + + /** + * Test get room name. + * + * @covers ::get_room_name + * @covers ::load_by_instance + */ + public function test_get_room_name(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertEquals('Sampleroom', $communicationprocessor->get_room_name()); + } + + /** + * Test get room provider. + * + * @covers ::get_room_provider + * @covers ::require_room_features + * @covers ::supports_room_features + * @covers ::load_by_instance + */ + public function test_get_room_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test get user provider. + * + * @covers ::get_user_provider + * @covers ::require_user_features + * @covers ::supports_user_features + * @covers ::load_by_instance + */ + public function test_get_user_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); + } + + /** + * Test get room user provider. + * + * @covers ::get_room_user_provider + * @covers ::require_room_features + * @covers ::require_room_user_features + * @covers ::supports_room_user_features + * @covers ::supports_room_features + * @covers ::load_by_instance + */ + public function test_get_room_user_provider(): void { + $this->resetAfterTest(); + $course = $this->get_course(); + + // Test the communication record exists. + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_user_provider()); + } + + /** + * Test get avatar. + * + * @covers ::get_avatar + * @covers ::load_by_instance + */ + public function test_get_avatar(): void { + $this->resetAfterTest(); + + global $CFG; + $course = $this->get_course('Sampleroom', 'none'); + + // Sample data. + $communicationroomname = 'Sampleroom'; + $selectedcommunication = 'communication_matrix'; + $avatarurl = $CFG->dirroot . '/communication/tests/fixtures/moodle_logo.jpg'; + + $communication = \core_communication\api::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + $communication->create_and_configure_room($selectedcommunication, $communicationroomname, $avatarurl); + + $communicationprocessor = processor::load_by_instance( + 'core_course', + 'coursecommunication', + $course->id + ); + + $avatar = $communicationprocessor->get_avatar(); + + $this->assertNotNull($avatar); + $this->assertEquals($avatar->get_component(), 'core_communication'); + $this->assertEquals($avatar->get_filearea(), 'avatar'); + $this->assertEquals($avatar->get_itemid(), $communicationprocessor->get_id()); + $this->assertEquals($avatar->get_filepath(), '/'); + $this->assertEquals($avatar->get_filearea(), 'avatar'); + } +} diff --git a/lang/en/communication.php b/lang/en/communication.php index 92c906dfae4..c04aa2ca66d 100644 --- a/lang/en/communication.php +++ b/lang/en/communication.php @@ -24,6 +24,11 @@ $string['communication'] = 'Communication'; $string['communicationprovidernotfound'] = 'The \'{$a}\' communication provider doesn\'t exist or is not recognised.'; +$string['communicationroomname'] = 'Room name'; +$string['communicationroomname_help'] = 'The name that participants see when they visit the room. If you leave this blank, a default room name will be automatically set.'; $string['managecommunicationproviders'] = 'Manage communication providers'; $string['nocommunicationprovider'] = 'No communication provider found.'; +$string['nocommunicationselected'] = 'None'; $string['privacy:metadata'] = 'The Moodle communication subsystem does not store any personal data.'; +$string['seleccommunicationprovider'] = 'Communication service'; +$string['seleccommunicationprovider_help'] = 'The tool available for chat and related communication methods.'; diff --git a/lib/db/install.xml b/lib/db/install.xml index ac1a966513c..5c0f16874df 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -218,6 +218,35 @@ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 2af3b5374c5..170df67f98b 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3256,6 +3256,52 @@ privatefiles,moodle|/user/files.php'; upgrade_main_savepoint(true, 2023042000.00); } + if ($oldversion < 2023050400.01) { + // Define communication table. + $table = new xmldb_table('communication'); + + // Adding fields to table communication. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('instanceid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'id'); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null, 'instanceid'); + $table->add_field('instancetype', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null, 'component'); + $table->add_field('provider', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null, 'instancerype'); + $table->add_field('roomname', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'provider'); + $table->add_field('avatarfilename', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'roomname'); + $table->add_field('active', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, 1, 'avatarfilename'); + + // Add key. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Conditionally launch create table for communication. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Define communication user table. + $table = new xmldb_table('communication_user'); + + // Adding fields to table communication. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('commid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'id'); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'commid'); + $table->add_field('synced', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, 0, 'userid'); + $table->add_field('deleted', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, 0, 'synced'); + + // Add keys. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('commid', XMLDB_KEY_FOREIGN, ['commid'], 'communication', ['id']); + $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']); + + // Conditionally launch create table for communication. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2023050400.01); + } + // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. diff --git a/version.php b/version.php index aa0c583e0a1..1263fce8eab 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2023050400.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2023050400.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.3dev (Build: 20230504)'; // Human-friendly version name