From 58351e62e05b94c332d335cd0a472d054c7cb261 Mon Sep 17 00:00:00 2001 From: Dmitrii Metelkin Date: Wed, 6 Mar 2024 15:51:08 +1100 Subject: [PATCH] issue #7: conditions add CRUD --- .github/workflows/ci.yml | 1 + amd/build/condition_form.min.js | 10 + amd/build/condition_form.min.js.map | 1 + amd/src/condition_form.js | 272 ++++++++++++++++++ classes/condition_base.php | 4 +- classes/condition_form.php | 85 ++++++ classes/condition_manager.php | 65 +++++ classes/external/condition_form.php | 105 +++++++ .../local/systemreports/rules.php | 41 +-- classes/rule.php | 36 ++- classes/rule_form.php | 36 +++ classes/rule_manager.php | 28 ++ db/services.php | 36 +++ edit.php | 2 + lang/en/tool_dynamic_cohorts.php | 6 + lib.php | 62 ++++ styles.css | 6 + templates/conditions.mustache | 67 +++++ tests/rule_manager_test.php | 135 ++++++++- tests/rule_test.php | 35 +++ version.php | 4 +- 21 files changed, 1012 insertions(+), 25 deletions(-) create mode 100644 amd/build/condition_form.min.js create mode 100644 amd/build/condition_form.min.js.map create mode 100644 amd/src/condition_form.js create mode 100644 classes/condition_form.php create mode 100644 classes/external/condition_form.php create mode 100644 db/services.php create mode 100644 lib.php create mode 100644 styles.css create mode 100644 templates/conditions.mustache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef23d2d..d646a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,3 +8,4 @@ jobs: uses: catalyst/catalyst-moodle-workflows/.github/workflows/ci.yml@main with: disable_behat: true + disable_phpcpd: true diff --git a/amd/build/condition_form.min.js b/amd/build/condition_form.min.js new file mode 100644 index 0000000..f8a34b0 --- /dev/null +++ b/amd/build/condition_form.min.js @@ -0,0 +1,10 @@ +define("tool_dynamic_cohorts/condition_form",["exports","core/ajax","core/templates","core/fragment","core/modal_events","core/modal_factory","core/notification","core/str"],(function(_exports,_ajax,_templates,_fragment,_modal_events,_modal_factory,_notification,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Condition modal form. + * + * @module tool_dynamic_cohorts/condition_form + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_ajax=_interopRequireDefault(_ajax),_templates=_interopRequireDefault(_templates),_fragment=_interopRequireDefault(_fragment),_modal_events=_interopRequireDefault(_modal_events),_modal_factory=_interopRequireDefault(_modal_factory),_notification=_interopRequireDefault(_notification);const SELECTORS_ADD_CONDITION_BUTTON="#id_conditionmodalbutton",SELECTORS_SELECT_CONDITION="#id_condition",SELECTORS_CONDITIONS_LIST="#conditions",SELECTORS_RULE_FORM_CONDITIONS_JSON="#id_conditionjson",SELECTORS_RULE_FORM_IS_CONDITIONS_CHANGED="#id_isconditionschanged",SELECTORS_CONDITIONS_NOT_SAVED_WARNING="#tool-dynamic-cohorts-not-saved",SELECTORS_CONDITION_EDIT_ACTION="tool-dynamic-cohorts-condition-edit",SELECTORS_CONDITION_DELETE_ACTION="tool-dynamic-cohorts-condition-delete",SELECTORS_CONDITIONS="tool-dynamic-cohorts-conditions",getModalFormBody=(className,submittedData,defaults)=>{void 0===defaults&&(defaults="");const params={classname:className,jsonformdata:JSON.stringify(submittedData),defaults:JSON.stringify(defaults)};return _fragment.default.loadFragment("tool_dynamic_cohorts","condition_form",1,params)},displayModalForm=(className,defaults)=>{void 0===defaults&&(defaults=""),_modal_factory.default.create({type:_modal_factory.default.types.SAVE_CANCEL,title:(0,_str.get_string)("conditionformtitle","tool_dynamic_cohorts"),body:getModalFormBody(className,"",defaults),large:!0}).then((function(modal){modal.getRoot().on(_modal_events.default.save,(function(e){e.preventDefault(),modal.getRoot().find("form").submit()})),modal.getRoot().on(_modal_events.default.hidden,(function(){modal.destroy()})),modal.getRoot().on("submit","form",(function(e){e.preventDefault(),submitModalFormAjax(className,modal)})),modal.show()}))},submitModalFormAjax=(className,modal)=>{const changeEvent=document.createEvent("HTMLEvents");changeEvent.initEvent("change",!0,!0),modal.getRoot().find(":input").each((function(index,element){element.dispatchEvent(changeEvent)}));const invalid=modal.getRoot().find('[aria-invalid="true"]');if(invalid.length)invalid.first().focus();else{const submittedData=modal.getRoot().find("form").serialize();_ajax.default.call([{methodname:"tool_dynamic_cohorts_submit_condition_form",args:{classname:className,jsonformdata:JSON.stringify(submittedData)},done:function(response){updateCondition(response),renderConditions(getConditions()),modal.destroy()},fail:function(){modal.setBody(getModalFormBody(className,submittedData,""))}}])}},updateCondition=data=>{let condition={...data},conditions=getConditions();condition.sortorder>=0?conditions[condition.sortorder]=condition:(conditions.push(condition),condition.sortorder=conditions.length-1),saveConditionsToRuleForm(conditions)},getConditions=()=>{let conditions=[];const conditionsjson=document.querySelector(SELECTORS_RULE_FORM_CONDITIONS_JSON).value;return""!==conditionsjson&&(conditions=JSON.parse(conditionsjson)),conditions},saveConditionsToRuleForm=conditions=>{document.querySelector(SELECTORS_RULE_FORM_CONDITIONS_JSON).setAttribute("value",JSON.stringify(conditions)),document.querySelector(SELECTORS_RULE_FORM_IS_CONDITIONS_CHANGED).setAttribute("value",1)},renderConditions=conditions=>{_templates.default.render("tool_dynamic_cohorts/conditions",{conditions:conditions}).then((function(html){document.querySelector(SELECTORS_CONDITIONS_LIST).innerHTML=html,applyConditionActions(),document.querySelector(SELECTORS_CONDITIONS_NOT_SAVED_WARNING).classList.remove("hidden")})).fail((function(){_notification.default.exception({message:"Error updating conditions"})}))},applyConditionActions=()=>{document.getElementsByClassName(SELECTORS_CONDITIONS)[0].addEventListener("click",(event=>{let element="SPAN"===event.target.tagName?event.target:event.target.parentNode;if(element.className===SELECTORS_CONDITION_DELETE_ACTION&&_notification.default.confirm((0,_str.get_string)("confirm","moodle"),(0,_str.get_string)("delete_confirm_condition","tool_dynamic_cohorts"),(0,_str.get_string)("yes","moodle"),(0,_str.get_string)("no","moodle"),(function(){let sortorder=element.dataset.sortorder,conditions=getConditions().filter((c=>c.sortorder!=sortorder)).map(((condition,index)=>({...condition,sortorder:index})));saveConditionsToRuleForm(conditions),renderConditions(conditions)})),element.className===SELECTORS_CONDITION_EDIT_ACTION){let sortorder=element.dataset.sortorder,condition=getConditions()[sortorder];displayModalForm(condition.classname,condition)}}))};_exports.init=()=>{const addButton=document.querySelector(SELECTORS_ADD_CONDITION_BUTTON),conditionSelect=document.querySelector(SELECTORS_SELECT_CONDITION);addButton.addEventListener("click",(e=>{e.preventDefault();const className=conditionSelect.value;""!==className&&displayModalForm(className,"")})),applyConditionActions()}})); + +//# sourceMappingURL=condition_form.min.js.map \ No newline at end of file diff --git a/amd/build/condition_form.min.js.map b/amd/build/condition_form.min.js.map new file mode 100644 index 0000000..33fc086 --- /dev/null +++ b/amd/build/condition_form.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"condition_form.min.js","sources":["../src/condition_form.js"],"sourcesContent":["// This file is part of Moodle - https://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Condition modal form.\n *\n * @module tool_dynamic_cohorts/condition_form\n * @copyright 2024 Catalyst IT\n * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * */\n\n\nimport Ajax from 'core/ajax';\nimport Templates from 'core/templates';\nimport Fragment from 'core/fragment';\nimport ModalEvents from 'core/modal_events';\nimport ModalFactory from 'core/modal_factory';\nimport Notification from 'core/notification';\nimport {get_string as getString} from 'core/str';\n\n/**\n * A list of used selectors.\n */\nconst SELECTORS = {\n ADD_CONDITION_BUTTON: '#id_conditionmodalbutton',\n SELECT_CONDITION: '#id_condition',\n CONDITIONS_LIST: '#conditions',\n RULE_FORM_CONDITIONS_JSON: '#id_conditionjson',\n RULE_FORM_IS_CONDITIONS_CHANGED: '#id_isconditionschanged',\n CONDITIONS_NOT_SAVED_WARNING: '#tool-dynamic-cohorts-not-saved',\n CONDITION_EDIT_ACTION: 'tool-dynamic-cohorts-condition-edit',\n CONDITION_DELETE_ACTION: 'tool-dynamic-cohorts-condition-delete',\n CONDITIONS: 'tool-dynamic-cohorts-conditions'\n};\n\n\n/**\n * Get modal form html body using fragment API.\n *\n * @param {string} className\n * @param {string} submittedData Submitted form data.\n * @param {any} defaults Default values for the form\n * @returns {Promise}\n */\nconst getModalFormBody = (className, submittedData, defaults) => {\n if (defaults === undefined) {\n defaults = '';\n }\n\n const params = {\n classname: className,\n jsonformdata: JSON.stringify(submittedData),\n defaults: JSON.stringify(defaults),\n };\n\n return Fragment.loadFragment('tool_dynamic_cohorts', 'condition_form', 1, params);\n};\n\n/**\n * Display Modal form.\n *\n * @param {string} className\n * @param {any} defaults Default values for the form\n */\nconst displayModalForm = (className, defaults) => {\n\n if (defaults === undefined) {\n defaults = '';\n }\n\n ModalFactory.create({\n type: ModalFactory.types.SAVE_CANCEL,\n title: getString('conditionformtitle', 'tool_dynamic_cohorts'),\n body: getModalFormBody(className, '', defaults),\n large: true,\n }).then(function (modal) {\n\n modal.getRoot().on(ModalEvents.save, function(e) {\n e.preventDefault();\n modal.getRoot().find('form').submit();\n });\n\n modal.getRoot().on(ModalEvents.hidden, function() {\n modal.destroy();\n });\n\n modal.getRoot().on('submit', 'form', function(e) {\n e.preventDefault();\n submitModalFormAjax(className, modal);\n });\n\n modal.show();\n });\n};\n\n/**\n * Submit modal form via ajax.\n *\n * @param {string} className Condition class name.\n * @param {object} modal Modal object.\n */\nconst submitModalFormAjax = (className, modal) => {\n const changeEvent = document.createEvent('HTMLEvents');\n changeEvent.initEvent('change', true, true);\n\n // Prompt all inputs to run their validation functions.\n // Normally this would happen when the form is submitted, but\n // since we aren't submitting the form normally we need to run client side\n // validation.\n modal.getRoot().find(':input').each(function(index, element) {\n element.dispatchEvent(changeEvent);\n });\n\n const invalid = modal.getRoot().find('[aria-invalid=\"true\"]');\n\n // If we found invalid fields, focus on the first one and do not submit via ajax.\n if (invalid.length) {\n invalid.first().focus();\n } else {\n const submittedData = modal.getRoot().find('form').serialize();\n\n Ajax.call([{\n methodname: 'tool_dynamic_cohorts_submit_condition_form',\n args: {classname: className, jsonformdata: JSON.stringify(submittedData)},\n done: function (response) {\n updateCondition(response);\n renderConditions(getConditions());\n modal.destroy();\n },\n fail: function () {\n modal.setBody(getModalFormBody(className, submittedData, ''));\n }\n }]);\n }\n};\n\n/**\n * Update condition with provided data.\n *\n * @param {object} data Updated condition data.\n */\nconst updateCondition = (data) => {\n let condition = {...data};\n\n let conditions = getConditions();\n\n if (condition.sortorder >= 0) {\n conditions[condition.sortorder] = condition;\n } else {\n conditions.push(condition);\n condition.sortorder = conditions.length - 1;\n }\n\n saveConditionsToRuleForm(conditions);\n};\n\n/**\n * Get a list of all conditions.\n *\n * @returns {*[]}\n */\nconst getConditions = () => {\n let conditions = [];\n const conditionsjson = document.querySelector(SELECTORS.RULE_FORM_CONDITIONS_JSON).value;\n if (conditionsjson !== '') {\n conditions = JSON.parse(conditionsjson);\n }\n return conditions;\n\n};\n\n/**\n * Save a list of conditions to a rule form element.\n *\n * @param {array} conditions A list of conditions to save\n */\nconst saveConditionsToRuleForm = (conditions) => {\n document.querySelector(SELECTORS.RULE_FORM_CONDITIONS_JSON).setAttribute('value', JSON.stringify(conditions));\n document.querySelector(SELECTORS.RULE_FORM_IS_CONDITIONS_CHANGED).setAttribute('value', 1);\n};\n\n/**\n * Display a warning that conditions are not saved.\n */\nconst displayNotSavedWarning = () => {\n document.querySelector(SELECTORS.CONDITIONS_NOT_SAVED_WARNING).classList.remove('hidden');\n};\n\n/**\n * Render conditions.\n *\n * @param {array} conditions A list of conditions to render.\n */\nconst renderConditions = (conditions) => {\n Templates.render(\n 'tool_dynamic_cohorts/conditions',\n {'conditions' : conditions}\n ).then(function(html) {\n document.querySelector(SELECTORS.CONDITIONS_LIST).innerHTML = html;\n applyConditionActions();\n displayNotSavedWarning();\n }).fail(function() {\n Notification.exception({message: 'Error updating conditions'});\n });\n};\n\n/**\n * Apply actions to conditions.\n */\nconst applyConditionActions = () => {\n document.getElementsByClassName(SELECTORS.CONDITIONS)[0].addEventListener('click', event => {\n let element = event.target.tagName === 'SPAN' ? event.target : event.target.parentNode;\n\n // On a click to a delete icon, grab the position of the selected for deleting condition\n // and remove an element of that position from the list of all existing conditions.\n // Then save updated list of conditions to the rule form and render new list on a screen.\n if (element.className === SELECTORS.CONDITION_DELETE_ACTION) {\n Notification.confirm(\n getString('confirm', 'moodle'),\n getString('delete_confirm_condition', 'tool_dynamic_cohorts'),\n getString('yes', 'moodle'),\n getString('no', 'moodle'),\n function () {\n let sortorder = element.dataset.sortorder;\n let conditions = getConditions()\n .filter(c => c.sortorder != sortorder)\n .map((condition, index) => ({...condition, sortorder: index}));\n saveConditionsToRuleForm(conditions);\n renderConditions(conditions);\n });\n }\n\n // On a click to an edit icon for a selected condition, grab condition data from the list of\n // all conditions by its position and then render modal form using the condition class.\n if (element.className === SELECTORS.CONDITION_EDIT_ACTION) {\n let sortorder = element.dataset.sortorder;\n let conditions = getConditions();\n let condition = conditions[sortorder];\n\n displayModalForm(condition.classname, condition);\n }\n });\n};\n\n/**\n * Init of the module.\n */\nexport const init = () => {\n const addButton = document.querySelector(SELECTORS.ADD_CONDITION_BUTTON);\n const conditionSelect = document.querySelector(SELECTORS.SELECT_CONDITION);\n\n addButton.addEventListener('click', (e) => {\n e.preventDefault();\n const className = conditionSelect.value;\n if (className !== '') {\n displayModalForm(className, '');\n }\n });\n applyConditionActions();\n};\n"],"names":["SELECTORS","getModalFormBody","className","submittedData","defaults","undefined","params","classname","jsonformdata","JSON","stringify","Fragment","loadFragment","displayModalForm","create","type","ModalFactory","types","SAVE_CANCEL","title","body","large","then","modal","getRoot","on","ModalEvents","save","e","preventDefault","find","submit","hidden","destroy","submitModalFormAjax","show","changeEvent","document","createEvent","initEvent","each","index","element","dispatchEvent","invalid","length","first","focus","serialize","call","methodname","args","done","response","updateCondition","renderConditions","getConditions","fail","setBody","data","condition","conditions","sortorder","push","saveConditionsToRuleForm","conditionsjson","querySelector","value","parse","setAttribute","render","html","innerHTML","applyConditionActions","classList","remove","exception","message","getElementsByClassName","addEventListener","event","target","tagName","parentNode","confirm","dataset","filter","c","map","addButton","conditionSelect"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/condition_form.js b/amd/src/condition_form.js new file mode 100644 index 0000000..668591e --- /dev/null +++ b/amd/src/condition_form.js @@ -0,0 +1,272 @@ +// 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 . + +/** + * Condition modal form. + * + * @module tool_dynamic_cohorts/condition_form + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * */ + + +import Ajax from 'core/ajax'; +import Templates from 'core/templates'; +import Fragment from 'core/fragment'; +import ModalEvents from 'core/modal_events'; +import ModalFactory from 'core/modal_factory'; +import Notification from 'core/notification'; +import {get_string as getString} from 'core/str'; + +/** + * A list of used selectors. + */ +const SELECTORS = { + ADD_CONDITION_BUTTON: '#id_conditionmodalbutton', + SELECT_CONDITION: '#id_condition', + CONDITIONS_LIST: '#conditions', + RULE_FORM_CONDITIONS_JSON: '#id_conditionjson', + RULE_FORM_IS_CONDITIONS_CHANGED: '#id_isconditionschanged', + CONDITIONS_NOT_SAVED_WARNING: '#tool-dynamic-cohorts-not-saved', + CONDITION_EDIT_ACTION: 'tool-dynamic-cohorts-condition-edit', + CONDITION_DELETE_ACTION: 'tool-dynamic-cohorts-condition-delete', + CONDITIONS: 'tool-dynamic-cohorts-conditions' +}; + + +/** + * Get modal form html body using fragment API. + * + * @param {string} className + * @param {string} submittedData Submitted form data. + * @param {any} defaults Default values for the form + * @returns {Promise} + */ +const getModalFormBody = (className, submittedData, defaults) => { + if (defaults === undefined) { + defaults = ''; + } + + const params = { + classname: className, + jsonformdata: JSON.stringify(submittedData), + defaults: JSON.stringify(defaults), + }; + + return Fragment.loadFragment('tool_dynamic_cohorts', 'condition_form', 1, params); +}; + +/** + * Display Modal form. + * + * @param {string} className + * @param {any} defaults Default values for the form + */ +const displayModalForm = (className, defaults) => { + + if (defaults === undefined) { + defaults = ''; + } + + ModalFactory.create({ + type: ModalFactory.types.SAVE_CANCEL, + title: getString('conditionformtitle', 'tool_dynamic_cohorts'), + body: getModalFormBody(className, '', defaults), + large: true, + }).then(function (modal) { + + modal.getRoot().on(ModalEvents.save, function(e) { + e.preventDefault(); + modal.getRoot().find('form').submit(); + }); + + modal.getRoot().on(ModalEvents.hidden, function() { + modal.destroy(); + }); + + modal.getRoot().on('submit', 'form', function(e) { + e.preventDefault(); + submitModalFormAjax(className, modal); + }); + + modal.show(); + }); +}; + +/** + * Submit modal form via ajax. + * + * @param {string} className Condition class name. + * @param {object} modal Modal object. + */ +const submitModalFormAjax = (className, modal) => { + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + + // Prompt all inputs to run their validation functions. + // Normally this would happen when the form is submitted, but + // since we aren't submitting the form normally we need to run client side + // validation. + modal.getRoot().find(':input').each(function(index, element) { + element.dispatchEvent(changeEvent); + }); + + const invalid = modal.getRoot().find('[aria-invalid="true"]'); + + // If we found invalid fields, focus on the first one and do not submit via ajax. + if (invalid.length) { + invalid.first().focus(); + } else { + const submittedData = modal.getRoot().find('form').serialize(); + + Ajax.call([{ + methodname: 'tool_dynamic_cohorts_submit_condition_form', + args: {classname: className, jsonformdata: JSON.stringify(submittedData)}, + done: function (response) { + updateCondition(response); + renderConditions(getConditions()); + modal.destroy(); + }, + fail: function () { + modal.setBody(getModalFormBody(className, submittedData, '')); + } + }]); + } +}; + +/** + * Update condition with provided data. + * + * @param {object} data Updated condition data. + */ +const updateCondition = (data) => { + let condition = {...data}; + + let conditions = getConditions(); + + if (condition.sortorder >= 0) { + conditions[condition.sortorder] = condition; + } else { + conditions.push(condition); + condition.sortorder = conditions.length - 1; + } + + saveConditionsToRuleForm(conditions); +}; + +/** + * Get a list of all conditions. + * + * @returns {*[]} + */ +const getConditions = () => { + let conditions = []; + const conditionsjson = document.querySelector(SELECTORS.RULE_FORM_CONDITIONS_JSON).value; + if (conditionsjson !== '') { + conditions = JSON.parse(conditionsjson); + } + return conditions; + +}; + +/** + * Save a list of conditions to a rule form element. + * + * @param {array} conditions A list of conditions to save + */ +const saveConditionsToRuleForm = (conditions) => { + document.querySelector(SELECTORS.RULE_FORM_CONDITIONS_JSON).setAttribute('value', JSON.stringify(conditions)); + document.querySelector(SELECTORS.RULE_FORM_IS_CONDITIONS_CHANGED).setAttribute('value', 1); +}; + +/** + * Display a warning that conditions are not saved. + */ +const displayNotSavedWarning = () => { + document.querySelector(SELECTORS.CONDITIONS_NOT_SAVED_WARNING).classList.remove('hidden'); +}; + +/** + * Render conditions. + * + * @param {array} conditions A list of conditions to render. + */ +const renderConditions = (conditions) => { + Templates.render( + 'tool_dynamic_cohorts/conditions', + {'conditions' : conditions} + ).then(function(html) { + document.querySelector(SELECTORS.CONDITIONS_LIST).innerHTML = html; + applyConditionActions(); + displayNotSavedWarning(); + }).fail(function() { + Notification.exception({message: 'Error updating conditions'}); + }); +}; + +/** + * Apply actions to conditions. + */ +const applyConditionActions = () => { + document.getElementsByClassName(SELECTORS.CONDITIONS)[0].addEventListener('click', event => { + let element = event.target.tagName === 'SPAN' ? event.target : event.target.parentNode; + + // On a click to a delete icon, grab the position of the selected for deleting condition + // and remove an element of that position from the list of all existing conditions. + // Then save updated list of conditions to the rule form and render new list on a screen. + if (element.className === SELECTORS.CONDITION_DELETE_ACTION) { + Notification.confirm( + getString('confirm', 'moodle'), + getString('delete_confirm_condition', 'tool_dynamic_cohorts'), + getString('yes', 'moodle'), + getString('no', 'moodle'), + function () { + let sortorder = element.dataset.sortorder; + let conditions = getConditions() + .filter(c => c.sortorder != sortorder) + .map((condition, index) => ({...condition, sortorder: index})); + saveConditionsToRuleForm(conditions); + renderConditions(conditions); + }); + } + + // On a click to an edit icon for a selected condition, grab condition data from the list of + // all conditions by its position and then render modal form using the condition class. + if (element.className === SELECTORS.CONDITION_EDIT_ACTION) { + let sortorder = element.dataset.sortorder; + let conditions = getConditions(); + let condition = conditions[sortorder]; + + displayModalForm(condition.classname, condition); + } + }); +}; + +/** + * Init of the module. + */ +export const init = () => { + const addButton = document.querySelector(SELECTORS.ADD_CONDITION_BUTTON); + const conditionSelect = document.querySelector(SELECTORS.SELECT_CONDITION); + + addButton.addEventListener('click', (e) => { + e.preventDefault(); + const className = conditionSelect.value; + if (className !== '') { + displayModalForm(className, ''); + } + }); + applyConditionActions(); +}; diff --git a/classes/condition_base.php b/classes/condition_base.php index 218231a..c09f638 100644 --- a/classes/condition_base.php +++ b/classes/condition_base.php @@ -45,7 +45,7 @@ protected function __construct() { * @param \stdClass|null $record * @return \tool_dynamic_cohorts\condition_base|null */ - final public static function get_instance(int $id = 0, ?\stdClass $record = null):? condition_base { + final public static function get_instance(int $id = 0, ?\stdClass $record = null): ?condition_base { $condition = new condition($id, $record); // In case we are getting the instance without underlying persistent data. @@ -76,7 +76,7 @@ public static function retrieve_config_data(\stdClass $formdata): array { // Everything except these fields is considered as config data. unset($configdata['id']); unset($configdata['ruleid']); - unset($configdata['position']); + unset($configdata['sortorder']); return $configdata; } diff --git a/classes/condition_form.php b/classes/condition_form.php new file mode 100644 index 0000000..ad658a0 --- /dev/null +++ b/classes/condition_form.php @@ -0,0 +1,85 @@ +. + +namespace tool_dynamic_cohorts; + +use coding_exception; +use moodle_exception; +use moodleform; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * Condition form. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition_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('hidden', 'sortorder'); + $mform->setType('sortorder', PARAM_INT); + $mform->setDefault('sortorder', -1); + + $this->get_condition()->config_form_add($mform); + } + + /** + * Get condition instance. + * + * @return condition_base + */ + protected function get_condition(): condition_base { + if (empty($this->_customdata['classname'])) { + throw new coding_exception('Condition class name is not set'); + } + + $conditions = condition_manager::get_all_conditions(); + if (!array_key_exists($this->_customdata['classname'], $conditions)) { + throw new moodle_exception('Condition is broken. Invalid condition class.'); + } + + return $conditions[$this->_customdata['classname']]; + } + + /** + * Extra validation. + * + * @param array $data Data to validate. + * @param array $files Array of files. + * @return array of additional errors, or overridden errors. + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + + return array_merge($errors, $this->get_condition()->config_form_validate($data)); + } + +} diff --git a/classes/condition_manager.php b/classes/condition_manager.php index fc26725..578b789 100644 --- a/classes/condition_manager.php +++ b/classes/condition_manager.php @@ -57,4 +57,69 @@ public static function get_all_conditions(bool $excludebroken = true): array { return $instances; } + + /** + * Process conditions for submitted rule. + * + * @param rule $rule Rule instance/ + * @param \stdClass $formdata Data received from rule_form. + */ + public static function process_form(rule $rule, \stdClass $formdata): void { + if (!empty($formdata->isconditionschanged)) { + $submittedconditions = self::process_condition_json($formdata->conditionjson); + $oldconditions = $rule->get_condition_records(); + + $toupdate = []; + foreach ($submittedconditions as $condition) { + if (empty($condition->get('id'))) { + $condition->set('ruleid', $rule->get('id')); + $condition->create(); + } else { + $toupdate[$condition->get('id')] = $condition; + } + } + + $todelete = array_diff_key($oldconditions, $toupdate); + + foreach ($todelete as $conditiontodelete) { + $conditiontodelete->delete(); + } + + foreach ($toupdate as $conditiontoupdate) { + $conditiontoupdate->save(); + } + } + } + + /** + * Take JSON from the form and return a list of condition persistents. + * + * @param string $formjson Conditions JSON string from the rule form. + * + * @return condition[] + */ + private static function process_condition_json(string $formjson): array { + // Get only required fields for condition persistent. + $requiredconditionfield = array_diff( + array_keys(condition::properties_definition()), + ['ruleid', 'usermodified', 'timecreated', 'timemodified'] + ); + + $formjson = json_decode($formjson, true); + $submittedrecords = []; + + if (is_array($formjson)) { + // Filter out submitted conditions data to only fields required for condition persistent. + $submittedrecords = array_map(function (array $record) use ($requiredconditionfield): array { + return array_intersect_key($record, array_flip($requiredconditionfield)); + }, $formjson); + } + + $conditions = []; + foreach ($submittedrecords as $submittedrecord) { + $conditions[] = new condition($submittedrecord['id'], (object)$submittedrecord); + } + + return $conditions; + } } diff --git a/classes/external/condition_form.php b/classes/external/condition_form.php new file mode 100644 index 0000000..a7b4bf1 --- /dev/null +++ b/classes/external/condition_form.php @@ -0,0 +1,105 @@ +. + +namespace tool_dynamic_cohorts\external; + +use context_system; +use moodle_exception; +use external_api; +use external_function_parameters; +use external_single_structure; +use external_value; +use tool_dynamic_cohorts\condition_base; +use tool_dynamic_cohorts\condition_form as form; + +/** + * Condition form AJAX submission. + * + * @package tool_dynamic_cohorts + * @copyright 2024 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition_form extends external_api { + + /** + * Describes the parameters for validate_form webservice. + * @return external_function_parameters + */ + public static function submit_parameters(): external_function_parameters { + return new external_function_parameters([ + 'classname' => new external_value(PARAM_RAW, 'The condition class being submitted'), + 'jsonformdata' => new external_value(PARAM_RAW, 'The data from the form, encoded as a json array'), + ]); + } + + /** + * Submits a form via AJAX. + * + * @param string $classname Condition class name. + * @param string $jsonformdata The data from the form, encoded as a json array. + * @return array + */ + public static function submit(string $classname, string $jsonformdata): array { + $params = self::validate_parameters(self::submit_parameters(), + ['classname' => $classname, 'jsonformdata' => $jsonformdata]); + + // Always in a system context. + self::validate_context(context_system::instance()); + require_capability('tool/dynamic_cohorts:manage', context_system::instance()); + + $ajaxdata = []; + if (!empty($params['jsonformdata'])) { + $serialiseddata = json_decode($params['jsonformdata']); + parse_str($serialiseddata, $ajaxdata); + } + + $mform = new form(null, ['classname' => $classname], 'post', '', null, true, $ajaxdata); + if (!$mform->is_validated()) { + throw new moodle_exception('invaliddata'); + } + + $formdata = $mform->get_data(); + + $condition = condition_base::get_instance((int)$formdata->id, (object)['classname' => $classname]); + $condition->set_config_data($condition::retrieve_config_data($formdata)); + + return [ + 'id' => (int)$formdata->id, + 'sortorder' => (int)$formdata->sortorder, + 'classname' => $classname, + 'configdata' => json_encode($condition->get_config_data()), + 'description' => $condition->is_broken() ? $condition->get_broken_description() : $condition->get_config_description(), + 'name' => $condition->get_name(), + ]; + } + + /** + * Returns description of method result value. + * + * @return external_single_structure + */ + public static function submit_returns(): external_single_structure { + return new external_single_structure([ + 'id' => new external_value(PARAM_INT, ''), + 'sortorder' => new external_value(PARAM_INT, ''), + 'classname' => new external_value(PARAM_RAW, ''), + 'configdata' => new external_value(PARAM_RAW, ''), + 'description' => new external_value(PARAM_RAW, ''), + 'name' => new external_value(PARAM_RAW, ''), + ]); + } + +} diff --git a/classes/reportbuilder/local/systemreports/rules.php b/classes/reportbuilder/local/systemreports/rules.php index 71fa22e..499b2e4 100644 --- a/classes/reportbuilder/local/systemreports/rules.php +++ b/classes/reportbuilder/local/systemreports/rules.php @@ -24,6 +24,7 @@ use tool_dynamic_cohorts\reportbuilder\local\entities\rule_entity; use lang_string; use tool_dynamic_cohorts\rule; +use core_reportbuilder\local\report\column; /** * Rules admin table. @@ -53,11 +54,32 @@ protected function initialise(): void { $this->add_entity($cohortentity ->add_join("JOIN {cohort} {$cohortalias} ON {$cohortalias}.id = {$rulealias}.cohortid")); - $this->add_columns(); + $this->add_column_from_entity('rule_entity:name'); + $this->add_column_from_entity('rule_entity:description'); + $this->add_column_from_entity('cohort:name'); + $this->add_column_from_entity('rule_entity:bulkprocessing'); + + $this->add_column(new column( + 'conditions', + new lang_string('conditions', 'tool_dynamic_cohorts'), + $ruleentity->get_entity_name() + )) + ->set_type(column::TYPE_TEXT) + ->set_is_sortable(false) + ->add_fields("{$rulealias}.id") + ->add_callback(static function($id, $row): string { + $rule = new rule(0, $row); + return count($rule->get_condition_records()); + }); + + $this->add_column_from_entity('rule_entity:status'); + $this->add_actions(); $cohortentity->get_column('name') ->set_title(new lang_string('cohort', 'tool_dynamic_cohorts')); + + $this->set_initial_sort_column('rule_entity:name', SORT_ASC); } /** @@ -78,23 +100,6 @@ 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 * diff --git a/classes/rule.php b/classes/rule.php index 92d53be..611c99f 100644 --- a/classes/rule.php +++ b/classes/rule.php @@ -100,9 +100,41 @@ public function is_bulk_processing(): bool { /** * Return if the rule is broken. * + * @param bool $checkconditions If false, only DB state will be checked, otherwise conditions state will be checked. * @return bool */ - public function is_broken() : bool { - return (bool) $this->get('broken'); + public function is_broken(bool $checkconditions = false): bool { + if ($checkconditions) { + $broken = false; + + foreach ($this->get_condition_records() as $condition) { + $instance = condition_base::get_instance(0, $condition->to_record()); + if (!$instance || $instance->is_broken()) { + $broken = true; + break; + } + } + } else { + $broken = (bool) $this->get('broken'); + } + + return $broken; + } + + /** + * Mark rule as broken. + */ + public function mark_broken(): void { + $this->set('broken', 1); + $this->set('enabled', 0); + $this->save(); + } + + /** + * Mark rule as unbroken, + */ + public function mark_unbroken(): void { + $this->set('broken', 0); + $this->save(); } } diff --git a/classes/rule_form.php b/classes/rule_form.php index 8e8b645..9017f78 100644 --- a/classes/rule_form.php +++ b/classes/rule_form.php @@ -74,6 +74,21 @@ protected function definition() { $mform->addElement('hidden', 'conditionjson', '', ['id' => 'id_conditionjson']); $mform->setType('conditionjson', PARAM_RAW_TRIMMED); + // A flag to indicate whether the conditions were updated or not. + $mform->addElement('hidden', 'isconditionschanged', 0, ['id' => 'id_isconditionschanged']); + $mform->setType('isconditionschanged', PARAM_BOOL); + $mform->setDefault('isstepschanged', 0); + + $conditions = ['' => get_string('choosedots')]; + foreach (condition_manager::get_all_conditions() as $class => $condition) { + $conditions[$class] = $condition->get_name(); + } + + $group = []; + $group[] = $mform->createElement('select', 'condition', '', $conditions); + $group[] = $mform->createElement('button', 'conditionmodalbutton', get_string('addcondition', 'tool_dynamic_cohorts')); + $mform->addGroup($group, 'conditiongroup', get_string('condition', 'tool_dynamic_cohorts'), ' ', false); + $mform->addElement( 'advcheckbox', 'bulkprocessing', @@ -108,4 +123,25 @@ protected function get_cohort_options(): array { return $options; } + + /** + * Definition after data is set. + */ + public function definition_after_data() { + global $OUTPUT; + + $mform = $this->_form; + $conditionjson = $mform->getElementValue('conditionjson'); + $conditions = $OUTPUT->render_from_template('tool_dynamic_cohorts/conditions', [ + 'conditions' => json_decode($conditionjson, true), + ]); + + $mform->insertElementBefore( + $mform->createElement( + 'html', + '
' . $conditions . '
' + ), + 'buttonar' + ); + } } diff --git a/classes/rule_manager.php b/classes/rule_manager.php index 844d6fb..b426315 100644 --- a/classes/rule_manager.php +++ b/classes/rule_manager.php @@ -63,6 +63,27 @@ public static function build_delete_url(rule $rule): moodle_url { public static function build_data_for_form(rule $rule): array { $data = (array) $rule->to_record(); $data['conditionjson'] = ''; + $conditions = []; + + foreach ($rule->get_condition_records() as $condition) { + $instance = condition_base::get_instance(0, $condition->to_record()); + + if (!$instance) { + $name = $condition->get('classname'); + $description = $condition->get('configdata'); + } else { + $name = $instance->get_name(); + $description = $instance->is_broken() ? $instance->get_broken_description() : $instance->get_config_description(); + } + + $conditions[] = (array)$condition->to_record() + + ['description' => $description] + + ['name' => $name]; + } + + if (!empty($conditions)) { + $data['conditionjson'] = json_encode($conditions); + } return $data; } @@ -106,6 +127,13 @@ public static function process_form(\stdClass $formdata): rule { cohort_manager::unmanage_cohort($oldcohortid); cohort_manager::manage_cohort($formdata->cohortid); + condition_manager::process_form($rule, $formdata); + + if ($rule->is_broken(true)) { + $rule->mark_broken(); + } else { + $rule->mark_unbroken(); + } $transaction->allow_commit(); return $rule; diff --git a/db/services.php b/db/services.php new file mode 100644 index 0000000..373ebea --- /dev/null +++ b/db/services.php @@ -0,0 +1,36 @@ +. + +/** + * List of Web Services for the plugin. + * + * @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(); + +$functions = [ + 'tool_dynamic_cohorts_submit_condition_form' => [ + 'classname' => 'tool_dynamic_cohorts\external\condition_form', + 'methodname' => 'submit', + 'description' => 'Submits condition form', + 'type' => 'read', + 'capabilities' => 'tool/dynamic_cohorts:manage', + 'ajax' => true, + ], +]; diff --git a/edit.php b/edit.php index 729f024..901f5bc 100644 --- a/edit.php +++ b/edit.php @@ -67,6 +67,8 @@ redirect($manageurl); } +$PAGE->requires->js_call_amd('tool_dynamic_cohorts/condition_form', 'init'); + echo $OUTPUT->header(); echo $OUTPUT->heading($header); $mform->display(); diff --git a/lang/en/tool_dynamic_cohorts.php b/lang/en/tool_dynamic_cohorts.php index 9ef4cca..a569435 100644 --- a/lang/en/tool_dynamic_cohorts.php +++ b/lang/en/tool_dynamic_cohorts.php @@ -25,6 +25,7 @@ defined('MOODLE_INTERNAL') || die(); +$string['addcondition'] = 'Add a condition'; $string['addrule'] = 'Add a new rule'; $string['add_rule'] = 'Add new rule'; $string['bulkprocessing'] = 'Bulk processing'; @@ -32,8 +33,13 @@ $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['condition'] = 'Condition'; +$string['conditions'] = 'Conditions'; +$string['conditionchnagesnotapplied'] = 'Condition changes are not applied until you save the rule form'; +$string['conditionformtitle'] = 'Rule condition'; $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?'; $string['delete_rule'] = 'Delete rule'; $string['description'] = 'Description'; $string['description_help'] = 'As short description of this rule'; diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..6445add --- /dev/null +++ b/lib.php @@ -0,0 +1,62 @@ +. + +/** + * Callbacks. + * + * @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\condition_form; + +/** + * A new condition form as a fragment. + * + * @param array $args List of named arguments for the fragment loader. + * @return string + */ +function tool_dynamic_cohorts_output_fragment_condition_form(array $args): string { + $args = (object) $args; + + $classname = clean_param($args->classname, PARAM_RAW); + + $ajaxdata = []; + if (!empty($args->jsonformdata)) { + $serialiseddata = json_decode($args->jsonformdata); + parse_str($serialiseddata, $ajaxdata); + } + + $mform = new condition_form(null, ['classname' => $classname], 'post', '', null, true, $ajaxdata); + + unset($ajaxdata['classname']); + + if (!empty($args->defaults)) { + $data = json_decode($args->defaults, true); + if (!empty($data)) { + $confifdata = json_decode($data['configdata']); + $data = $data + (array)$confifdata; + $mform->set_data($data); + } + } + + if (!empty($ajaxdata)) { + $mform->is_validated(); + } + + return $mform->render(); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2518ced --- /dev/null +++ b/styles.css @@ -0,0 +1,6 @@ +.tool-dynamic-cohorts-condition-edit, +.tool-dynamic-cohorts-condition-delete, +.tool-dynamic-cohorts-condition-view { + cursor: pointer; + color: #0036ae; +} diff --git a/templates/conditions.mustache b/templates/conditions.mustache new file mode 100644 index 0000000..2da0231 --- /dev/null +++ b/templates/conditions.mustache @@ -0,0 +1,67 @@ +{{! + 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/conditions + + A list of conditions to display. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * conditions - A list of conditions. + * hidecontrols - True if need to hide controls. + + Example context (json): + { + "conditions" : "conditions", + "hidecontrols": "true" + } +}} + +
+ + + + + + + {{^hidecontrols}} + + {{/hidecontrols}} + + + + {{#conditions}} + + + + {{^hidecontrols}} + + {{/hidecontrols}} + + {{/conditions}} + +
{{# str }} name {{/ str }}{{# str }} description {{/ str }}{{# str }} actions {{/ str }}
{{{name}}}{{{description}}} + {{# pix }} t/edit, core, {{# str }} edit {{/ str }} {{/ pix }} + {{# pix }} t/delete, core, {{# str }} delete {{/ str }} {{/ pix }} +
+
+ diff --git a/tests/rule_manager_test.php b/tests/rule_manager_test.php index 60bfcd8..9e35d3f 100644 --- a/tests/rule_manager_test.php +++ b/tests/rule_manager_test.php @@ -21,6 +21,7 @@ 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\user_profile; /** * Tests for rule manager class. @@ -33,6 +34,20 @@ */ class rule_manager_test extends \advanced_testcase { + /** + * Get condition instance for testing. + * + * @param string $classname Class name. + * @param array $configdata Config data to be set. + * @return condition_base + */ + protected function get_condition(string $classname, array $configdata = []): condition_base { + $condition = condition_base::get_instance(0, (object)['classname' => $classname]); + $condition->set_config_data($configdata); + + return $condition; + } + /** * Test building edit URL. */ @@ -74,6 +89,23 @@ public function test_build_rule_data_for_form() { $this->resetAfterTest(); $rule = new rule(0, (object)['name' => 'Test rule', 'cohortid' => 0, 'description' => 'Test description']); + $instance = $this->get_condition( + 'tool_dynamic_cohorts\local\tool_dynamic_cohorts\condition\user_profile', + [ + 'profilefield' => 'username', + 'username_operator' => user_profile::TEXT_IS_EQUAL_TO, + 'username_value' => 'user1', + ] + ); + + $instance->get_record()->set('ruleid', $rule->get('id')); + $instance->get_record()->set('sortorder', 0); + $instance->get_record()->save(); + + $condition = condition::get_record(['id' => $instance->get_record()->get('id')]); + $conditions[] = (array) $condition->to_record() + + ['description' => $instance->get_config_description()] + + ['name' => $instance->get_name()]; $expected = [ 'name' => 'Test rule', @@ -86,7 +118,7 @@ public function test_build_rule_data_for_form() { 'timecreated' => 0, 'timemodified' => 0, 'usermodified' => 0, - 'conditionjson' => '', + 'conditionjson' => json_encode($conditions), ]; $this->assertSame($expected, rule_manager::build_data_for_form($rule)); @@ -329,6 +361,107 @@ public function test_process_rule_form_without_condition_data() { rule_manager::process_form((object)$formdata); } + /** + * Test conditions created when processing rule form data. + */ + public function test_process_rule_form_with_conditions() { + global $DB; + + $this->resetAfterTest(); + $cohort = $this->getDataGenerator()->create_cohort(); + + $this->assertEquals(0, $DB->count_records(rule::TABLE)); + + // Creating rule without conditions. + $formdata = ['name' => 'Test', 'cohortid' => $cohort->id, 'description' => '', + 'conditionjson' => '', 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + + // No conditions yet. Rule should be ok. + $this->assertFalse($rule->is_broken()); + // Rules disabled by default. + $this->assertFalse($rule->is_enabled()); + + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + $this->assertCount(0, $rule->get_condition_records()); + + // Updating the rule with 3 new conditions, but flag isconditionschanged is not set. + $conditionjson = json_encode([ + ['id' => 0, 'classname' => 'class1', 'sortorder' => 0, 'configdata' => ''], + ['id' => 0, 'classname' => 'class2', 'sortorder' => 1, 'configdata' => ''], + ['id' => 0, 'classname' => 'class3', 'sortorder' => 2, 'configdata' => ''], + ]); + + $formdata = ['id' => $rule->get('id'), 'name' => 'Test', 'enabled' => 1, 'cohortid' => $cohort->id, + 'description' => '', 'conditionjson' => $conditionjson, 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + $this->assertCount(0, $rule->get_condition_records()); + + // No conditions yet. Rule should be ok. + $this->assertFalse($rule->is_broken()); + // Rules disabled by default. + $this->assertFalse($rule->is_enabled()); + + // Updating the rule with 3 new conditions. Expecting 3 new conditions to be created. + $formdata = ['id' => $rule->get('id'), 'name' => 'Test', 'enabled' => 1, 'cohortid' => $cohort->id, + 'description' => '', 'conditionjson' => $conditionjson, 'isconditionschanged' => true, 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + $this->assertCount(3, $rule->get_condition_records()); + + // Rule should be broken as all conditions are broken (not existing class). + $this->assertTrue($rule->is_broken()); + $this->assertFalse($rule->is_enabled()); + + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class1', $rule->get('id')])); + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class2', $rule->get('id')])); + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class3', $rule->get('id')])); + + // Updating the rule with 1 new condition, 1 deleted condition (sortorder 1) and + // two updated conditions (sortorder added to a class name). Expecting 1 new condition, 2 updated and 1 deleted. + $conditions = $rule->get_condition_records(); + $conditionjson = []; + + foreach ($conditions as $condition) { + if ($condition->get('sortorder') != 1) { + $conditionjson[] = [ + 'id' => $condition->get('id'), + 'classname' => $condition->get('classname') . $condition->get('sortorder'), + 'sortorder' => $condition->get('sortorder'), + 'configdata' => $condition->get('configdata'), + ]; + } + } + + $conditionjson[] = ['id' => 0, 'classname' => 'class4', 'sortorder' => 2, 'configdata' => '']; + $conditionjson = json_encode($conditionjson); + + $formdata = ['id' => $rule->get('id'), 'name' => 'Test', 'enabled' => 1, 'cohortid' => $cohort->id, + 'description' => '', 'conditionjson' => $conditionjson, 'isconditionschanged' => true, 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + $this->assertCount(3, $rule->get_condition_records()); + $this->assertTrue($rule->is_broken()); + $this->assertFalse($rule->is_enabled()); + + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class10', $rule->get('id')])); + $this->assertFalse(condition::record_exists_select('classname = ? AND ruleid = ?', ['class2', $rule->get('id')])); + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class32', $rule->get('id')])); + $this->assertTrue(condition::record_exists_select('classname = ? AND ruleid = ?', ['class4', $rule->get('id')])); + + $formdata = ['id' => $rule->get('id'), 'name' => 'Test', 'enabled' => 1, 'cohortid' => $cohort->id, + 'description' => '', 'conditionjson' => '', 'isconditionschanged' => true, 'bulkprocessing' => 1]; + $rule = rule_manager::process_form((object)$formdata); + $this->assertEquals(1, $DB->count_records(rule::TABLE)); + $this->assertCount(0, $rule->get_condition_records()); + + // Should be unbroken as all broken conditions are gone. + $this->assertFalse($rule->is_broken()); + // Rules are disabled by default. + $this->assertFalse($rule->is_enabled()); + } + /** * Test rule deleting clear all related tables. */ diff --git a/tests/rule_test.php b/tests/rule_test.php index d283663..6a447b7 100644 --- a/tests/rule_test.php +++ b/tests/rule_test.php @@ -89,4 +89,39 @@ public function test_is_broken() { $rule = new rule(0, (object)['name' => 'Test not broken']); $this->assertFalse($rule->is_broken()); } + + /** + * Test is_broken when checking conditions. + */ + public function test_is_broken_check_conditions() { + $this->resetAfterTest(); + + $rule = new rule(0, (object)['name' => 'Test rule 1']); + $rule->save(); + + $condition = new condition(0, (object) ['ruleid' => $rule->get('id'), 'classname' => 'test', 'sortorder' => 0]); + $condition->save(); + + $this->assertFalse($rule->is_broken()); + $this->assertTrue($rule->is_broken(true)); + } + + /** + * Test marking a rule broken and unbroken. + */ + public function test_mark_broken_and_unbroken() { + $this->resetAfterTest(); + + $rule = new rule(0, (object)['name' => 'Test rule 2', 'broken' => 0, 'enabled' => 1]); + $this->assertFalse($rule->is_broken()); + $this->assertTrue($rule->is_enabled()); + + $rule->mark_broken(); + $this->assertTrue($rule->is_broken()); + $this->assertFalse($rule->is_enabled()); + + $rule->mark_unbroken(); + $this->assertFalse($rule->is_broken()); + $this->assertFalse($rule->is_enabled()); + } } diff --git a/version.php b/version.php index 462a96f..2f6c2d6 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tool_dynamic_cohorts'; -$plugin->release = 2024030401; -$plugin->version = 2024030401; +$plugin->release = 2024030600; +$plugin->version = 2024030600; $plugin->requires = 2022112800; $plugin->supported = [401, 403]; $plugin->maturity = MATURITY_ALPHA;