diff --git a/classes/condition_form.php b/classes/condition_form.php
index ad658a0..e9f7871 100644
--- a/classes/condition_form.php
+++ b/classes/condition_form.php
@@ -61,7 +61,7 @@ protected function get_condition(): condition_base {
throw new coding_exception('Condition class name is not set');
}
- $conditions = condition_manager::get_all_conditions();
+ $conditions = condition_manager::get_all_conditions(false);
if (!array_key_exists($this->_customdata['classname'], $conditions)) {
throw new moodle_exception('Condition is broken. Invalid condition class.');
}
diff --git a/classes/local/tool_dynamic_cohorts/condition/course_completed.php b/classes/local/tool_dynamic_cohorts/condition/course_completed.php
new file mode 100644
index 0000000..cfaee9f
--- /dev/null
+++ b/classes/local/tool_dynamic_cohorts/condition/course_completed.php
@@ -0,0 +1,244 @@
+.
+
+namespace tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition;
+
+use completion_info;
+use tool_dynamic_cohorts\condition_base;
+use tool_dynamic_cohorts\condition_sql;
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Condition based on course completion.
+ *
+ * @package tool_dynamic_cohorts
+ * @copyright 2024 Catalyst IT
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_completed extends condition_base {
+
+ /**
+ * Operator for any completion date.
+ */
+ public const OPERATOR_ANY = 1;
+
+ /**
+ * Operator for completion dates before some date.
+ */
+ public const OPERATOR_BEFORE = 2;
+
+ /*
+ * Operator for completion dates after some date.
+ */
+ public const OPERATOR_AFTER = 3;
+
+ /**
+ * Condition name.
+ *
+ * @return string
+ */
+ public function get_name(): string {
+ return get_string('condition:course_completed', 'tool_dynamic_cohorts');
+ }
+
+ /**
+ * Gets a list of operators.
+ *
+ * @return array A list of operators.
+ */
+ protected function get_operators(): array {
+ return [
+ self::OPERATOR_ANY => get_string('any', 'tool_dynamic_cohorts'),
+ self::OPERATOR_BEFORE => get_string('before', 'tool_dynamic_cohorts'),
+ self::OPERATOR_AFTER => get_string('after', 'tool_dynamic_cohorts'),
+ ];
+ }
+
+ /**
+ * Add config form elements.
+ *
+ * @param \MoodleQuickForm $mform
+ */
+ public function config_form_add(\MoodleQuickForm $mform): void {
+ $mform->addElement('course', 'courseid', get_string('course'), ['onlywithcompletion' => true]);
+ $mform->addRule('courseid', null, 'required', null, 'client');
+ $mform->setType('courseid', PARAM_INT);
+
+ $mform->addElement(
+ 'select',
+ 'operator',
+ get_string('completiondate', 'tool_dynamic_cohorts'),
+ $this->get_operators()
+ );
+
+ $mform->addElement('date_time_selector', 'timecompleted');
+ $mform->hideIf('timecompleted', 'operator', 'eq', self::OPERATOR_ANY);
+ $mform->setDefault('timecompleted', usergetmidnight(time()));
+ }
+
+ /**
+ * Validate config form elements.
+ *
+ * @param array $data Data to validate.
+ * @return array
+ */
+ public function config_form_validate(array $data): array {
+ $errors = [];
+
+ if (!isset($data['courseid'])) {
+ $errors['courseid'] = get_string('required');
+ return $errors;
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Gets configured course ID.
+ *
+ * @return int
+ */
+ protected function get_courseid_value(): int {
+ return $this->get_config_data()['courseid'] ?? 0;
+ }
+
+ /**
+ * Gets operator value.
+ *
+ * @return int
+ */
+ protected function get_operator_value(): int {
+ return $this->get_config_data()['operator'] ?? self::OPERATOR_ANY;
+ }
+
+ /**
+ * Gets configured completion time.
+ *
+ * @return int
+ */
+ protected function get_timecompleted_value(): int {
+ return $this->get_config_data()['timecompleted'] ?? 0;
+ }
+
+ /**
+ * Human-readable description of the configured condition.
+ *
+ * @return string
+ */
+ public function get_config_description(): string {
+ global $DB;
+
+ $coursename = $DB->get_field('course', 'fullname', ['id' => $this->get_courseid_value()]);
+ $coursename = format_string($coursename, true, ['context' => \context_system::instance(), 'escape' => false]);
+
+ $operatorvalue = $this->get_operator_value();
+
+ $operator = $operatorvalue != self::OPERATOR_ANY ? strtolower($this->get_operators()[$operatorvalue]) : '';
+ $timecompleted = $operatorvalue != self::OPERATOR_ANY ? userdate($this->get_timecompleted_value()) : '';
+
+ return get_string('condition:course_completed_description', 'tool_dynamic_cohorts', (object)[
+ 'course' => $coursename,
+ 'operator' => $operator,
+ 'timecompleted' => $timecompleted,
+ ]);
+ }
+
+ /**
+ * Human readable description of the broken condition.
+ *
+ * @return string
+ */
+ public function get_broken_description(): string {
+ global $DB;
+
+ // Check course exists.
+ if (!$course = $DB->get_record('course', ['id' => $this->get_courseid_value()])) {
+ return get_string('missingcourse', 'tool_dynamic_cohorts');
+ }
+
+ // Check completion is enabled for a course.
+ $completion = new completion_info($course);
+ if (!$completion->is_enabled()) {
+ return get_string('completionisdisabled', 'tool_dynamic_cohorts');
+ }
+
+ return parent::get_broken_description();
+ }
+
+ /**
+ * Gets SQL data for building SQL.
+ *
+ * @return condition_sql
+ */
+ public function get_sql(): condition_sql {
+ $sql = new condition_sql('', '1=0', []);
+
+ if (!$this->is_broken()) {
+ $params = [];
+
+ $completiontable = condition_sql::generate_table_alias();
+ $join = "JOIN {course_completions} $completiontable ON ($completiontable.userid = u.id)";
+
+ $courseid = $this->get_courseid_value();
+ $courseidparam = condition_sql::generate_param_alias();
+ $params[$courseidparam] = $courseid;
+
+ $timecompleted = $this->get_timecompleted_value();
+ $timecompletedparam = condition_sql::generate_param_alias();
+
+ $operator = $this->get_operator_value();
+
+ if ($operator != self::OPERATOR_ANY && $timecompleted > 0) {
+ $operator = $operator == self::OPERATOR_BEFORE ? '<' : '>';
+ $where = "$completiontable.course = :$courseidparam
+ AND $completiontable.timecompleted $operator :$timecompletedparam";
+ $params[$timecompletedparam] = $timecompleted;
+ } else {
+ $where = "$completiontable.course = :$courseidparam
+ AND $completiontable.timecompleted IS NOT NULL";
+ }
+
+ $sql = new condition_sql($join, $where, $params);
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Is condition broken.
+ *
+ * @return bool
+ */
+ public function is_broken(): bool {
+ global $DB;
+
+ // Check course exists.
+ if (!$course = $DB->get_record('course', ['id' => $this->get_courseid_value()])) {
+ return true;
+ }
+
+ // Check completion is enabled for a course.
+ $completion = new completion_info($course);
+ if (!$completion->is_enabled()) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/lang/en/tool_dynamic_cohorts.php b/lang/en/tool_dynamic_cohorts.php
index 966e888..422090c 100644
--- a/lang/en/tool_dynamic_cohorts.php
+++ b/lang/en/tool_dynamic_cohorts.php
@@ -28,7 +28,10 @@
$string['addcondition'] = 'Add a condition';
$string['addrule'] = 'Add a new rule';
$string['add_rule'] = 'Add new rule';
+$string['after'] = 'After';
+$string['any'] = 'Any';
$string['backtolistofrules'] = 'Back to the list of rules';
+$string['before'] = 'Before';
$string['brokenruleswarning'] = 'There are some broken rules require your attention.
To fix a broken rule you should remove all broken conditions.
Sometimes a rule becomes broken when matching users SQL failed. In this case all condition are ok, but the rule is marked as broken. You should check Moodle logs for "Matching users failed" event and related SQL errors.
Please note, that in any case you have to re-save the rule to mark it as unbroken.';
$string['bulkprocessing'] = 'Bulk processing';
$string['bulkprocessing_help'] = 'If this option is enabled, users will be added and removed from cohort in bulk. This will significantly improve processing performance. However, using this option will suppress triggering events when users added or removed from cohort.';
@@ -38,6 +41,8 @@
$string['cohortid'] = 'Cohort';
$string['cohortswith'] = 'Cohort(s) with field';
$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['completiondate'] = 'Completion date';
+$string['completionisdisabled'] = 'Completion is disabled for configured course';
$string['condition'] = 'Condition';
$string['conditions'] = 'Conditions';
$string['conditionstext'] = '{$a->conditions} ( logical {$a->operator} )';
@@ -50,6 +55,8 @@
$string['condition:cohort_membership_broken_description'] = 'Condition is broken. Using the same cohort that the given rule is configured to manage to.';
$string['condition:cohort_field'] = 'Cohort field';
$string['condition:cohort_field_description'] = 'A user {$a->operator} cohorts with field \'{$a->field}\' {$a->fieldoperator} {$a->fieldvalue}';
+$string['condition:course_completed'] = 'Course completed';
+$string['condition:course_completed_description'] = 'A user has completed course "{$a->course}" {$a->operator} {$a->timecompleted}';
$string['condition:profile_field_description'] = '{$a->field} {$a->fieldoperator} {$a->fieldvalue}';
$string['condition:user_profile'] = 'User standard profile field';
$string['condition:user_custom_profile'] = 'User custom profile field';
@@ -89,6 +96,7 @@
$string['managerules'] = 'Manage rules';
$string['managecohorts'] = 'Manage cohorts';
$string['matchingusers'] = 'Matching users';
+$string['missingcourse'] = 'Missing course';
$string['name'] = 'Rule name';
$string['name_help'] = 'A human readable name of this rule.';
$string['operator'] = 'Operator';
diff --git a/tests/local/tool_dynamic_cohorts/condition/course_completed_test.php b/tests/local/tool_dynamic_cohorts/condition/course_completed_test.php
new file mode 100644
index 0000000..fd7279d
--- /dev/null
+++ b/tests/local/tool_dynamic_cohorts/condition/course_completed_test.php
@@ -0,0 +1,261 @@
+.
+
+namespace tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition;
+
+use tool_dynamic_cohorts\condition_base;
+use tool_dynamic_cohorts\rule;
+
+/**
+ * Unit tests for course_completed 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\course_completed
+ */
+class course_completed_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\course_completed',
+ ]);
+ $condition->set_config_data($configdata);
+
+ return $condition;
+ }
+
+ /**
+ * Test retrieving of config data.
+ */
+ public function test_retrieving_configdata() {
+ $formdata = (object)[
+ 'courseid' => 1,
+ 'operator' => 3,
+ 'timecompleted' => 777777,
+ 'ruleid' => 1,
+ 'sortorder' => 0,
+ ];
+
+ $actual = $this->get_condition()::retrieve_config_data($formdata);
+ $expected = [
+ 'courseid' => 1,
+ 'operator' => 3,
+ 'timecompleted' => 777777,
+ ];
+ $this->assertEquals($expected, $actual);
+ }
+
+ /**
+ * Test setting and getting config data.
+ */
+ public function test_set_and_get_configdata() {
+ $condition = $this->get_condition([
+ 'courseid' => 1,
+ 'operator' => 3,
+ 'timecompleted' => 777777,
+ ]);
+
+ $this->assertEquals(
+ [
+ 'courseid' => 1,
+ 'operator' => 3,
+ 'timecompleted' => 777777,
+ ],
+ $condition->get_config_data()
+ );
+ }
+
+ /**
+ * Test getting config description.
+ */
+ public function test_config_description() {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $now = time();
+
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_AFTER,
+ 'timecompleted' => $now,
+ ]);
+
+ $this->assertSame(
+ 'A user has completed course "' . $course->fullname . '" after ' . userdate($now),
+ $condition->get_config_description(),
+ );
+
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_BEFORE,
+ 'timecompleted' => $now,
+ ]);
+
+ $this->assertSame(
+ 'A user has completed course "' . $course->fullname . '" before ' . userdate($now),
+ $condition->get_config_description(),
+ );
+
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_ANY,
+ 'timecompleted' => $now,
+ ]);
+
+ $this->assertSame(
+ 'A user has completed course "' . $course->fullname . '" ',
+ $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_and_broken_description() {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+
+ // Invalid course.
+ $condition = $this->get_condition([
+ 'courseid' => 7777,
+ 'operator' => course_completed::OPERATOR_ANY,
+ 'timecompleted' => 0,
+ ]);
+ $this->assertTrue($condition->is_broken());
+ $this->assertSame('Missing course', $condition->get_broken_description());
+
+ // Completion is disabled.
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_ANY,
+ 'timecompleted' => 0,
+ ]);
+ $this->assertTrue($condition->is_broken());
+ $this->assertSame('Completion is disabled for configured course', $condition->get_broken_description());
+
+ // Completion is enabled.
+ $DB->set_field('course', 'enablecompletion', 1, ['id' => $course->id]);
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_ANY,
+ 'timecompleted' => 0,
+ ]);
+ $this->assertFalse($condition->is_broken());
+ }
+
+ /**
+ * Test getting correct SQL.
+ */
+ public function test_get_sql_data() {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $now = time();
+
+ $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+ $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+
+ $user1 = $this->getDataGenerator()->create_user();
+ $user2 = $this->getDataGenerator()->create_user();
+
+ $this->getDataGenerator()->enrol_user($user1->id, $course->id, $studentrole->id);
+ $this->getDataGenerator()->enrol_user($user2->id, $course->id, $studentrole->id);
+
+ $completionuser1 = new \completion_completion(['userid' => $user1->id, 'course' => $course->id]);
+ $completionuser1->mark_complete($now + WEEKSECS);
+
+ $completionuser2 = new \completion_completion(['userid' => $user2->id, 'course' => $course->id]);
+ $completionuser2->mark_complete($now - WEEKSECS);
+
+ $totalusers = $DB->count_records('user');
+ $this->assertTrue($totalusers > 2);
+
+ // Any completion. Should get user 1 and user 2.
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_ANY,
+ 'timecompleted' => 0,
+ ]);
+
+ $result = $condition->get_sql();
+ $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}";
+ $this->assertCount(2, $DB->get_records_sql($sql, $result->get_params()));
+
+ // Before. Should get user 2.
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_BEFORE,
+ 'timecompleted' => $now,
+ ]);
+
+ $result = $condition->get_sql();
+ $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}";
+ $actual = $DB->get_records_sql($sql, $result->get_params());
+ $this->assertCount(1, $actual);
+ $this->assertSame($user2->id, reset($actual)->id);
+
+ // After. Should get user 1.
+ $condition = $this->get_condition([
+ 'courseid' => $course->id,
+ 'operator' => course_completed::OPERATOR_AFTER,
+ 'timecompleted' => $now,
+ ]);
+
+ $result = $condition->get_sql();
+ $sql = "SELECT u.id FROM {user} u {$result->get_join()} WHERE {$result->get_where()}";
+ $actual = $DB->get_records_sql($sql, $result->get_params());
+ $this->assertCount(1, $actual);
+ $this->assertSame($user1->id, reset($actual)->id);
+ }
+
+ /**
+ * Test events that the condition is listening to.
+ */
+ public function test_get_events() {
+ $this->assertEquals([], $this->get_condition()->get_events());
+ }
+
+}
diff --git a/version.php b/version.php
index d6388c0..027b861 100644
--- a/version.php
+++ b/version.php
@@ -25,8 +25,8 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'tool_dynamic_cohorts';
-$plugin->release = 2024040200;
-$plugin->version = 2024040200;
+$plugin->release = 2024040300;
+$plugin->version = 2024040300;
$plugin->requires = 2022112800;
$plugin->supported = [401, 403];
$plugin->maturity = MATURITY_STABLE;