From 0c0bab5ccbfe9f109cd364897abee5d2c713b7f4 Mon Sep 17 00:00:00 2001 From: Dmitrii Metelkin Date: Thu, 7 Mar 2024 12:34:29 +1100 Subject: [PATCH] issue #9 issue #18: add rule processing and cohort membership condition --- classes/event/matching_failed.php | 79 +++++ .../condition/cohort_membership.php | 284 ++++++++++++++++ classes/rule_manager.php | 155 +++++++++ classes/task/process_rule.php | 48 +++ classes/task/process_rules.php | 53 +++ db/tasks.php | 36 ++ lang/en/tool_dynamic_cohorts.php | 10 + .../condition/cohort_membership_test.php | 310 ++++++++++++++++++ tests/rule_manager_test.php | 279 ++++++++++++++++ version.php | 4 +- 10 files changed, 1256 insertions(+), 2 deletions(-) create mode 100644 classes/event/matching_failed.php create mode 100644 classes/local/tool_dynamic_cohorts/condition/cohort_membership.php create mode 100644 classes/task/process_rule.php create mode 100644 classes/task/process_rules.php create mode 100644 db/tasks.php create mode 100644 tests/local/tool_dynamic_cohorts/condition/cohort_membership_test.php diff --git a/classes/event/matching_failed.php b/classes/event/matching_failed.php new file mode 100644 index 0000000..f53b23d --- /dev/null +++ b/classes/event/matching_failed.php @@ -0,0 +1,79 @@ +. + +namespace tool_dynamic_cohorts\event; + +use core\event\base; + + /** + * Event triggered when a matching users failed with an error. + * + * @property-read array $other { + * Extra information about event. + * - string ruleid: related rule id. + * - string error: error that matching failed with . + * } + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class matching_failed extends base { + + /** + * Initialise the rule data. + */ + protected function init() { + $this->data['edulevel'] = self::LEVEL_OTHER; + $this->data['crud'] = 'u'; + $this->context = \context_system::instance(); + } + + /** + * Return localised event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('event:matchingfailed', 'tool_dynamic_cohorts'); + } + + /** + * Returns description of what happened. + * + * @return string + */ + public function get_description(): string { + return "Matching users for rule with id '{$this->other['ruleid']}' failed with an error '{$this->other['error']}''"; + } + + /** + * Validates the custom data. + * + * @throws \coding_exception if missing required data. + */ + protected function validate_data() { + parent::validate_data(); + + if (!isset($this->other['ruleid'])) { + throw new \coding_exception('The \'name\' value must be set in other.'); + } + + if (!isset($this->other['error'])) { + throw new \coding_exception('The \'description\' value must be set in other.'); + } + } +} diff --git a/classes/local/tool_dynamic_cohorts/condition/cohort_membership.php b/classes/local/tool_dynamic_cohorts/condition/cohort_membership.php new file mode 100644 index 0000000..919a7cd --- /dev/null +++ b/classes/local/tool_dynamic_cohorts/condition/cohort_membership.php @@ -0,0 +1,284 @@ +. + +namespace tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition; + +use tool_dynamic_cohorts\cohort_manager; +use tool_dynamic_cohorts\condition_base; +use tool_dynamic_cohorts\condition_sql; +use html_writer; + +/** + * Condition based on cohort membership. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohort_membership extends condition_base { + + /** + * A field name in the form. + */ + public const FIELD_NAME = 'cohort_membership'; + + /** + * Operator value when need members of cohort(s). + */ + public const OPERATOR_IS_MEMBER_OF = 1; + + /** + * Operator value when don't need members of cohort(s). + */ + public const OPERATOR_IS_NOT_MEMBER_OF = 2; + + /** + * Cached locally list of all cohorts. + * @var null|array + */ + protected $allcohorts = null; + + /** + * Condition name. + * + * @return string + */ + public function get_name(): string { + return get_string('condition:cohort_membership', 'tool_dynamic_cohorts'); + } + + /** + * Gets an list of comparison operators. + * + * @return array A list of operators. + */ + protected function get_operators(): array { + return [ + self::OPERATOR_IS_MEMBER_OF => get_string('ismemberof', 'tool_dynamic_cohorts'), + self::OPERATOR_IS_NOT_MEMBER_OF => get_string('isnotmemberof', 'tool_dynamic_cohorts'), + ]; + } + + /** + * Returns a list of all cohorts. + * + * @return array + */ + protected function get_all_cohorts(): array { + if (is_null($this->allcohorts)) { + $this->allcohorts = []; + foreach (cohort_manager::get_cohorts() as $cohort) { + $this->allcohorts[$cohort->id] = $cohort->name; + } + } + + return $this->allcohorts; + } + + /** + * Add config form elements. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_add(\MoodleQuickForm $mform): void { + $mform->addElement( + 'select', + $this->get_operator_field(), + get_string('operator', 'tool_dynamic_cohorts'), + $this->get_operators() + ); + + $mform->addElement( + 'autocomplete', + $this->get_cohort_field(), + get_string('cohort', 'cohort'), + $this->get_all_cohorts(), + ['noselectionstring' => get_string('choosedots'), 'multiple' => true] + ); + + $mform->addRule($this->get_cohort_field(), get_string('required'), 'required'); + } + + /** + * Validate config form elements. + * + * @param array $data Data to validate. + * @return array + */ + public function config_form_validate(array $data): array { + $errors = []; + + if (empty($data[$this->get_cohort_field()])) { + $errors[$this->get_cohort_field()] = get_string('pleaseselectcohort', 'tool_dynamic_cohorts'); + } + + return $errors; + } + + /** + * Operator field. + * + * @return string + */ + protected function get_operator_field(): string { + return self::FIELD_NAME . '_operator'; + } + + /** + * Get cohort field. + * + * @return string + */ + protected function get_cohort_field(): string { + return self::FIELD_NAME . '_value'; + } + + /** + * Gets a list of configured cohort IDs. + * + * @return array + */ + protected function get_configured_cohorts(): array { + return $this->get_config_data()[$this->get_cohort_field()] ?? []; + } + + /** + * Gets operator value. + * + * @return array|mixed + */ + protected function get_operator_value() { + return $this->get_config_data()[$this->get_operator_field()] ?? self::OPERATOR_IS_MEMBER_OF; + } + + /** + * Human-readable description of the configured condition. + * + * @return string + */ + public function get_config_description(): string { + $operator = $this->get_operators()[$this->get_operator_value()]; + + $cohorts = array_map(function ($cohortid) { + return $this->get_all_cohorts()[$cohortid] ?? $cohortid; + }, $this->get_configured_cohorts()); + + $cohorts = implode(' ' . get_string('or', 'tool_dynamic_cohorts') . ' ', $cohorts); + + return get_string('condition:cohort_membership_description', 'tool_dynamic_cohorts', (object)[ + 'operator' => $operator, + 'cohorts' => $cohorts, + ]); + } + + /** + * Human readable description of the broken condition. + * + * @return string + */ + public function get_broken_description(): string { + if ($this->is_using_rule_cohort()) { + $description = get_string('condition:cohort_membership_broken_description', 'tool_dynamic_cohorts'); + $description .= html_writer::empty_tag('br'); + $description .= $this->get_config_description(); + } else { + $description = parent::get_broken_description(); + } + + return $description; + } + + /** + * Gets SQL data for building SQL. + * + * @return condition_sql + */ + public function get_sql(): condition_sql { + global $DB; + + $result = new condition_sql('', '1=0', []); + + if (!$this->is_broken() && !empty($this->get_configured_cohorts())) { + $innertable = condition_sql::generate_table_alias(); + $outertable = condition_sql::generate_table_alias(); + + list($sql, $params) = $DB->get_in_or_equal( + $this->get_configured_cohorts(), + SQL_PARAMS_NAMED, + condition_sql::generate_param_alias() + ); + + // Are we getting members? + $needmembers = $this->get_operator_value() == self::OPERATOR_IS_MEMBER_OF; + // Select all users that are members or not members of given cohorts depending on selected operator. + $join = "LEFT JOIN (SELECT {$innertable}.userid + FROM {cohort_members} $innertable + WHERE {$innertable}.cohortid {$sql}) {$outertable} + ON u.id = {$outertable}.userid"; + + $where = $needmembers ? "$outertable.userid is NOT NULL" : "$outertable.userid is NULL"; + $result = new condition_sql($join, $where, $params); + } + + return $result; + } + + /** + * A list of events the condition is listening to. + * + * @return string[] + */ + public function get_events(): array { + return [ + '\core\event\cohort_member_added', + '\core\event\cohort_member_removed', + ]; + } + + /** + * Check if condition is configured to check the same cohort that set for the related rule. + * + * @return bool + */ + protected function is_using_rule_cohort(): bool { + $rule = $this->get_rule(); + if ($rule && in_array($rule->get('cohortid'), $this->get_configured_cohorts())) { + return true; + } + + return false; + } + + /** + * Is condition broken. + * + * @return bool + */ + public function is_broken(): bool { + // Check if configured cohort is still exist. + foreach ($this->get_configured_cohorts() as $cohortid) { + if (!array_key_exists($cohortid, $this->get_all_cohorts())) { + return true; + } + } + // Check if rule manages one of the configured cohorts. + if ($this->is_using_rule_cohort()) { + return true; + } + + return false; + } +} diff --git a/classes/rule_manager.php b/classes/rule_manager.php index 3f96a98..c621cad 100644 --- a/classes/rule_manager.php +++ b/classes/rule_manager.php @@ -18,6 +18,7 @@ use moodle_url; use moodle_exception; +use tool_dynamic_cohorts\event\matching_failed; use tool_dynamic_cohorts\event\rule_created; use tool_dynamic_cohorts\event\rule_deleted; use tool_dynamic_cohorts\event\rule_updated; @@ -31,6 +32,11 @@ */ class rule_manager { + /** + * A number of users for a bulk processing. + */ + const BULK_PROCESSING_SIZE = 10000; + /** * Builds rule edit URL. * @@ -187,4 +193,153 @@ public static function delete_rule(rule $rule): void { cohort_manager::unmanage_cohort($rule->get('cohortid')); } } + + /** + * Returns a list of all matching users for provided rule. + * + * @param rule $rule A rule to get a list of users. + * @param int|null $userid Optional user ID if we need to check just one user. + * + * @return array + */ + public static function get_matching_users(rule $rule, ?int $userid = null): array { + global $DB; + + $conditions = $rule->get_condition_records(); + + if (empty($conditions)) { + return []; + } + + $where = ' u.deleted = 0 '; + $join = ''; + $params = []; + + $sql = "SELECT DISTINCT u.id FROM {user} u"; + + foreach ($conditions as $condition) { + try { + $instance = condition_base::get_instance(0, $condition->to_record()); + + if (!$instance || $instance->is_broken()) { + return []; + } + + $sqldata = $instance->get_sql(); + + if (!empty($sqldata->get_join())) { + $join .= ' ' . $sqldata->get_join(); + } + + if (!empty($sqldata->get_where())) { + $where .= ' AND (' . $sqldata->get_where() . ')'; + } + + if (!empty($sqldata->get_params())) { + $params += $sqldata->get_params(); + } + } catch (\Exception $exception ) { + matching_failed::create([ + 'other' => [ + 'ruleid' => $rule->get('id'), + 'error' => $exception->getMessage(), + ], + ])->trigger(); + + $rule->mark_broken(); + return []; + } + } + + if ($userid) { + $userparam = condition_sql::generate_param_alias(); + $where .= " AND u.id = :{$userparam} "; + $params += [$userparam => $userid]; + } + + try { + return $DB->get_records_sql($sql . $join . ' WHERE ' . $where, $params); + } catch (\Exception $exception) { + matching_failed::create([ + 'other' => [ + 'ruleid' => $rule->get('id'), + 'error' => $exception->getMessage(), + ], + ])->trigger(); + + $rule->mark_broken(); + + return []; + } + } + + /** + * Process a given rule. + * + * @param rule $rule A rule to process. + * @param int|null $userid Optional user ID for processing a rule just for a single user. + */ + public static function process_rule(rule $rule, ?int $userid = null): void { + global $DB; + + if (!$rule->is_enabled() || $rule->is_broken()) { + return; + } + + if ($rule->is_broken(true)) { + $rule->mark_broken(); + return; + } + + $cohortid = $rule->get('cohortid'); + + if (!$DB->record_exists('cohort', ['id' => $cohortid])) { + $rule->mark_broken(); + return; + } + + $users = self::get_matching_users($rule, $userid); + + $cohortmembersparams = ['cohortid' => $cohortid]; + + if (!empty($userid)) { + $cohortmembersparams['userid'] = $userid; + } + + $cohortmembers = $DB->get_records('cohort_members', $cohortmembersparams, '', 'userid'); + + $userstoadd = array_diff_key($users, $cohortmembers); + $userstodelete = array_diff_key($cohortmembers, $users); + + if ($rule->is_bulk_processing()) { + $timeadded = time(); + foreach (array_chunk($userstoadd, self::BULK_PROCESSING_SIZE) as $users) { + $records = []; + foreach ($users as $user) { + $record = new \stdClass(); + $record->userid = $user->id; + $record->cohortid = $cohortid; + $record->timeadded = $timeadded; + $records[] = $record; + } + $DB->insert_records('cohort_members', $records); + } + + foreach (array_chunk($userstodelete, self::BULK_PROCESSING_SIZE) as $users) { + $userids = array_column($users, 'userid'); + list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + $sql = "userid $insql AND cohortid = :cohort"; + $inparams['cohort'] = $cohortid; + $DB->delete_records_select('cohort_members', $sql, $inparams); + } + } else { + foreach ($userstoadd as $user) { + cohort_add_member($cohortid, $user->id); + } + + foreach ($userstodelete as $user) { + cohort_remove_member($cohortid, $user->userid); + } + } + } } diff --git a/classes/task/process_rule.php b/classes/task/process_rule.php new file mode 100644 index 0000000..3a1ed7a --- /dev/null +++ b/classes/task/process_rule.php @@ -0,0 +1,48 @@ +. + +namespace tool_dynamic_cohorts\task; + +use core\task\adhoc_task; +use tool_dynamic_cohorts\rule_manager; +use tool_dynamic_cohorts\rule; + +/** + * Processing a single rule. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_rule extends adhoc_task { + + /** + * Task execution + */ + public function execute() { + $ruleid = $this->get_custom_data(); + + try { + $rule = rule::get_record(['id' => $ruleid]); + } catch (\Exception $e) { + mtrace("Processing dynamic cohort rules: rule with ID {$ruleid} is not found."); + return; + } + + mtrace("Processing dynamic cohort rules: processing rule with id {$ruleid}"); + rule_manager::process_rule($rule); + } +} diff --git a/classes/task/process_rules.php b/classes/task/process_rules.php new file mode 100644 index 0000000..e35da15 --- /dev/null +++ b/classes/task/process_rules.php @@ -0,0 +1,53 @@ +. + +namespace tool_dynamic_cohorts\task; + +use core\task\manager; +use core\task\scheduled_task; +use tool_dynamic_cohorts\rule; + +/** + * Processing rules. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_rules extends scheduled_task { + + /** + * Task name. + */ + public function get_name() { + return get_string('processrulestask', 'tool_dynamic_cohorts'); + } + + /** + * Task execution. + */ + public function execute() { + $rules = rule::get_records(['enabled' => 1, 'broken' => 0], 'id'); + + foreach ($rules as $rule) { + $adhoctask = new process_rule(); + $adhoctask->set_custom_data($rule->get('id')); + $adhoctask->set_component('tool_dynamic_cohorts'); + + manager::queue_adhoc_task($adhoctask, true); + } + } +} diff --git a/db/tasks.php b/db/tasks.php new file mode 100644 index 0000000..ffa5de2 --- /dev/null +++ b/db/tasks.php @@ -0,0 +1,36 @@ +. + +/** + * Tasks description. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'tool_dynamic_cohorts\task\process_rules', + 'blocking' => 0, + 'minute' => '*', + 'hour' => '*', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + ], +]; diff --git a/lang/en/tool_dynamic_cohorts.php b/lang/en/tool_dynamic_cohorts.php index 054b455..0782ebf 100644 --- a/lang/en/tool_dynamic_cohorts.php +++ b/lang/en/tool_dynamic_cohorts.php @@ -37,6 +37,9 @@ $string['conditions'] = 'Conditions'; $string['conditionchnagesnotapplied'] = 'Condition changes are not applied until you save the rule form'; $string['conditionformtitle'] = 'Rule condition'; +$string['condition:cohort_membership'] = 'Cohort membership'; +$string['condition:cohort_membership_description'] = 'A user {$a->operator} {$a->cohorts}'; +$string['condition:cohort_membership_broken_description'] = 'Condition is broken. Using the same cohort that the given rule is configured to manage to.'; $string['condition_user_profile'] = 'User standard profile field'; $string['delete_confirm'] = 'Are you sure you want to delete rule {$a}?'; $string['delete_confirm_condition'] = 'Are you sure you want to delete this condition?'; @@ -52,15 +55,21 @@ $string['event:conditioncreated'] = 'Condition created'; $string['event:conditiondeleted'] = 'Condition deleted'; $string['event:conditionupdated'] = 'Condition updated'; +$string['event:matchingfailed'] = 'Matching users failed'; $string['event:rulecreated'] = 'Rule created'; $string['event:ruleupdated'] = 'Rule updated'; $string['event:ruledeleted'] = 'Rule deleted'; $string['invalidfieldvalue'] = 'Invalid field value'; +$string['ismemberof'] = 'is member of'; +$string['isnotmemberof'] = 'is not member of'; $string['isnotempty'] = 'is not empty'; $string['managerules'] = 'Manage rules'; $string['managecohorts'] = 'Manage cohorts'; $string['name'] = 'Rule name'; $string['name_help'] = 'A human readable name of this rule.'; +$string['operator'] = 'Operator'; +$string['or'] = 'OR'; +$string['pleaseselectcohort'] = 'Please select a cohort'; $string['pleaseselectfield'] = 'Please select a field'; $string['pluginname'] = 'Dynamic cohort rules'; $string['privacy:metadata:tool_dynamic_cohorts'] = 'Information about rules created or updated by a user'; @@ -69,6 +78,7 @@ $string['privacy:metadata:tool_dynamic_cohorts_c'] = 'Information about conditions created or updated by a user'; $string['privacy:metadata:tool_dynamic_cohorts_c:ruleid'] = 'ID of the rule'; $string['privacy:metadata:tool_dynamic_cohorts_c:usermodified'] = 'The ID of the user who created or updated a condition'; +$string['processrulestask'] = 'Process dynamic cohort rules'; $string['profilefield'] = 'Profile field'; $string['ruleisbroken'] = 'Rule is broken'; $string['ruledisabledpleasereview'] = 'Newly created or updated rules are disabled by default. Please review the rule below and enable it when ready.'; diff --git a/tests/local/tool_dynamic_cohorts/condition/cohort_membership_test.php b/tests/local/tool_dynamic_cohorts/condition/cohort_membership_test.php new file mode 100644 index 0000000..4fec834 --- /dev/null +++ b/tests/local/tool_dynamic_cohorts/condition/cohort_membership_test.php @@ -0,0 +1,310 @@ +. + +namespace tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition; + +use tool_dynamic_cohorts\condition_base; +use tool_dynamic_cohorts\rule; + +/** + * Unit tests for cohort membership condition class. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\cohort_membership + */ +class cohort_membership_test extends \advanced_testcase { + + /** + * Get condition instance for testing. + * + * @param array $configdata Config data to be set. + * @return condition_base + */ + protected function get_condition(array $configdata = []): condition_base { + $condition = condition_base::get_instance(0, (object)[ + 'classname' => '\tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\cohort_membership', + ]); + $condition->set_config_data($configdata); + + return $condition; + } + + /** + * Test class constants. + */ + public function test_constants() { + $this->assertSame('cohort_membership', cohort_membership::FIELD_NAME); + $this->assertSame(1, cohort_membership::OPERATOR_IS_MEMBER_OF); + $this->assertSame(2, cohort_membership::OPERATOR_IS_NOT_MEMBER_OF); + } + + /** + * Test retrieving of config data. + */ + public function test_retrieving_configdata() { + $formdata = (object)[ + 'id' => 1, + 'cohort_membership_operator' => 3, + 'cohort_membership_value' => 123, + 'ruleid' => 1, + 'sortorder' => 0, + ]; + + $actual = $this->get_condition()::retrieve_config_data($formdata); + $expected = [ + 'cohort_membership_operator' => 3, + 'cohort_membership_value' => 123, + ]; + $this->assertEquals($expected, $actual); + } + + /** + * Test setting and getting config data. + */ + public function test_set_and_get_configdata() { + $condition = $this->get_condition([ + 'cohort_membership_operator' => 3, + 'cohort_membership_value' => 123, + ]); + + $this->assertEquals( + ['cohort_membership_operator' => 3, 'cohort_membership_value' => 123], + $condition->get_config_data() + ); + } + + /** + * Test getting config description. + */ + public function test_config_description() { + $this->resetAfterTest(); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $this->assertSame('A user is member of ' . $cohort1->name, $condition->get_config_description()); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id], + ]); + $this->assertSame( + 'A user is not member of ' . $cohort1->name . ' OR ' . $cohort2->name, + $condition->get_config_description() + ); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id, 777], + ]); + $this->assertSame( + 'A user is member of ' . $cohort1->name . ' OR ' . $cohort2->name . ' OR ' . 777, + $condition->get_config_description() + ); + } + + /** + * Test getting rule. + */ + public function test_get_rule() { + $this->resetAfterTest(); + + // Rule is not set. + $condition = $this->get_condition(); + $this->assertNull($condition->get_rule()); + + // Create a rule and set it to an instance. + $rule = new rule(0, (object)['name' => 'Test rule 1']); + $rule->save(); + + $condition = cohort_membership::get_instance(0, (object)['ruleid' => $rule->get('id')]); + $this->assertEquals($condition->get_rule()->get('id'), $rule->get('id')); + } + + /** + * Test is broken. + */ + public function test_is_broken() { + $this->resetAfterTest(); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + $rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort1->id]); + $rule->save(); + + // Should be ok by default. + $condition = cohort_membership::get_instance(0, (object)['ruleid' => $rule->get('id')]); + $this->assertFalse($condition->is_broken()); + + // Existing cohort. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort2->id], + ]); + $this->assertFalse($condition->is_broken()); + + // Non existing cohort. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [777], + ]); + $this->assertTrue($condition->is_broken()); + + // Cohort is taken by a rule. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $this->assertTrue($condition->is_broken()); + + // One of the cohorts is taken by a rule. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id], + ]); + $this->assertTrue($condition->is_broken()); + } + + /** + * Test getting broken description. + */ + public function test_get_broken_description() { + $this->resetAfterTest(); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + $rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort1->id]); + $rule->save(); + + // Default broken description. + $condition = cohort_membership::get_instance(0, (object)['ruleid' => $rule->get('id')]); + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort2->id], + ]); + + $this->assertSame( + '{"cohort_membership_operator":1,"cohort_membership_value":["' . $cohort2->id . '"]}', + $condition->get_broken_description() + ); + + // Broken description if using the same cohort as the rule does. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id], + ]); + $this->assertSame( + get_string('condition:cohort_membership_broken_description', 'tool_dynamic_cohorts') + . '
' + . "A user is member of {$cohort1->name} OR {$cohort2->name}", + $condition->get_broken_description() + ); + } + + /** + * Test setting and getting config data. + */ + public function test_get_sql_data() { + global $DB; + + $this->resetAfterTest(); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + cohort_add_member($cohort1->id, $user1->id); + cohort_add_member($cohort1->id, $user2->id); + + $totalusers = $DB->count_records('user'); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // User 1 and user 2 as they are members of cohort 1. + $this->assertCount(2, $DB->get_records_sql($sql, $result->get_params())); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort2->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // Cohort is empty. + $this->assertCount(0, $DB->get_records_sql($sql, $result->get_params())); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // User 1 and user 2 as they are members of cohort 1. + $this->assertCount(2, $DB->get_records_sql($sql, $result->get_params())); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // Everyone except user 1 and user 2 as they are member of cohort 1. + $this->assertCount($totalusers - 2, $DB->get_records_sql($sql, $result->get_params())); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort2->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // Everyone as cohort is empty. + $this->assertCount($totalusers, $DB->get_records_sql($sql, $result->get_params())); + + $condition = $this->get_condition([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id, $cohort2->id], + ]); + $result = $condition->get_sql(); + $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}"; + // Everyone except user 1 and user 2 as they are member of cohort 1. + $this->assertCount($totalusers - 2, $DB->get_records_sql($sql, $result->get_params())); + } + + /** + * Test events that the condition is listening to. + */ + public function test_get_events() { + $this->assertEquals([ + '\core\event\cohort_member_added', + '\core\event\cohort_member_removed', + ], $this->get_condition()->get_events()); + } + +} diff --git a/tests/rule_manager_test.php b/tests/rule_manager_test.php index 9e35d3f..3a8c572 100644 --- a/tests/rule_manager_test.php +++ b/tests/rule_manager_test.php @@ -16,13 +16,17 @@ namespace tool_dynamic_cohorts; +use core\event\cohort_member_removed; +use core\event\cohort_member_added; use moodle_url; use moodle_exception; use tool_dynamic_cohorts\event\rule_created; use tool_dynamic_cohorts\event\rule_deleted; use tool_dynamic_cohorts\event\rule_updated; +use tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\cohort_membership; use tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\user_profile; + /** * Tests for rule manager class. * @@ -543,4 +547,279 @@ public function test_deleting_rule_triggers_event() { $this->assertCount(1, $events); $this->assertEquals($expectedruleid, reset($events)->other['ruleid']); } + + /** + * Basic test for get matching users to make sure it all works. + */ + public function test_get_matching_users() { + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(['username' => 'user1username']); + $user2 = $this->getDataGenerator()->create_user(['username' => 'user2username']); + $user3 = $this->getDataGenerator()->create_user(['username' => 'test']); + + $cohort = $this->getDataGenerator()->create_cohort(); + + $rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort->id]); + $rule->save(); + + $condition = user_profile::get_instance(0, (object)['ruleid' => $rule->get('id'), 'sortorder' => 1]); + $condition->set_config_data([ + 'profilefield' => 'username', + 'username_operator' => user_profile::TEXT_IS_EQUAL_TO, + 'username_value' => 'user1username', + ]); + $condition->get_record()->save(); + + $users = rule_manager::get_matching_users($rule); + + $this->assertArrayHasKey($user1->id, $users); + $this->assertArrayNotHasKey($user2->id, $users); + $this->assertArrayNotHasKey($user3->id, $users); + } + + /** + * Test rule processing. + */ + public function test_rule_processing() { + global $DB; + + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(['username' => 'user1']); + $user2 = $this->getDataGenerator()->create_user(['username' => 'user2']); + $user3 = $this->getDataGenerator()->create_user(['username' => 'test']); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + cohort_add_member($cohort1->id, $user1->id); + cohort_add_member($cohort1->id, $user2->id); + cohort_add_member($cohort1->id, $user3->id); + + $rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort2->id]); + $rule->save(); + + $condition = cohort_membership::get_instance(0, (object)['ruleid' => $rule->get('id'), 'sortorder' => 0]); + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $condition->get_record()->save(); + + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $eventsink = $this->redirectEvents(); + + // Rule disabled by default. + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(0, $events); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(0, $events); + + $eventsink->clear(); + + // Enable rule and get all users added to Cohort2. + $rule->set('enabled', 1); + $rule->save(); + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(0, $events); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(3, $events); + + $eventsink->clear(); + + // Now change the condition and let all users to be removed from Cohort2. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $condition->get_record()->save(); + + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(3, $events); + $eventsink->clear(); + + // Create a new user and check if we can process him individually. + $user4 = $this->getDataGenerator()->create_user(['username' => 'user4']); + rule_manager::process_rule($rule, $user4->id); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(1, $events); + $eventsink->clear(); + + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user4->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user4->id])); + } + + /** + * Test rule processing in bulk. + */ + public function test_rule_processing_with_bulk_processing_enabled() { + global $DB; + + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(['username' => 'user1']); + $user2 = $this->getDataGenerator()->create_user(['username' => 'user2']); + $user3 = $this->getDataGenerator()->create_user(['username' => 'test']); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + + cohort_add_member($cohort1->id, $user1->id); + cohort_add_member($cohort1->id, $user2->id); + cohort_add_member($cohort1->id, $user3->id); + + $rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort2->id, 'bulkprocessing' => 1]); + $rule->save(); + + $condition = cohort_membership::get_instance(0, (object)['ruleid' => $rule->get('id'), 'sortorder' => 0]); + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $condition->get_record()->save(); + + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $eventsink = $this->redirectEvents(); + + // Rule disabled by default. + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(0, $events); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(0, $events); + + $eventsink->clear(); + + $rule->set('enabled', 1); + $rule->save(); + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(0, $events); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(0, $events); + + $eventsink->clear(); + + // Now change the condition and let all users to be removed from Cohort2. + $condition->set_config_data([ + 'cohort_membership_operator' => cohort_membership::OPERATOR_IS_NOT_MEMBER_OF, + 'cohort_membership_value' => [$cohort1->id], + ]); + $condition->get_record()->save(); + + rule_manager::process_rule($rule); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_removed; + }); + $this->assertCount(0, $events); + $eventsink->clear(); + + // Create a new user and check if we can process him individually. + $user4 = $this->getDataGenerator()->create_user(['username' => 'user4']); + rule_manager::process_rule($rule, $user4->id); + + $events = array_filter($eventsink->get_events(), function ($event) { + return $event instanceof cohort_member_added; + }); + $this->assertCount(0, $events); + $eventsink->clear(); + + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user1->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user2->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort1->id, 'userid' => $user4->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user1->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user3->id])); + $this->assertTrue($DB->record_exists('cohort_members', ['cohortid' => $cohort2->id, 'userid' => $user4->id])); + } } diff --git a/version.php b/version.php index 2f6c2d6..8ed02d7 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tool_dynamic_cohorts'; -$plugin->release = 2024030600; -$plugin->version = 2024030600; +$plugin->release = 2024030700; +$plugin->version = 2024030700; $plugin->requires = 2022112800; $plugin->supported = [401, 403]; $plugin->maturity = MATURITY_ALPHA;