Skip to content

Commit

Permalink
Merge pull request #58 from catalyst/issue52
Browse files Browse the repository at this point in the history
issue #52: implement a logical operator for rules
  • Loading branch information
dmitriim authored Mar 25, 2024
2 parents 01630a9 + 9dfe458 commit 292cdff
Show file tree
Hide file tree
Showing 13 changed files with 247 additions and 35 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ Conditions are simple predicates which assert something about a user in the syst

## Rules

Rules are what determine if a user will be added or removed from a cohort. A rule is defined by two things:
Rules are what determine if a user will be added or removed from a cohort. A rule is defined by few things:

1. A cohort
2. A set of conditions
3. A logical operator to be applied for conditions (OR/AND)

For a user to be added to the cohort specified by a rule, they must match all of the rule's conditions.
For users to be added to the cohort specified by a rule, they must match all of rule's conditions (logical operator AND) or any of rule's conditions (logical operator OR).

**NB:** A cohort can be managed by _one and only one_ rule. This is to prevent rules competing over users in a cohort (e.g., to avoid situations where Rule A wants users a, b, c to be in a cohort, but Rule B wants to remove user c from the same cohort).

Expand Down
17 changes: 12 additions & 5 deletions classes/condition_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,14 @@ public static function get_conditions_with_event(base $event): array {
* A helper function to build sql data for given list of conditions.
*
* @param \tool_dynamic_cohorts\condition[] $conditions A list of conditions.
* @param int|null $userid
* @param string $operator Logical operator.
* @param int|null $userid Optional user id in case we need to build SQL for one user. For example when event is triggered.
*
* @return \tool_dynamic_cohorts\condition_sql
*/
public static function build_sql_data(array $conditions, ?int $userid = null): condition_sql {
$where = ' u.deleted = 0 ';
public static function build_sql_data(array $conditions, string $operator = rule_manager::CONDITIONS_OPERATOR_AND,
?int $userid = null): condition_sql {
$where = '';
$join = '';
$params = [];

Expand All @@ -239,7 +241,10 @@ public static function build_sql_data(array $conditions, ?int $userid = null): c
}

if (!empty($sqldata->get_where())) {
$where .= ' AND (' . $sqldata->get_where() . ')';
if (!empty($where)) {
$where .= ' ' . rule_manager::get_logical_operator_text($operator);
}
$where .= ' (' . $sqldata->get_where() . ')';
}

if (!empty($sqldata->get_params())) {
Expand All @@ -249,10 +254,12 @@ public static function build_sql_data(array $conditions, ?int $userid = null): c

if ($userid) {
$userparam = condition_sql::generate_param_alias();
$where .= " AND u.id = :{$userparam} ";
$where .= " AND ( u.id = :{$userparam}) ";
$params += [$userparam => $userid];
}

$where .= ' AND ( u.deleted = 0) ';

return new condition_sql($join, $where, $params);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected function initialise(): void {
$this->add_base_condition_sql(' true = false');
}

$sql = condition_manager::build_sql_data($conditions);
$sql = condition_manager::build_sql_data($conditions, $rule->get('operator'));

$this->add_base_fields('DISTINCT u.id');
$this->add_join($sql->get_join());
Expand Down
9 changes: 6 additions & 3 deletions classes/reportbuilder/local/systemreports/rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use tool_dynamic_cohorts\rule;
use pix_icon;
use html_writer;
use tool_dynamic_cohorts\rule_manager;

/**
* Rules admin table.
Expand Down Expand Up @@ -88,7 +89,7 @@ protected function initialise(): void {
))
->set_type(column::TYPE_TEXT)
->set_is_sortable(false)
->add_fields("{$rulealias}.id")
->add_fields("{$rulealias}.id, {$rulealias}.operator")
->add_callback(static function($id, $row): string {
$rule = new rule(0, $row);
$conditions = count($rule->get_condition_records());
Expand All @@ -99,8 +100,10 @@ protected function initialise(): void {
'data-ruleid' => $rule->get('id'),
]);
}

return $conditions;
return get_string('conditionstext', 'tool_dynamic_cohorts', (object)[
'conditions' => $conditions,
'operator' => rule_manager::get_logical_operator_text($rule->get('operator')),
]);
});

$this->add_column_from_entity('rule_entity:status');
Expand Down
4 changes: 4 additions & 0 deletions classes/rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ protected static function define_properties() {
'type' => PARAM_INT,
'default' => 0,
],
'operator' => [
'type' => PARAM_INT,
'default' => 0,
],
];
}

Expand Down
7 changes: 7 additions & 0 deletions classes/rule_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ protected function definition() {
);
$mform->addHelpButton('bulkprocessing', 'bulkprocessing', 'tool_dynamic_cohorts');

$mform->addElement('select',
'operator',
get_string('logical_operator', 'tool_dynamic_cohorts'),
[rule_manager::CONDITIONS_OPERATOR_AND => 'AND', rule_manager::CONDITIONS_OPERATOR_OR => 'OR']);
$mform->addHelpButton('operator', 'logical_operator', 'tool_dynamic_cohorts');
$mform->setType('operator', PARAM_INT);

$this->add_action_buttons();
}

Expand Down
25 changes: 23 additions & 2 deletions classes/rule_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ class rule_manager {
*/
const BULK_PROCESSING_SIZE = 10000;

/**
* Conditions logical operator AND.
*/
const CONDITIONS_OPERATOR_AND = 0;

/**
* Conditions logical operator OR.
*/
const CONDITIONS_OPERATOR_OR = 1;

/**
* Builds rule edit URL.
*
Expand Down Expand Up @@ -121,6 +131,7 @@ public static function process_form(\stdClass $formdata): rule {
'cohortid' => $formdata->cohortid,
'description' => $formdata->description,
'bulkprocessing' => $formdata->bulkprocessing,
'operator' => $formdata->operator,
];

$oldcohortid = 0;
Expand Down Expand Up @@ -223,7 +234,7 @@ public static function get_matching_users(rule $rule, ?int $userid = null): arra
$sql = "SELECT DISTINCT u.id FROM {user} u";

try {
$sqldata = condition_manager::build_sql_data($conditions, $userid);
$sqldata = condition_manager::build_sql_data($conditions, $rule->get('operator'), $userid);
} catch (\Exception $exception ) {
self::trigger_matching_failed_event($rule, $exception->getMessage());
$rule->mark_broken();
Expand Down Expand Up @@ -261,7 +272,7 @@ public static function get_matching_users_count(rule $rule, ?int $userid = null)
$sql = "SELECT COUNT(DISTINCT u.id) cnt FROM {user} u";

try {
$sqldata = condition_manager::build_sql_data($conditions, $userid);
$sqldata = condition_manager::build_sql_data($conditions, $rule->get('operator'), $userid);
} catch (\Exception $exception ) {
self::trigger_matching_failed_event($rule, $exception->getMessage());
$rule->mark_broken();
Expand Down Expand Up @@ -400,4 +411,14 @@ public static function get_rules_with_condition(condition_base $condition): arra

return $rules;
}

/**
* Returns logical operator text based on value.
*
* @param int $operator Operator value.
* @return string
*/
public static function get_logical_operator_text(int $operator): string {
return $operator == self::CONDITIONS_OPERATOR_AND ? 'AND' : 'OR';
}
}
1 change: 1 addition & 0 deletions db/install.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Is the rule enabled"/>
<FIELD NAME="bulkprocessing" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Should the rule be processed in bulk"/>
<FIELD NAME="broken" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Is this rule broken?"/>
<FIELD NAME="operator" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Logical operator (OR/AND) for all conditions in the rule"/>
<FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
Expand Down
48 changes: 48 additions & 0 deletions db/upgrade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.

/**
* Upgrade hook.
*
* @package tool_dynamic_cohorts
* @copyright 2024 Catalyst IT
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

/**
* Upgrade the plugin.
*
* @param int $oldversion The old version of the plugin
* @return bool
*/
function xmldb_tool_dynamic_cohorts_upgrade($oldversion): bool {
global $DB;

$dbman = $DB->get_manager();

if ($oldversion < 2024032501) {
$table = new xmldb_table('tool_dynamic_cohorts');
$field = new xmldb_field('operator', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, 0);

if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}

upgrade_plugin_savepoint(true, 2024032501, 'tool', 'dynamic_cohorts');
}

return true;
}
3 changes: 3 additions & 0 deletions lang/en/tool_dynamic_cohorts.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
$string['cohortid_help'] = 'A cohort to manage as part of this rule. Only cohorts that are not managed by other plugins are displayed in this list.';
$string['condition'] = 'Condition';
$string['conditions'] = 'Conditions';
$string['conditionstext'] = '{$a->conditions} ( logical {$a->operator} )';
$string['conditionsformtitle'] = 'Rule conditions';
$string['conditionchnagesnotapplied'] = 'Condition changes are not applied until you save the rule form';
$string['conditionformtitle'] = 'Rule condition';
Expand Down Expand Up @@ -81,6 +82,8 @@
$string['ismemberof'] = 'is member of';
$string['isnotmemberof'] = 'is not member of';
$string['isnotempty'] = 'is not empty';
$string['logical_operator'] = 'Logical operator';
$string['logical_operator_help'] = 'A logical operator to be applied to conditions for this rule. Operator "AND" means a user has to match all conditions to be added to a cohort. "OR" means a user has to match any of conditions to be added to a cohort.';
$string['managerules'] = 'Manage rules';
$string['managecohorts'] = 'Manage cohorts';
$string['matchingusers'] = 'Matching users';
Expand Down
65 changes: 65 additions & 0 deletions tests/condition_manager_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use tool_dynamic_cohorts\event\condition_created;
use tool_dynamic_cohorts\event\condition_deleted;
use tool_dynamic_cohorts\event\condition_updated;
use tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\user_profile;

/**
* Tests for condition manager class.
Expand Down Expand Up @@ -210,4 +211,68 @@ public function test_get_conditions_with_event() {
$this->assertArrayHasKey('tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\user_custom_profile', $conditions);
$this->assertArrayHasKey('tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\user_profile', $conditions);
}

/**
* Basic test of building SQL data.
*/
public function test_build_sql_data() {
$this->resetAfterTest();

$this->getDataGenerator()->create_user(['username' => 'user1username']);
$this->getDataGenerator()->create_user(['username' => 'user2username']);
$this->getDataGenerator()->create_user(['username' => 'test']);

$cohort = $this->getDataGenerator()->create_cohort();

$rule = new rule(0, (object)['name' => 'Test rule 1', 'cohortid' => $cohort->id,
'operator' => rule_manager::CONDITIONS_OPERATOR_OR]);
$rule->save();

$conditions = [];

$condition = user_profile::get_instance(0, (object)['ruleid' => $rule->get('id'), 'sortorder' => 1]);
$condition->set_config_data([
'profilefield' => 'username',
'username_operator' => condition_base::TEXT_IS_EQUAL_TO,
'username_value' => 'user1username',
]);
$condition->get_record()->save();
$conditions[] = $condition->get_record();

$condition = user_profile::get_instance(0, (object)['ruleid' => $rule->get('id'), 'sortorder' => 1]);
$condition->set_config_data([
'profilefield' => 'username',
'username_operator' => condition_base::TEXT_IS_EQUAL_TO,
'username_value' => 'user2username',
]);
$condition->get_record()->save();
$conditions[] = $condition->get_record();

$sql = condition_manager::build_sql_data($conditions);
$this->assertEquals('', $sql->get_join());
$this->assertStringNotContainsString('OR', $sql->get_where());
$this->assertStringContainsString('AND ( u.deleted = 0)', $sql->get_where());
$this->assertTrue(in_array('user1username', $sql->get_params()));
$this->assertTrue(in_array('user2username', $sql->get_params()));
$this->assertStringNotContainsString('AND ( u.id = ', $sql->get_where());
$this->assertFalse(in_array(777, $sql->get_params()));

$sql = condition_manager::build_sql_data($conditions, rule_manager::CONDITIONS_OPERATOR_OR);
$this->assertEquals('', $sql->get_join());
$this->assertStringContainsString('OR', $sql->get_where());
$this->assertStringContainsString('AND ( u.deleted = 0)', $sql->get_where());
$this->assertTrue(in_array('user1username', $sql->get_params()));
$this->assertTrue(in_array('user2username', $sql->get_params()));
$this->assertStringNotContainsString('AND ( u.id = ', $sql->get_where());
$this->assertFalse(in_array(777, $sql->get_params()));

$sql = condition_manager::build_sql_data($conditions, rule_manager::CONDITIONS_OPERATOR_OR, 777);
$this->assertEquals('', $sql->get_join());
$this->assertStringContainsString('OR', $sql->get_where());
$this->assertStringContainsString('AND ( u.deleted = 0)', $sql->get_where());
$this->assertStringContainsString('AND ( u.id = ', $sql->get_where());
$this->assertTrue(in_array('user1username', $sql->get_params()));
$this->assertTrue(in_array('user2username', $sql->get_params()));
$this->assertTrue(in_array(777, $sql->get_params()));
}
}
Loading

0 comments on commit 292cdff

Please sign in to comment.