From 85ce3a45821ea443fcea3ca961ac20ee5da2658f Mon Sep 17 00:00:00 2001 From: Dmitrii Metelkin Date: Wed, 6 Mar 2024 08:38:47 +1100 Subject: [PATCH] issue #5: add CRUD for rules --- classes/cohort_manager.php | 88 +++++ .../local/entities/rule_entity.php | 152 ++++++++ .../local/systemreports/rules.php | 151 ++++++++ classes/rule_form.php | 111 ++++++ classes/rule_manager.php | 160 ++++++++ db/access.php | 1 - delete.php | 55 +++ edit.php | 73 ++++ index.php | 43 +++ lang/en/tool_dynamic_cohorts.php | 31 ++ settings.php | 34 ++ templates/addbutton.mustache | 39 ++ tests/cohort_manager_test.php | 90 +++++ tests/rule_manager_test.php | 349 ++++++++++++++++++ toggle.php | 72 ++++ 15 files changed, 1448 insertions(+), 1 deletion(-) create mode 100644 classes/cohort_manager.php create mode 100644 classes/reportbuilder/local/entities/rule_entity.php create mode 100644 classes/reportbuilder/local/systemreports/rules.php create mode 100644 classes/rule_form.php create mode 100644 classes/rule_manager.php create mode 100644 delete.php create mode 100644 edit.php create mode 100644 index.php create mode 100644 settings.php create mode 100644 templates/addbutton.mustache create mode 100644 tests/cohort_manager_test.php create mode 100644 tests/rule_manager_test.php create mode 100644 toggle.php diff --git a/classes/cohort_manager.php b/classes/cohort_manager.php new file mode 100644 index 0000000..5c06aa0 --- /dev/null +++ b/classes/cohort_manager.php @@ -0,0 +1,88 @@ +. + +namespace tool_dynamic_cohorts; + +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); +require_once($CFG->dirroot.'/cohort/lib.php'); + +/** + * Cohort manager class. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohort_manager { + + /** + * Cohort component. + */ + const COHORT_COMPONENT = 'tool_dynamic_cohorts'; + + /** + * Get a list of all cohort names in the system keyed by cohort ID. + * + * @param bool $excludemanaged Exclude cohorts managed by us. + * @return array + */ + public static function get_cohorts(bool $excludemanaged = false): array { + $cohorts = []; + foreach (\cohort_get_all_cohorts(0, 0)['cohorts'] as $cohort) { + if (empty($cohort->component) || (!$excludemanaged && $cohort->component === self::COHORT_COMPONENT)) { + $cohorts[$cohort->id] = $cohort; + } + } + + return $cohorts; + } + + /** + * Set cohort to be managed by tool_dynamic_cohorts. + * + * @param int $cohortid Cohort ID. + */ + public static function manage_cohort(int $cohortid): void { + $cohorts = self::get_cohorts(); + if (!empty($cohorts[$cohortid])) { + $cohort = $cohorts[$cohortid]; + + if ($cohort->component === self::COHORT_COMPONENT) { + throw new moodle_exception('Cohort ' . $cohortid . ' is already managed by tool_dynamic_cohorts'); + } + + $cohort->component = 'tool_dynamic_cohorts'; + cohort_update_cohort($cohort); + } + } + + /** + * Unset cohort from being managed by tool_dynamic_cohorts. + * + * @param int $cohortid Cohort ID. + */ + public static function unmanage_cohort(int $cohortid): void { + $cohorts = self::get_cohorts(); + + if (!empty($cohorts[$cohortid])) { + $cohort = $cohorts[$cohortid]; + $cohort->component = ''; + cohort_update_cohort($cohort); + } + } +} diff --git a/classes/reportbuilder/local/entities/rule_entity.php b/classes/reportbuilder/local/entities/rule_entity.php new file mode 100644 index 0000000..9cf60ef --- /dev/null +++ b/classes/reportbuilder/local/entities/rule_entity.php @@ -0,0 +1,152 @@ +. + +namespace tool_dynamic_cohorts\reportbuilder\local\entities; + +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\report\column; +use lang_string; +use tool_dynamic_cohorts\rule; + +/** + * Report builder entity for rules. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class rule_entity extends base { + + /** + * Returns the default table aliases. + * @return array + */ + protected function get_default_table_aliases(): array { + return [ + 'tool_dynamic_cohorts' => 'tdc', + ]; + } + + /** + * Returns the default table name. + * @return \lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('rule_entity', 'tool_dynamic_cohorts'); + } + + /** + * Initialises the entity. + * @return \core_reportbuilder\local\entities\base + */ + public function initialise(): base { + foreach ($this->get_all_columns() as $column) { + $this->add_column($column); + } + + return $this; + } + + /** + * Returns list of available columns. + * + * @return column[] + */ + protected function get_all_columns(): array { + $alias = $this->get_table_alias('tool_dynamic_cohorts'); + + $columns[] = (new column( + 'id', + new lang_string('rule_entity.id', 'tool_dynamic_cohorts'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_field("{$alias}.id") + ->set_is_sortable(true); + + $columns[] = (new column( + 'name', + new lang_string('rule_entity.name', 'tool_dynamic_cohorts'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$alias}.name") + ->add_field("{$alias}.id") + ->set_is_sortable(true) + ->add_callback(function ($value, $row) { + return $value; + }); + + $columns[] = (new column( + 'description', + new lang_string('rule_entity.description', 'tool_dynamic_cohorts'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$alias}.description") + ->set_is_sortable(false); + + $columns[] = (new column( + 'bulkprocessing', + new lang_string('rule_entity.bulkprocessing', 'tool_dynamic_cohorts'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$alias}.bulkprocessing") + ->add_fields("{$alias}.id, {$alias}.name, {$alias}.bulkprocessing") + ->set_is_sortable(true) + ->add_callback(function ($value, $row) { + $rule = new rule(0, $row); + return !empty($rule->is_bulk_processing()) ? get_string('yes') : get_string('no'); + }); + + $columns[] = (new column( + 'status', + new lang_string('rule_entity.status', 'tool_dynamic_cohorts'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$alias}.broken") + ->add_fields("{$alias}.id, {$alias}.name, {$alias}.broken, {$alias}.enabled") + ->set_is_sortable(true) + ->add_callback(function ($value, $row) { + global $OUTPUT; + + $rule = new rule(0, $row); + + if ($rule->is_enabled()) { + $enabled = $OUTPUT->pix_icon('t/hide', get_string('enabled', 'tool_dynamic_cohorts')); + } else { + $enabled = $OUTPUT->pix_icon('t/show', get_string('disabled', 'tool_dynamic_cohorts')); + } + + if ($rule->is_broken()) { + $broken = $OUTPUT->pix_icon('i/invalid', get_string('statuserror')); + } else { + $broken = $OUTPUT->pix_icon('i/valid', get_string('ok')); + } + + return $broken . $enabled; + }); + + return $columns; + } +} diff --git a/classes/reportbuilder/local/systemreports/rules.php b/classes/reportbuilder/local/systemreports/rules.php new file mode 100644 index 0000000..71fa22e --- /dev/null +++ b/classes/reportbuilder/local/systemreports/rules.php @@ -0,0 +1,151 @@ +. + +namespace tool_dynamic_cohorts\reportbuilder\local\systemreports; + +use context; +use context_system; +use core_cohort\reportbuilder\local\entities\cohort; +use core_reportbuilder\local\report\action; +use core_reportbuilder\system_report; +use tool_dynamic_cohorts\reportbuilder\local\entities\rule_entity; +use lang_string; +use tool_dynamic_cohorts\rule; + +/** + * Rules admin table. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class rules extends system_report { + + /** + * Initialise the report. + * + * @return void + */ + protected function initialise(): void { + $ruleentity = new rule_entity(); + $rulealias = $ruleentity->get_table_alias('tool_dynamic_cohorts'); + $this->set_main_table('tool_dynamic_cohorts', $rulealias); + $this->add_entity($ruleentity); + + // Any columns required by actions should be defined here to ensure they're always available. + $this->add_base_fields("{$rulealias}.id, {$rulealias}.enabled"); + + $cohortentity = new cohort(); + $cohortalias = $cohortentity->get_table_alias('cohort'); + $this->add_entity($cohortentity + ->add_join("JOIN {cohort} {$cohortalias} ON {$cohortalias}.id = {$rulealias}.cohortid")); + + $this->add_columns(); + $this->add_actions(); + + $cohortentity->get_column('name') + ->set_title(new lang_string('cohort', 'tool_dynamic_cohorts')); + } + + /** + * Returns report context. + * + * @return \context + */ + public function get_context(): context { + return context_system::instance(); + } + + /** + * Check if can view this system report. + * + * @return bool + */ + protected function can_view(): bool { + return has_capability('tool/dynamic_cohorts:manage', $this->get_context()); + } + + /** + * Adds the columns we want to display in the report + * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their + * unique identifier + */ + protected function add_columns(): void { + $columns = [ + 'rule_entity:name', + 'rule_entity:description', + 'cohort:name', + 'rule_entity:bulkprocessing', + 'rule_entity:status', + ]; + + $this->add_columns_from_entities($columns); + } + + /** + * Add the system report actions. An extra column will be appended to each row, containing all actions added here + * + * Note the use of ":id" placeholder which will be substituted according to actual values in the row + */ + protected function add_actions(): void { + $this->add_action((new action( + new \moodle_url('/admin/tool/dynamic_cohorts/toggle.php', ['ruleid' => ':id', 'sesskey' => sesskey()]), + new \pix_icon('t/hide', '', 'core'), + [], + false, + new lang_string('enable') + ))->add_callback(function(\stdClass $row): bool { + return empty($row->enabled); + })); + + $this->add_action((new action( + new \moodle_url('/admin/tool/dynamic_cohorts/toggle.php', ['ruleid' => ':id', 'sesskey' => sesskey()]), + new \pix_icon('t/show', '', 'core'), + [], + false, + new lang_string('disable') + ))->add_callback(function(\stdClass $row): bool { + return !empty($row->enabled); + })); + + $this->add_action((new action( + new \moodle_url('/admin/tool/dynamic_cohorts/edit.php', ['ruleid' => ':id']), + new \pix_icon('t/edit', '', 'core'), + [], + false, + new lang_string('edit') + ))); + + $this->add_action((new action( + new \moodle_url('/admin/tool/dynamic_cohorts/delete.php', ['ruleid' => ':id', 'sesskey' => sesskey()]), + new \pix_icon('t/delete', '', 'core'), + [], + false, + new lang_string('delete') + ))); + } + + /** + * CSS class for the row + * + * @param \stdClass $row + * @return string + */ + public function get_row_class(\stdClass $row): string { + $rule = new rule(0, $row); + return (!$rule->is_enabled()) ? 'text-muted' : ''; + } +} diff --git a/classes/rule_form.php b/classes/rule_form.php new file mode 100644 index 0000000..8e8b645 --- /dev/null +++ b/classes/rule_form.php @@ -0,0 +1,111 @@ +. + +namespace tool_dynamic_cohorts; + +use html_writer; +use moodle_url; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * A form for adding/editing rules. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class rule_form extends \moodleform { + + /** + * Form definition. + */ + protected function definition() { + $mform = $this->_form; + + $mform->addElement('hidden', 'id'); + $mform->setType('id', PARAM_INT); + $mform->setDefault('id', 0); + + $mform->addElement('text', 'name', get_string('name', 'tool_dynamic_cohorts'), 'size="50"'); + $mform->setType('name', PARAM_TEXT); + $mform->addRule('name', get_string('required'), 'required'); + $mform->addHelpButton('name', 'name', 'tool_dynamic_cohorts'); + + $mform->addElement( + 'textarea', + 'description', + get_string('description', 'tool_dynamic_cohorts'), + ['rows' => 5, 'cols' => 50] + ); + $mform->addHelpButton('description', 'description', 'tool_dynamic_cohorts'); + $mform->setType('description', PARAM_TEXT); + + $mform->addElement( + 'autocomplete', + 'cohortid', + get_string('cohortid', 'tool_dynamic_cohorts'), + $this->get_cohort_options(), + ['noselectionstring' => get_string('choosedots')] + ); + $mform->addHelpButton('cohortid', 'cohortid', 'tool_dynamic_cohorts'); + $mform->addRule('cohortid', get_string('required'), 'required'); + + $link = html_writer::link(new moodle_url('/cohort/index.php'), get_string('managecohorts', 'tool_dynamic_cohorts')); + $mform->addElement('static', '', '', $link); + + // Hidden field for storing condition json string. + $mform->addElement('hidden', 'conditionjson', '', ['id' => 'id_conditionjson']); + $mform->setType('conditionjson', PARAM_RAW_TRIMMED); + + $mform->addElement( + 'advcheckbox', + 'bulkprocessing', + get_string('bulkprocessing', 'tool_dynamic_cohorts'), + get_string('enable'), + [], + [0, 1] + ); + $mform->addHelpButton('bulkprocessing', 'bulkprocessing', 'tool_dynamic_cohorts'); + + $this->add_action_buttons(); + } + + /** + * Get a list of all cohorts in the system. + * + * @return array + */ + protected function get_cohort_options(): array { + $options = ['' => get_string('choosedots')]; + + // Retrieve only available cohorts to display in the select. + foreach (cohort_manager::get_cohorts(true) as $cohort) { + $options[$cohort->id] = $cohort->name; + } + + // Add the currently selected cohort as it won't be in the list. + if (isset($this->_customdata['defaultcohort'])) { + $cohort = $this->_customdata['defaultcohort']; + $options[$cohort->id] = $cohort->name; + } + + return $options; + } +} diff --git a/classes/rule_manager.php b/classes/rule_manager.php new file mode 100644 index 0000000..3cfd2d7 --- /dev/null +++ b/classes/rule_manager.php @@ -0,0 +1,160 @@ +. + +namespace tool_dynamic_cohorts; + +use moodle_url; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/cohort/lib.php'); + +/** + * Rule manager class. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class rule_manager { + + /** + * Builds rule edit URL. + * + * @param rule $rule Rule instance. + * @return moodle_url + */ + public static function build_edit_url(rule $rule): moodle_url { + return new moodle_url('/admin/tool/dynamic_cohorts/edit.php', ['ruleid' => $rule->get('id')]); + } + + /** + * Builds rule delete URL. + * + * @param rule $rule Rule instance. + * @return moodle_url + */ + public static function build_delete_url(rule $rule): moodle_url { + return new \moodle_url('/admin/tool/dynamic_cohorts/delete.php', [ + 'ruleid' => $rule->get('id'), + 'sesskey' => sesskey(), + ]); + } + + /** + * Build data for setting into a rule form as default values. + * + * @param rule $rule Rule to build a data for. + * @return array + */ + public static function build_data_for_form(rule $rule): array { + $data = (array) $rule->to_record(); + $data['conditionjson'] = ''; + + return $data; + } + + /** + * A helper method for processing rule form data. + * + * @param \stdClass $formdata Data received from rule_form. + * @return rule Rule instance. + */ + public static function process_form(\stdClass $formdata): rule { + global $DB; + + $formdata->enabled = 0; + self::validate_submitted_data($formdata); + + $ruledata = (object) [ + 'name' => $formdata->name, + 'enabled' => $formdata->enabled, + 'cohortid' => $formdata->cohortid, + 'description' => $formdata->description, + 'bulkprocessing' => $formdata->bulkprocessing, + ]; + + $oldcohortid = 0; + + $transaction = $DB->start_delegated_transaction(); + + try { + if (empty($formdata->id)) { + $rule = new rule(0, $ruledata); + $rule->create(); + } else { + $rule = new rule($formdata->id); + $oldcohortid = $rule->get('cohortid'); + $rule->from_record($ruledata); + $rule->update(); + } + + cohort_manager::unmanage_cohort($oldcohortid); + cohort_manager::manage_cohort($formdata->cohortid); + + $transaction->allow_commit(); + return $rule; + } catch (\Exception $exception) { + $transaction->rollback($exception); + throw new $exception; + } + } + + /** + * Validate rule data. + * + * @param \stdClass $formdata Data received from rule_form. + */ + private static function validate_submitted_data(\stdClass $formdata): void { + $requiredfields = array_diff( + array_keys(rule::properties_definition()), + ['id', 'broken', 'usermodified', 'timecreated', 'timemodified'] + ); + + foreach ($requiredfields as $field) { + if (!isset($formdata->{$field})) { + throw new moodle_exception('Invalid rule data. Missing field: ' . $field); + } + } + + if (!array_key_exists($formdata->cohortid, cohort_manager::get_cohorts())) { + throw new moodle_exception('Invalid rule data. Cohort is invalid: ' . $formdata->cohortid); + } + + if (!isset($formdata->conditionjson)) { + throw new moodle_exception('Invalid rule data. Missing condition data.'); + } + } + + /** + * Delete rule. + * + * @param rule $rule + */ + public static function delete_rule(rule $rule) { + $conditions = $rule->get_condition_records(); + + if ($rule->delete()) { + // Delete related condition in a loop to be able to trigger events. + foreach ($conditions as $condition) { + $condition->delete(); + } + + cohort_manager::unmanage_cohort($rule->get('cohortid')); + } + } +} diff --git a/db/access.php b/db/access.php index e53f4a9..0452729 100644 --- a/db/access.php +++ b/db/access.php @@ -26,7 +26,6 @@ defined('MOODLE_INTERNAL') || die(); $capabilities = [ - 'tool/dynamic_cohorts:manage' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, diff --git a/delete.php b/delete.php new file mode 100644 index 0000000..caf1fc7 --- /dev/null +++ b/delete.php @@ -0,0 +1,55 @@ +. + +/** + * Delete action page. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use tool_dynamic_cohorts\rule; +use tool_dynamic_cohorts\rule_manager; +use core\output\notification; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/formslib.php'); + +admin_externalpage_setup('tool_dynamic_cohorts_rules'); + +$ruleid = required_param('ruleid', PARAM_INT); +$confirm = optional_param('confirm', '', PARAM_ALPHANUM); + +$rule = rule::get_record(['id' => $ruleid], MUST_EXIST); +$manageurl = new moodle_url('/admin/tool/dynamic_cohorts/index.php'); + +if ($confirm != md5($ruleid)) { + $confirmstring = get_string('delete_confirm', 'tool_dynamic_cohorts', $rule->get('name')); + $cinfirmoptions = ['ruleid' => $ruleid, 'confirm' => md5($ruleid), 'sesskey' => sesskey()]; + $deleteurl = new moodle_url('/admin/tool/dynamic_cohorts/delete.php', $cinfirmoptions); + + $PAGE->navbar->add(get_string('delete_rule', 'tool_dynamic_cohorts')); + + echo $OUTPUT->header(); + echo $OUTPUT->confirm($confirmstring, $deleteurl, $manageurl); + echo $OUTPUT->footer(); + +} else if (data_submitted() && confirm_sesskey()) { + rule_manager::delete_rule($rule); + redirect($manageurl, get_string('ruledeleted', 'tool_dynamic_cohorts'), null, notification::NOTIFY_SUCCESS); +} diff --git a/edit.php b/edit.php new file mode 100644 index 0000000..729f024 --- /dev/null +++ b/edit.php @@ -0,0 +1,73 @@ +. + +/** + * Rules edit page. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core\notification; +use tool_dynamic_cohorts\rule; +use tool_dynamic_cohorts\rule_form; +use tool_dynamic_cohorts\rule_manager; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +$ruleid = optional_param('ruleid', 0, PARAM_INT); +$action = !empty($ruleid) ? 'edit' : 'add'; + +admin_externalpage_setup('tool_dynamic_cohorts_rules'); + +$manageurl = new moodle_url('/admin/tool/dynamic_cohorts/index.php'); +$editurl = new moodle_url('/admin/tool/dynamic_cohorts/edit.php'); +$header = get_string($action . '_rule', 'tool_dynamic_cohorts'); +$PAGE->navbar->add($header); + + +if (!empty($ruleid)) { + $rule = rule::get_record(['id' => $ruleid]); + if (empty($rule)) { + throw new dml_missing_record_exception(null); + } else { + $defaultcohort = $DB->get_record('cohort', ['id' => $rule->get('cohortid')]); + $mform = new rule_form(rule_manager::build_edit_url($rule)->out(), ['defaultcohort' => $defaultcohort ?: null]); + $mform->set_data(rule_manager::build_data_for_form($rule)); + } +} else { + $mform = new rule_form(); +} + +if ($mform->is_cancelled()) { + redirect($manageurl); +} else if ($formdata = $mform->get_data()) { + try { + rule_manager::process_form($formdata); + notification::success(get_string('changessaved')); + notification::warning(get_string('ruledisabledpleasereview', 'tool_dynamic_cohorts')); + } catch (Exception $e) { + notification::error($e->getMessage()); + } + redirect($manageurl); +} + +echo $OUTPUT->header(); +echo $OUTPUT->heading($header); +$mform->display(); +echo $OUTPUT->footer(); diff --git a/index.php b/index.php new file mode 100644 index 0000000..c750148 --- /dev/null +++ b/index.php @@ -0,0 +1,43 @@ +. + +/** + * Rules page. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +admin_externalpage_setup('tool_dynamic_cohorts_rules'); + +$manageurl = new moodle_url('/admin/tool/dynamic_cohorts/index.php'); +$editurl = new moodle_url('/admin/tool/dynamic_cohorts/edit.php'); + +$report = \core_reportbuilder\system_report_factory::create( + \tool_dynamic_cohorts\reportbuilder\local\systemreports\rules::class, + context_system::instance(), + 'tool_dynamic_cohorts' +); + +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('managerules', 'tool_dynamic_cohorts')); +echo $OUTPUT->render_from_template('tool_dynamic_cohorts/addbutton', ['url' => $editurl]); +echo $report->output(); +echo $OUTPUT->footer(); diff --git a/lang/en/tool_dynamic_cohorts.php b/lang/en/tool_dynamic_cohorts.php index b15ede3..392d125 100644 --- a/lang/en/tool_dynamic_cohorts.php +++ b/lang/en/tool_dynamic_cohorts.php @@ -25,7 +25,27 @@ defined('MOODLE_INTERNAL') || die(); +$string['addrule'] = 'Add a new rule'; +$string['add_rule'] = 'Add new rule'; +$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.'; +$string['cohort'] = 'Cohort'; +$string['cohortid'] = 'Cohort'; +$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['delete_confirm'] = 'Are you sure you want to delete rule {$a}?'; +$string['delete_rule'] = 'Delete rule'; +$string['description'] = 'Description'; +$string['description_help'] = 'As short description of this rule'; +$string['disabled'] = 'Disabled'; +$string['disable_confirm'] = 'Are you sure you want to disable rule {$a}?'; $string['dynamic_cohorts:manage'] = 'Manage rules'; +$string['edit_rule'] = 'Edit rule'; +$string['enabled'] = 'Enabled'; +$string['enable_confirm'] = 'Are you sure you want to enable rule {$a}?'; +$string['managerules'] = 'Manage rules'; +$string['managecohorts'] = 'Manage cohorts'; +$string['name'] = 'Rule name'; +$string['name_help'] = 'A human readable name of this rule.'; $string['pluginname'] = 'Dynamic cohort rules'; $string['privacy:metadata:tool_dynamic_cohorts'] = 'Information about rules created or updated by a user'; $string['privacy:metadata:tool_dynamic_cohorts:name'] = 'Rule name'; @@ -33,3 +53,14 @@ $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['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.'; +$string['ruledeleted'] = 'Rule has been deleted'; +$string['ruleenabled'] = 'Rule has been enabled'; +$string['ruledisabled'] = 'Rule has been disabled'; +$string['rule_entity'] = 'Dynamic cohort rule'; +$string['rule_entity.id'] = 'ID'; +$string['rule_entity.name'] = 'Name'; +$string['rule_entity.description'] = 'Description'; +$string['rule_entity.bulkprocessing'] = 'Bulk processing'; +$string['rule_entity.status'] = 'Status'; diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..d636835 --- /dev/null +++ b/settings.php @@ -0,0 +1,34 @@ +. + +/** + * Settings page + * + * @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(); + +if ($hassiteconfig) { + $ADMIN->add('accounts', new admin_category('tool_dynamic_cohorts', get_string('pluginname', 'tool_dynamic_cohorts'))); + $ADMIN->add('tool_dynamic_cohorts', new admin_externalpage( + 'tool_dynamic_cohorts_rules', + get_string('managerules', 'tool_dynamic_cohorts'), + new moodle_url('/admin/tool/dynamic_cohorts/index.php') + )); +} diff --git a/templates/addbutton.mustache b/templates/addbutton.mustache new file mode 100644 index 0000000..b8c3ccf --- /dev/null +++ b/templates/addbutton.mustache @@ -0,0 +1,39 @@ +{{! + 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 . +}} +{{! + @template tool_dynamic_cohorts/button + + Button to add a new rule. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * url - URL of edit page. + + Example context (json): + { + "url" : "https://example.com/admin/tool/dynamic_cohorts/edit.php" + } +}} + + + {{#str}}addrule, tool_dynamic_cohorts{{/str}} + diff --git a/tests/cohort_manager_test.php b/tests/cohort_manager_test.php new file mode 100644 index 0000000..7e76729 --- /dev/null +++ b/tests/cohort_manager_test.php @@ -0,0 +1,90 @@ +. + +namespace tool_dynamic_cohorts; + +/** + * Tests for cohort manager 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\cohort_manager + */ +class cohort_manager_test extends \advanced_testcase { + + /** + * Test getting available cohorts. + */ + public function test_get_available_cohorts() { + $this->resetAfterTest(); + + $this->assertEmpty(cohort_manager::get_cohorts()); + + $cohort1 = $this->getDataGenerator()->create_cohort(['component' => cohort_manager::COHORT_COMPONENT]); + $cohort2 = $this->getDataGenerator()->create_cohort(); + $cohort3 = $this->getDataGenerator()->create_cohort(); + $cohort4 = $this->getDataGenerator()->create_cohort(['component' => 'mod_assign']); + + $allcohorts = cohort_manager::get_cohorts(); + + $this->assertCount(3, $allcohorts); + + $this->assertEquals($cohort1, $allcohorts[$cohort1->id]); + $this->assertEquals($cohort2, $allcohorts[$cohort2->id]); + $this->assertEquals($cohort3, $allcohorts[$cohort3->id]); + $this->assertArrayNotHasKey($cohort4->id, $allcohorts); + + $allcohorts = cohort_manager::get_cohorts(true); + $this->assertCount(2, $allcohorts); + + $this->assertArrayNotHasKey($cohort1->id, $allcohorts); + $this->assertEquals($cohort2, $allcohorts[$cohort2->id]); + $this->assertEquals($cohort3, $allcohorts[$cohort3->id]); + $this->assertArrayNotHasKey($cohort4->id, $allcohorts); + } + + /** + * Test managing cohort. + */ + public function test_manage_cohort() { + global $DB; + + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(); + $this->assertEquals('', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + + cohort_manager::manage_cohort($cohort->id); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + } + + /** + * Test unmanaging cohort. + */ + public function test_unmanage_cohort() { + global $DB; + + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(['component' => 'tool_dynamic_cohorts']); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + + cohort_manager::unmanage_cohort($cohort->id); + $this->assertEquals('', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + } +} diff --git a/tests/rule_manager_test.php b/tests/rule_manager_test.php new file mode 100644 index 0000000..3402993 --- /dev/null +++ b/tests/rule_manager_test.php @@ -0,0 +1,349 @@ +. + +namespace tool_dynamic_cohorts; + +use moodle_url; +use moodle_exception; + +/** + * Tests for rule manager 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\rule_manager + */ +class rule_manager_test extends \advanced_testcase { + + /** + * Test building edit URL. + */ + public function test_build_edit_url() { + $this->resetAfterTest(); + + $data = ['name' => 'Test', 'enabled' => 1, 'cohortid' => 2, 'description' => '']; + $rule = new rule(0, (object)$data); + $rule->save(); + + $actual = rule_manager::build_edit_url($rule); + $expected = new moodle_url('/admin/tool/dynamic_cohorts/edit.php', ['ruleid' => $rule->get('id')]); + $this->assertEquals($expected->out(), $actual->out()); + } + + /** + * Test delete URL. + */ + public function test_build_rule_delete_url() { + $this->resetAfterTest(); + + $data = ['name' => 'Test', 'enabled' => 1, 'cohortid' => 2, 'description' => '']; + $rule = new rule(0, (object)$data); + $rule->save(); + + $actual = rule_manager::build_delete_url($rule); + $expected = new moodle_url('/admin/tool/dynamic_cohorts/delete.php', [ + 'ruleid' => $rule->get('id'), + 'sesskey' => sesskey(), + ]); + + $this->assertEquals($expected->out(), $actual->out()); + } + + /** + * Test building rule data for form. + */ + public function test_build_rule_data_for_form() { + $this->resetAfterTest(); + + $rule = new rule(0, (object)['name' => 'Test rule', 'cohortid' => 0, 'description' => 'Test description']); + + $expected = [ + 'name' => 'Test rule', + 'description' => 'Test description', + 'cohortid' => 0, + 'enabled' => 0, + 'bulkprocessing' => 0, + 'broken' => 0, + 'id' => 0, + 'timecreated' => 0, + 'timemodified' => 0, + 'usermodified' => 0, + 'conditionjson' => '', + ]; + + $this->assertSame($expected, rule_manager::build_data_for_form($rule)); + } + + /** + * Data provider for testing test_process_rule_form_with_invalid_data. + * + * @return array + */ + public function process_rule_form_with_invalid_data_provider(): array { + return [ + [[]], + [['name' => 'Test']], + [['enabled' => 1]], + [['cohortid' => 1]], + [['description' => '']], + [['conditionjson' => '']], + [['enabled' => 1, 'cohortid' => 1, 'description' => '', 'conditionjson' => '']], + [['name' => 'Test', 'cohortid' => 1, 'description' => '', 'conditionjson' => '']], + [['name' => 'Test', 'enabled' => 1, 'description' => '', 'conditionjson' => '']], + [['name' => 'Test', 'enabled' => 1, 'cohortid' => 1, 'conditionjson' => '']], + [['name' => 'Test', 'enabled' => 1, 'cohortid' => 1, 'description' => '']], + ]; + } + + /** + * Test processing rules with invalid data. + * + * @dataProvider process_rule_form_with_invalid_data_provider + * @param array $formdata Broken form data + */ + public function test_process_rule_form_with_invalid_data(array $formdata) { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid rule data'); + + rule_manager::process_form((object)$formdata); + } + + /** + * Test new rules are created when processing form data. + */ + public function test_process_rule_form_new_rule() { + global $DB; + + $this->resetAfterTest(); + $this->assertEquals(0, $DB->count_records(rule::TABLE)); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + $cohort3 = $this->getDataGenerator()->create_cohort(); + + $formdata = ['name' => 'Test', 'cohortid' => $cohort1->id, 'description' => '', + 'conditionjson' => '', 'bulkprocessing' => 1]; + + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + + $rule = rule::get_record(['id' => $rule->get('id')]); + unset($formdata['conditionjson']); + foreach ($formdata as $field => $value) { + $this->assertEquals($value, $rule->get($field)); + } + + $formdata = ['name' => 'Test', 'cohortid' => $cohort2->id, 'description' => '', + 'conditionjson' => '', 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(2, $DB->count_records(rule::TABLE)); + + $rule = rule::get_record(['id' => $rule->get('id')]); + unset($formdata['conditionjson']); + foreach ($formdata as $field => $value) { + $this->assertEquals($value, $rule->get($field)); + } + + $cohort = $this->getDataGenerator()->create_cohort(); + $formdata = ['name' => 'Test1', 'cohortid' => $cohort3->id, 'description' => '', + 'conditionjson' => '', 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(3, $DB->count_records(rule::TABLE)); + + $rule = rule::get_record(['id' => $rule->get('id')]); + unset($formdata['conditionjson']); + foreach ($formdata as $field => $value) { + $this->assertEquals($value, $rule->get($field)); + } + } + + /** + * Test existing rules are updated when processing form data. + */ + public function test_process_rule_form_existing_rule() { + global $DB; + + $this->resetAfterTest(); + $this->assertEquals(0, $DB->count_records(rule::TABLE)); + + $cohort = $this->getDataGenerator()->create_cohort(); + $formdata = ['name' => 'Test', 'cohortid' => $cohort->id, 'description' => '']; + $rule = new rule(0, (object)$formdata); + $rule->create(); + + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + unset($formdata['conditionjson']); + foreach ($formdata as $field => $value) { + $this->assertEquals($value, $rule->get($field)); + } + + $cohort = $this->getDataGenerator()->create_cohort(); + $formdata = ['id' => $rule->get('id'), 'name' => 'Test1', 'cohortid' => $cohort->id, + 'description' => 'D', 'conditionjson' => '', 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + + $rule = rule::get_record(['id' => $rule->get('id')]); + unset($formdata['conditionjson']); + foreach ($formdata as $field => $value) { + $this->assertEquals($value, $rule->get($field)); + } + } + + /** + * Test trying to submit form data and sending not existing cohort. + */ + public function test_process_rule_form_with_not_existing_cohort() { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid rule data. Cohort is invalid: 999'); + + $formdata = ['name' => 'Test', 'cohortid' => 999, 'description' => '', 'conditionjson' => '', 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + } + + /** + * Test trying to submit form data and sending a cohort taken by other component. + */ + public function test_process_rule_form_with_cohort_managed_by_other_component() { + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(['component' => 'mod_assign']); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid rule data. Cohort is invalid: ' . $cohort->id); + + $formdata = ['name' => 'Test', 'cohortid' => $cohort->id, 'description' => '', + 'conditionjson' => '', 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + } + + /** + * Test trying to submit form data and sending a cohort taken by other rule. + */ + public function test_process_rule_form_with_cohort_managed_by_another_rule() { + global $DB; + + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(['component' => 'tool_dynamic_cohorts']); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Cohort ' . $cohort->id . ' is already managed by tool_dynamic_cohorts'); + + $formdata = ['name' => 'Test1', 'cohortid' => $cohort->id, 'description' => 'D', 'conditionjson' => '', + 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + + // Trying to make a new rule with a cohort that is already taken. Should throw exception. + $formdata = ['name' => 'Test2', 'cohortid' => $cohort->id, 'description' => 'D', + 'conditionjson' => '', 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + } + + /** + * Test trying to submit form data and not updating the cohort. + */ + public function test_process_rule_form_update_rule_form_keeping_cohort() { + global $DB; + + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(); + + $formdata = ['name' => 'Test1', 'cohortid' => $cohort->id, 'description' => 'D', + 'conditionjson' => '', 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort->id])); + + // Update the rule, changing the name. Should work as cohort is the same. + $formdata = ['id' => $rule->get('id'), 'name' => 'Test1', + 'cohortid' => $cohort->id, 'description' => 'D', 'conditionjson' => '', 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + } + + /** + * Test trying to submit form data and sending a cohort taken by other component. + */ + public function test_process_rule_form_without_condition_data() { + $this->resetAfterTest(); + + $cohort = $this->getDataGenerator()->create_cohort(['component' => 'tool_dynamic_cohorts']); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid rule data. Missing condition data.'); + + $formdata = ['name' => 'Test', 'cohortid' => $cohort->id, 'description' => '', 'bulkprocessing' => 1]; + rule_manager::process_form((object)$formdata); + } + + /** + * Test rule deleting clear all related tables. + */ + public function test_deleting_rule_deletes_all_related_records() { + global $DB; + + $this->resetAfterTest(); + + $this->assertSame(0, $DB->count_records(rule::TABLE)); + $this->assertSame(0, $DB->count_records(condition::TABLE)); + + $cohort = $this->getDataGenerator()->create_cohort(); + + $rule = new rule(0, (object)['name' => 'Test rule', 'cohortid' => $cohort->id]); + $rule->save(); + + $condition = new condition(0, (object)['ruleid' => $rule->get('id'), 'classname' => 'test', 'sortorder' => 0]); + $condition->save(); + + $this->assertSame(1, $DB->count_records(rule::TABLE)); + $this->assertSame(1, $DB->count_records(condition::TABLE)); + + rule_manager::delete_rule($rule); + $this->assertSame(0, $DB->count_records(rule::TABLE)); + $this->assertSame(0, $DB->count_records(condition::TABLE)); + } + + /** + * Test cohorts are getting released after related rules are deleted. + */ + public function test_deleting_rule_releases_cohorts() { + global $DB; + + $this->resetAfterTest(); + + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + $this->assertEquals('', $DB->get_field('cohort', 'component', ['id' => $cohort1->id])); + $this->assertEquals('', $DB->get_field('cohort', 'component', ['id' => $cohort2->id])); + + $rule1 = new rule(0, (object)['name' => 'Test rule', 'cohortid' => $cohort1->id]); + $rule1->save(); + cohort_manager::manage_cohort($cohort1->id); + + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort1->id])); + + $rule2 = new rule(0, (object)['name' => 'Test rule 2', 'cohortid' => $cohort2->id]); + $rule2->save(); + cohort_manager::manage_cohort($cohort2->id); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort1->id])); + + rule_manager::delete_rule($rule1); + $this->assertEquals('tool_dynamic_cohorts', $DB->get_field('cohort', 'component', ['id' => $cohort2->id])); + + rule_manager::delete_rule($rule2); + $this->assertEquals('', $DB->get_field('cohort', 'component', ['id' => $cohort2->id])); + } +} diff --git a/toggle.php b/toggle.php new file mode 100644 index 0000000..0f3c815 --- /dev/null +++ b/toggle.php @@ -0,0 +1,72 @@ +. + +/** + * Toggle action page. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core\output\notification; +use tool_dynamic_cohorts\rule; +use tool_dynamic_cohorts\rule_manager; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/formslib.php'); + +admin_externalpage_setup('tool_dynamic_cohorts_rules'); + +$ruleid = required_param('ruleid', PARAM_INT); +$confirm = optional_param('confirm', '', PARAM_ALPHANUM); + +$rule = rule::get_record(['id' => $ruleid], MUST_EXIST); +$manageurl = new moodle_url('/admin/tool/dynamic_cohorts/index.php'); + +if (!$rule->is_broken()) { + $identificator = $rule->is_enabled() ? 'ruledisabled' : 'ruleenabled'; + $action = $rule->is_enabled() ? 'disable' : 'enable'; + $message = get_string($identificator, 'tool_dynamic_cohorts'); + $newvalue = (int) !$rule->is_enabled(); + $messagetype = notification::NOTIFY_SUCCESS; + + if ($confirm != md5($ruleid)) { + $confirmstring = get_string($action . '_confirm', 'tool_dynamic_cohorts', $rule->get('name')); + $cinfirmoptions = ['ruleid' => $ruleid, 'confirm' => md5($ruleid), 'sesskey' => sesskey()]; + $deleteurl = new moodle_url('/admin/tool/dynamic_cohorts/toggle.php', $cinfirmoptions); + + $PAGE->navbar->add(get_string('delete_rule', 'tool_dynamic_cohorts')); + + echo $OUTPUT->header(); + echo $OUTPUT->confirm($confirmstring, $deleteurl, $manageurl); + echo $OUTPUT->footer(); + + } else if (data_submitted() && confirm_sesskey()) { + $rule->set('enabled', $newvalue); + $rule->save(); + redirect($manageurl, $message, null, $messagetype); + } +} else { + $newvalue = 0; + $message = get_string('ruleisbroken', 'tool_dynamic_cohorts'); + $messagetype = notification::NOTIFY_ERROR; + + $rule->set('enabled', $newvalue); + $rule->save(); + redirect($manageurl, $message, null, $messagetype); +}