From 97eabd5120fd850a7d66f5fa6531fea34388d9c8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Wed, 28 Aug 2024 17:12:23 +1000 Subject: [PATCH] MDL-82120 mod_assign: recalculate penalty --- .../duedate/lang/en/gradepenalty_duedate.php | 2 +- lang/en/grades.php | 2 + .../amd/build/override_delete_modal.min.js | 3 + .../build/override_delete_modal.min.js.map | 1 + mod/assign/amd/src/override_delete_modal.js | 165 ++++++++++++++++++ mod/assign/classes/hook/hook_callbacks.php | 59 +++++++ .../classes/task/recalculate_penalties.php | 66 +++++++ mod/assign/db/hooks.php | 32 ++++ mod/assign/lang/en/assign.php | 2 + mod/assign/lib.php | 2 +- mod/assign/locallib.php | 7 + mod/assign/mod_form.php | 47 ++++- mod/assign/override_form.php | 32 ++++ mod/assign/overridedelete.php | 15 ++ mod/assign/overrideedit.php | 16 ++ mod/assign/overrides.php | 28 ++- .../templates/override_delete_modal.mustache | 52 ++++++ .../tests/behat/assign_group_override.feature | 1 + mod/assign/tests/penalty_test.php | 116 +++++++++--- mod/assign/version.php | 2 +- 20 files changed, 622 insertions(+), 28 deletions(-) create mode 100644 mod/assign/amd/build/override_delete_modal.min.js create mode 100644 mod/assign/amd/build/override_delete_modal.min.js.map create mode 100644 mod/assign/amd/src/override_delete_modal.js create mode 100644 mod/assign/classes/hook/hook_callbacks.php create mode 100644 mod/assign/classes/task/recalculate_penalties.php create mode 100644 mod/assign/db/hooks.php create mode 100644 mod/assign/templates/override_delete_modal.mustache diff --git a/grade/penalty/duedate/lang/en/gradepenalty_duedate.php b/grade/penalty/duedate/lang/en/gradepenalty_duedate.php index eea9027c80b82..cc2ee8087a43e 100644 --- a/grade/penalty/duedate/lang/en/gradepenalty_duedate.php +++ b/grade/penalty/duedate/lang/en/gradepenalty_duedate.php @@ -55,8 +55,8 @@ $string['privacy:metadata:gradepenalty_duedate_rule'] = 'Grade penalty due date table'; $string['privacy:metadata:gradepenalty_duedate_rule:usermodified'] = 'User who modified the rule'; $string['recalculatepenalty'] = 'Penalty recalculation'; -$string['resetconfirm'] = 'This will remove all rules set up for this context. Are you sure you want to continue?'; $string['recalculatepenalty_help'] = 'Recalculate and apply penalties for all submissions in this context.'; $string['recalculatepenaltybutton'] = 'Update grades'; $string['recalculatepenaltyconfirm'] = 'This will recalculate and apply penalties for all submissions in this context. Are you sure you want to continue?'; $string['recalculatepenaltysuccess'] = 'Successfully initiated penalty recalculation. There may be a delay before grades are updated.'; +$string['resetconfirm'] = 'This will remove all rules set up for this context. Are you sure you want to continue?'; diff --git a/lang/en/grades.php b/lang/en/grades.php index ae9d538250963..8829454cf39f5 100644 --- a/lang/en/grades.php +++ b/lang/en/grades.php @@ -526,6 +526,8 @@ $string['modgradeerrorbadpoint'] = 'Invalid grade value. This must be an integer between 1 and {$a}'; $string['modgradeerrorbadscale'] = 'Invalid scale selected. Please make sure you select a scale from the selections below.'; $string['modgrademaxgrade'] = 'Maximum grade'; +$string['modgraderecalculatepenalty'] = 'Recalculate penalty'; +$string['modgraderecalculatepenalty_help'] = 'The penalty will be recalculated for all users.'; $string['modgraderescalegrades'] = 'Rescale existing grades'; $string['modgraderescalegrades_help'] = 'When changing the maximum grades on a gradebook item you need to specify whether or not this will cause existing percentage grades to change as well. diff --git a/mod/assign/amd/build/override_delete_modal.min.js b/mod/assign/amd/build/override_delete_modal.min.js new file mode 100644 index 0000000000000..8addc2d578d81 --- /dev/null +++ b/mod/assign/amd/build/override_delete_modal.min.js @@ -0,0 +1,3 @@ +define("mod_assign/override_delete_modal",["exports","core/custom_interaction_events","core/modal"],(function(_exports,CustomEvents,_modal){var obj;function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,CustomEvents=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(CustomEvents),_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};let modal=null;const SELECTORS_DELETE_BUTTONS=".delete-override",SELECTORS_RECACULATION_CHECKBOX="#recalculatepenalties";class OverrideDeleteModal extends _modal.default{static async init(confirmMessage,showRecalculationCheckBox){modal=await OverrideDeleteModal.create({templateContext:{confirmmessage:confirmMessage,showpenaltyrecalculation:showRecalculationCheckBox}}),document.querySelectorAll(SELECTORS_DELETE_BUTTONS).forEach((button=>{button.addEventListener("click",(async event=>{event.preventDefault(),modal.setOverrideId(button.getAttribute("data-overrideid")),modal.setSessionKey(button.getAttribute("data-sesskey")),modal.show()}))}))}configure(modalConfig){modalConfig.large=!0,modalConfig.show=!1,modalConfig.removeOnClose=!1,super.configure(modalConfig)}constructor(root){super(root),this.recalculationCheckbox=this.getModal().find(SELECTORS_RECACULATION_CHECKBOX),this.setOverrideId(null),this.setSessionKey(null)}setOverrideId(id){this.overrideId=id}getOverrideId(){return this.overrideId}setSessionKey(key){this.sessionKey=key}getSessionKey(){return this.sessionKey}registerEventListeners(){super.registerEventListeners(this),this.registerCloseOnCancel(),this.getModal().on(CustomEvents.events.activate,this.getActionSelector("delete"),(()=>{this.deleteOverride()}))}deleteOverride(){const recalculate=this.recalculationCheckbox.prop("checked");window.location.href=M.cfg.wwwroot+"/mod/assign/overridedelete.php?id="+this.getOverrideId()+"&sesskey="+this.getSessionKey()+"&confirm=1"+(recalculate?"&recalculate=1":""),this.hide()}hide(){this.setOverrideId(null),this.setSessionKey(null),this.recalculationCheckbox.prop("checked",!1),super.hide()}}return _exports.default=OverrideDeleteModal,_defineProperty(OverrideDeleteModal,"TYPE","mod_assign/override_delete_modal"),_defineProperty(OverrideDeleteModal,"TEMPLATE","mod_assign/override_delete_modal"),OverrideDeleteModal.registerModalType(),_exports.default})); + +//# sourceMappingURL=override_delete_modal.min.js.map \ No newline at end of file diff --git a/mod/assign/amd/build/override_delete_modal.min.js.map b/mod/assign/amd/build/override_delete_modal.min.js.map new file mode 100644 index 0000000000000..923efca20172b --- /dev/null +++ b/mod/assign/amd/build/override_delete_modal.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"override_delete_modal.min.js","sources":["../src/override_delete_modal.js"],"sourcesContent":["import * as CustomEvents from 'core/custom_interaction_events';\nimport Modal from 'core/modal';\n\n// Custom modal.\nlet modal = null;\n\nconst SELECTORS = {\n DELETE_BUTTONS: '.delete-override',\n RECACULATION_CHECKBOX: '#recalculatepenalties',\n};\n\n/**\n * Custom Modal\n */\nexport default class OverrideDeleteModal extends Modal {\n static TYPE = \"mod_assign/override_delete_modal\";\n static TEMPLATE = \"mod_assign/override_delete_modal\";\n\n /**\n * Register the modal type.\n * @param {string} confirmMessage The message to display in the modal.\n * @param {boolean} showRecalculationCheckBox Whether to show the recalculation checkbox.\n * @returns {Promise}\n */\n static async init(confirmMessage, showRecalculationCheckBox) {\n // Create the modal.\n modal = await OverrideDeleteModal.create({\n templateContext: {\n confirmmessage: confirmMessage,\n showpenaltyrecalculation: showRecalculationCheckBox,\n },\n });\n\n // Add event listeners.\n document.querySelectorAll(SELECTORS.DELETE_BUTTONS).forEach(button => {\n button.addEventListener('click', async(event) => {\n event.preventDefault();\n modal.setOverrideId(button.getAttribute('data-overrideid'));\n modal.setSessionKey(button.getAttribute('data-sesskey'));\n modal.show();\n });\n });\n }\n\n /**\n * Configure the modal.\n *\n * @param {Object} modalConfig\n */\n configure(modalConfig) {\n // Add question modals are always large.\n modalConfig.large = true;\n\n // Always show on creation.\n modalConfig.show = false;\n modalConfig.removeOnClose = false;\n\n // Apply standard configuration.\n super.configure(modalConfig);\n }\n\n /**\n * Constructor.\n * Set required data to null.\n *\n * @param {HTMLElement} root\n */\n constructor(root) {\n super(root);\n\n // Recalculate penalties checkbox.\n this.recalculationCheckbox = this.getModal().find(SELECTORS.RECACULATION_CHECKBOX);\n\n // Data.\n this.setOverrideId(null);\n this.setSessionKey(null);\n }\n\n /**\n * Set the override id.\n *\n * @param {number} id The override id.\n */\n setOverrideId(id) {\n this.overrideId = id;\n }\n\n /**\n * Get the override id.\n *\n * @returns {*}\n */\n getOverrideId() {\n return this.overrideId;\n }\n\n /**\n * Set the session key.\n *\n * @param {string} key\n */\n setSessionKey(key) {\n this.sessionKey = key;\n }\n\n /**\n * Get the session key.\n *\n * @returns {*}\n */\n getSessionKey() {\n return this.sessionKey;\n }\n\n /**\n * Register events.\n *\n */\n registerEventListeners() {\n // Apply parent event listeners.\n super.registerEventListeners(this);\n\n // Register to close on cancel.\n this.registerCloseOnCancel();\n\n // Register the delete action.\n this.getModal().on(CustomEvents.events.activate, this.getActionSelector('delete'), () => {\n this.deleteOverride();\n });\n }\n\n /**\n * Delete a override.\n *\n */\n deleteOverride() {\n // Check if the recalculation checkbox is checked.\n const recalculate = this.recalculationCheckbox.prop('checked');\n\n // Redirect to the delete URL.\n window.location.href = M.cfg.wwwroot + '/mod/assign/overridedelete.php?id=' + this.getOverrideId() +\n '&sesskey=' + this.getSessionKey() + '&confirm=1'\n + (recalculate ? '&recalculate=1' : '');\n\n // Hide the modal.\n this.hide();\n }\n\n /**\n * Reset the modal data when hiding.\n *\n */\n hide() {\n // Reset the data.\n this.setOverrideId(null);\n this.setSessionKey(null);\n\n // Reset the recalculation checkbox.\n this.recalculationCheckbox.prop('checked', false);\n\n super.hide();\n }\n}\n\nOverrideDeleteModal.registerModalType();\n"],"names":["modal","SELECTORS","OverrideDeleteModal","Modal","confirmMessage","showRecalculationCheckBox","create","templateContext","confirmmessage","showpenaltyrecalculation","document","querySelectorAll","forEach","button","addEventListener","async","event","preventDefault","setOverrideId","getAttribute","setSessionKey","show","configure","modalConfig","large","removeOnClose","constructor","root","recalculationCheckbox","this","getModal","find","id","overrideId","getOverrideId","key","sessionKey","getSessionKey","registerEventListeners","registerCloseOnCancel","on","CustomEvents","events","activate","getActionSelector","deleteOverride","recalculate","prop","window","location","href","M","cfg","wwwroot","hide","registerModalType"],"mappings":"63CAIIA,MAAQ,WAENC,yBACc,mBADdA,gCAEqB,8BAMNC,4BAA4BC,iCAU3BC,eAAgBC,2BAE9BL,YAAcE,oBAAoBI,OAAO,CACrCC,gBAAiB,CACbC,eAAgBJ,eAChBK,yBAA0BJ,6BAKlCK,SAASC,iBAAiBV,0BAA0BW,SAAQC,SACxDA,OAAOC,iBAAiB,SAASC,MAAAA,QAC7BC,MAAMC,iBACNjB,MAAMkB,cAAcL,OAAOM,aAAa,oBACxCnB,MAAMoB,cAAcP,OAAOM,aAAa,iBACxCnB,MAAMqB,aAUlBC,UAAUC,aAENA,YAAYC,OAAQ,EAGpBD,YAAYF,MAAO,EACnBE,YAAYE,eAAgB,QAGtBH,UAAUC,aASpBG,YAAYC,YACFA,WAGDC,sBAAwBC,KAAKC,WAAWC,KAAK9B,sCAG7CiB,cAAc,WACdE,cAAc,MAQvBF,cAAcc,SACLC,WAAaD,GAQtBE,uBACWL,KAAKI,WAQhBb,cAAce,UACLC,WAAaD,IAQtBE,uBACWR,KAAKO,WAOhBE,+BAEUA,uBAAuBT,WAGxBU,6BAGAT,WAAWU,GAAGC,aAAaC,OAAOC,SAAUd,KAAKe,kBAAkB,WAAW,UAC1EC,oBAQbA,uBAEUC,YAAcjB,KAAKD,sBAAsBmB,KAAK,WAGpDC,OAAOC,SAASC,KAAOC,EAAEC,IAAIC,QAAU,qCAAuCxB,KAAKK,gBAC/E,YAAcL,KAAKQ,gBAAkB,cAClCS,YAAc,iBAAmB,SAGnCQ,OAOTA,YAESpC,cAAc,WACdE,cAAc,WAGdQ,sBAAsBmB,KAAK,WAAW,SAErCO,oEAlJOpD,2BACH,oDADGA,+BAEC,oCAoJtBA,oBAAoBqD"} \ No newline at end of file diff --git a/mod/assign/amd/src/override_delete_modal.js b/mod/assign/amd/src/override_delete_modal.js new file mode 100644 index 0000000000000..c1e38957b9760 --- /dev/null +++ b/mod/assign/amd/src/override_delete_modal.js @@ -0,0 +1,165 @@ +import * as CustomEvents from 'core/custom_interaction_events'; +import Modal from 'core/modal'; + +// Custom modal. +let modal = null; + +const SELECTORS = { + DELETE_BUTTONS: '.delete-override', + RECACULATION_CHECKBOX: '#recalculatepenalties', +}; + +/** + * Custom Modal + */ +export default class OverrideDeleteModal extends Modal { + static TYPE = "mod_assign/override_delete_modal"; + static TEMPLATE = "mod_assign/override_delete_modal"; + + /** + * Register the modal type. + * @param {string} confirmMessage The message to display in the modal. + * @param {boolean} showRecalculationCheckBox Whether to show the recalculation checkbox. + * @returns {Promise} + */ + static async init(confirmMessage, showRecalculationCheckBox) { + // Create the modal. + modal = await OverrideDeleteModal.create({ + templateContext: { + confirmmessage: confirmMessage, + showpenaltyrecalculation: showRecalculationCheckBox, + }, + }); + + // Add event listeners. + document.querySelectorAll(SELECTORS.DELETE_BUTTONS).forEach(button => { + button.addEventListener('click', async(event) => { + event.preventDefault(); + modal.setOverrideId(button.getAttribute('data-overrideid')); + modal.setSessionKey(button.getAttribute('data-sesskey')); + modal.show(); + }); + }); + } + + /** + * Configure the modal. + * + * @param {Object} modalConfig + */ + configure(modalConfig) { + // Add question modals are always large. + modalConfig.large = true; + + // Always show on creation. + modalConfig.show = false; + modalConfig.removeOnClose = false; + + // Apply standard configuration. + super.configure(modalConfig); + } + + /** + * Constructor. + * Set required data to null. + * + * @param {HTMLElement} root + */ + constructor(root) { + super(root); + + // Recalculate penalties checkbox. + this.recalculationCheckbox = this.getModal().find(SELECTORS.RECACULATION_CHECKBOX); + + // Data. + this.setOverrideId(null); + this.setSessionKey(null); + } + + /** + * Set the override id. + * + * @param {number} id The override id. + */ + setOverrideId(id) { + this.overrideId = id; + } + + /** + * Get the override id. + * + * @returns {*} + */ + getOverrideId() { + return this.overrideId; + } + + /** + * Set the session key. + * + * @param {string} key + */ + setSessionKey(key) { + this.sessionKey = key; + } + + /** + * Get the session key. + * + * @returns {*} + */ + getSessionKey() { + return this.sessionKey; + } + + /** + * Register events. + * + */ + registerEventListeners() { + // Apply parent event listeners. + super.registerEventListeners(this); + + // Register to close on cancel. + this.registerCloseOnCancel(); + + // Register the delete action. + this.getModal().on(CustomEvents.events.activate, this.getActionSelector('delete'), () => { + this.deleteOverride(); + }); + } + + /** + * Delete a override. + * + */ + deleteOverride() { + // Check if the recalculation checkbox is checked. + const recalculate = this.recalculationCheckbox.prop('checked'); + + // Redirect to the delete URL. + window.location.href = M.cfg.wwwroot + '/mod/assign/overridedelete.php?id=' + this.getOverrideId() + + '&sesskey=' + this.getSessionKey() + '&confirm=1' + + (recalculate ? '&recalculate=1' : ''); + + // Hide the modal. + this.hide(); + } + + /** + * Reset the modal data when hiding. + * + */ + hide() { + // Reset the data. + this.setOverrideId(null); + this.setSessionKey(null); + + // Reset the recalculation checkbox. + this.recalculationCheckbox.prop('checked', false); + + super.hide(); + } +} + +OverrideDeleteModal.registerModalType(); diff --git a/mod/assign/classes/hook/hook_callbacks.php b/mod/assign/classes/hook/hook_callbacks.php new file mode 100644 index 0000000000000..dc5ee94c5a0f8 --- /dev/null +++ b/mod/assign/classes/hook/hook_callbacks.php @@ -0,0 +1,59 @@ +. + +namespace mod_assign\hook; + +use core_grades\hook\before_penalty_recalculation; +use mod_assign\task\recalculate_penalties; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/assign/locallib.php'); + +/** + * Hook callbacks. + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_callbacks { + + /** + * Callback for before_penalty_recalculation. + * + * @param before_penalty_recalculation $hook + * @return void + */ + public static function extend_penalty_recalculation(before_penalty_recalculation $hook): void { + global $DB; + + switch ($hook->context->contextlevel) { + case CONTEXT_MODULE: + $cmid = $hook->context->instanceid; + $cm = get_coursemodule_from_id('assign', $cmid, 0, false, MUST_EXIST); + recalculate_penalties::queue($cm->instance, $hook->usermodified); + break; + case CONTEXT_COURSE: + $courseid = $hook->context->instanceid; + $assigns = $DB->get_records('assign', ['course' => $courseid]); + foreach ($assigns as $assign) { + recalculate_penalties::queue($assign->id, $hook->usermodified); + } + break; + } + } +} diff --git a/mod/assign/classes/task/recalculate_penalties.php b/mod/assign/classes/task/recalculate_penalties.php new file mode 100644 index 0000000000000..c76516856fb2e --- /dev/null +++ b/mod/assign/classes/task/recalculate_penalties.php @@ -0,0 +1,66 @@ +. + +namespace mod_assign\task; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/assign/lib.php'); +require_once($CFG->dirroot.'/course/lib.php'); + +use core\exception\moodle_exception; +use core\task\adhoc_task; + +/** + * Ad-hoc task to recalculate penalties for users in an assignment. + * + * @package mod_assign + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class recalculate_penalties extends adhoc_task { + + /** + * Execute the task. + */ + public function execute(): void { + global $DB; + try { + $assignid = $this->get_custom_data()->assignid; + $assign = $DB->get_record('assign', ['id' => $assignid], '*', MUST_EXIST); + $cm = get_coursemodule_from_instance('assign', $assignid, 0, false, MUST_EXIST); + $assign->cmidnumber = $cm->idnumber; + assign_update_grades($assign); + } catch (moodle_exception $e) { + debugging($e->getMessage(), DEBUG_DEVELOPER); + } + } + + /** + * Queue the task. + * + * @param int $assignid assignment id + * @param int $usermodified user who triggered the recalculation + */ + public static function queue(int $assignid, int $usermodified): void { + $task = new self(); + $task->set_custom_data((object)[ + 'assignid' => $assignid, + 'usermodified' => $usermodified, + ]); + \core\task\manager::queue_adhoc_task($task); + } +} diff --git a/mod/assign/db/hooks.php b/mod/assign/db/hooks.php new file mode 100644 index 0000000000000..c676df551f0f3 --- /dev/null +++ b/mod/assign/db/hooks.php @@ -0,0 +1,32 @@ +. + +/** + * Hooks listeners. + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => \core_grades\hook\before_penalty_recalculation::class, + 'callback' => \mod_assign\hook\hook_callbacks::class . '::extend_penalty_recalculation', + ], +]; diff --git a/mod/assign/lang/en/assign.php b/mod/assign/lang/en/assign.php index 62391c8528cb8..8558cb6e13090 100644 --- a/mod/assign/lang/en/assign.php +++ b/mod/assign/lang/en/assign.php @@ -464,6 +464,7 @@ $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->assign} - {$a->group}'; +$string['overriderecalculatepenalty'] = 'Recalculate penalty for user(s) in the override'; $string['overrides'] = 'Overrides'; $string['overrideuser'] = 'Override user'; $string['overrideusereventname'] = '{$a->assign} - Override'; @@ -472,6 +473,7 @@ $string['page-mod-assign-view'] = 'Assignment module main and submission page'; $string['paramtimeremaining'] = '{$a} remaining'; $string['participant'] = 'Participant'; +$string['penaltyduedatechangemessage'] = 'Some grades have already been awarded. In order to change the due date, disable/enable penalty, you must first choose whether or not to recalculate existing grades.'; $string['pluginadministration'] = 'Assignment administration'; $string['pluginname'] = 'Assignment'; $string['preventsubmissionnotingroup'] = 'Require group to make submission'; diff --git a/mod/assign/lib.php b/mod/assign/lib.php index 580dcd708768e..eb029d43325e7 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -1094,7 +1094,7 @@ function assign_grade_item_update($assign, $grades=null) { } else { // Multiple user grade update. foreach ($grades as $grade) { - if (is_object($grade) && isset($grade->userid)) { + if (is_object($grade) && isset($grade->userid) && isset($grade->rawgrade)) { $userids[] = $grade->userid; } } diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php index aa7a91a496095..dfcb8915f3837 100644 --- a/mod/assign/locallib.php +++ b/mod/assign/locallib.php @@ -1624,6 +1624,13 @@ public function update_instance($formdata) { $update->nosubmissions = (!$this->is_any_submission_plugin_enabled()) ? 1: 0; $DB->update_record('assign', $update); + // Check if we need to recalculate penalty for existing grades. + if (!empty($formdata->recalculatepenalty) && $formdata->recalculatepenalty === 'yes') { + $assign = clone $this->get_instance(); + $assign->cmidnumber = $this->get_course_module()->idnumber; + assign_update_grades($assign); + } + return $result; } diff --git a/mod/assign/mod_form.php b/mod/assign/mod_form.php index d2dba62dcf442..f44ca86948663 100644 --- a/mod/assign/mod_form.php +++ b/mod/assign/mod_form.php @@ -42,7 +42,7 @@ class mod_assign_mod_form extends moodleform_mod { * @return void */ public function definition() { - global $CFG, $COURSE, $DB; + global $CFG, $COURSE, $DB, $OUTPUT;; $mform = $this->_form; $mform->addElement('header', 'general', get_string('general', 'form')); @@ -95,9 +95,26 @@ public function definition() { $mform->addElement('date_time_selector', 'allowsubmissionsfromdate', $name, $options); $mform->addHelpButton('allowsubmissionsfromdate', 'allowsubmissionsfromdate', 'assign'); + // Add the option to recalculate the penalty if there is existing grade. + $penaltysettingmessage = ''; + if ($assignment->has_instance() + && \mod_assign\penalty\helper::is_penalty_enabled($assignment->get_instance()->id) + && $assignment->count_grades() > 0) { + // Create notification. + $penaltysettingmessage = $OUTPUT->notification(get_string('penaltyduedatechangemessage', 'assign'), 'warning', false); + $mform->addElement('html', $penaltysettingmessage); + $mform->addElement('select', 'recalculatepenalty', get_string('modgraderecalculatepenalty', 'grades'), [ + '' => get_string('choose'), + 'no' => get_string('no'), + 'yes' => get_string('yes'), + ]); + $mform->addHelpButton('recalculatepenalty', 'modgraderecalculatepenalty', 'grades'); + } + $name = get_string('duedate', 'assign'); $mform->addElement('date_time_selector', 'duedate', $name, array('optional'=>true)); $mform->addHelpButton('duedate', 'duedate', 'assign'); + $mform->disabledIf('duedate', 'recalculatepenalty', 'eq', ''); $name = get_string('cutoffdate', 'assign'); $mform->addElement('date_time_selector', 'cutoffdate', $name, array('optional'=>true)); @@ -246,6 +263,12 @@ public function definition() { // Add Penalty settings if the module supports it. if (\core_grades\local\penalty\manager::is_penalty_enabled_for_module('assign')) { + // Show the message if we need to change the penalty settings. + if (!empty($penaltysettingmessage)) { + $mform->addElement('html', $penaltysettingmessage); + } + + // Enable or disable the penalty settings. $mform->addElement('selectyesno', 'gradepenalty', get_string('gradepenalty', 'mod_assign')); $mform->addHelpButton('gradepenalty', 'gradepenalty', 'mod_assign'); $mform->setDefault('gradepenalty', 0); @@ -255,6 +278,9 @@ public function definition() { // Hide if the grade type is not set to point. $mform->hideIf('gradepenalty', 'grade[modgrade_type]', 'neq', 'point'); + + // Disable if the recalculate penalty is not set. + $mform->disabledIf('gradepenalty', 'recalculatepenalty', 'eq', ''); } $this->standard_coursemodule_elements(); @@ -263,6 +289,25 @@ public function definition() { $this->add_action_buttons(); } + /** + * Override definition after data has been set. + * + * The value of date time selector will be lost in a POST request, if the selector is disabled. + * So, we need to set the value again. + * + * return void + */ + public function definition_after_data() { + parent::definition_after_data(); + $mform = $this->_form; + + // The value of date time selector will be lost in a POST request. + $recalculatepenalty = optional_param('recalculatepenalty', null, PARAM_TEXT); + if ($recalculatepenalty === '') { + $mform->setConstant('duedate', $mform->_defaultValues['duedate']); + } + } + /** * Perform minimal validation on the settings form * @param array $data diff --git a/mod/assign/override_form.php b/mod/assign/override_form.php index 8f5f7cf49019b..33f2e0ee8d027 100644 --- a/mod/assign/override_form.php +++ b/mod/assign/override_form.php @@ -251,8 +251,22 @@ protected function definition() { get_string('allowsubmissionsfromdate', 'assign'), array('optional' => true)); $mform->setDefault('allowsubmissionsfromdate', $assigninstance->allowsubmissionsfromdate); + // Add the option to recalculate the penalty if there is existing grade. + if (\mod_assign\penalty\helper::is_penalty_enabled($assigninstance->id) && $this->assign->count_grades() > 0) { + // Create notification. + $notice = $OUTPUT->notification(get_string('penaltyduedatechangemessage', 'assign'), 'warning', false); + $mform->addElement('html', $notice); + $mform->addElement('select', 'recalculatepenalty', get_string('modgraderecalculatepenalty', 'grades'), [ + '' => get_string('choose'), + 'no' => get_string('no'), + 'yes' => get_string('yes'), + ]); + $mform->addHelpButton('recalculatepenalty', 'modgraderecalculatepenalty', 'grades'); + } + $mform->addElement('date_time_selector', 'duedate', get_string('duedate', 'assign'), array('optional' => true)); $mform->setDefault('duedate', $assigninstance->duedate); + $mform->disabledIf('duedate', 'recalculatepenalty', 'eq', ''); $mform->addElement('date_time_selector', 'cutoffdate', get_string('cutoffdate', 'assign'), array('optional' => true)); $mform->setDefault('cutoffdate', $assigninstance->cutoffdate); @@ -286,6 +300,24 @@ protected function definition() { } + /** + * Override definition after data has been set. + * + * The value of date time selector will be lost in a POST request, if the selector is disabled. + * So, we need to set the value again. + * + * return void + */ + public function definition_after_data() { + $mform = $this->_form; + + // The value of date time selector will be lost in a POST request. + $recalculatepenalty = optional_param('recalculatepenalty', null, PARAM_TEXT); + if ($recalculatepenalty === '') { + $mform->setConstant('duedate', $mform->_defaultValues['duedate']); + } + } + /** * Validate the submitted form data. * diff --git a/mod/assign/overridedelete.php b/mod/assign/overridedelete.php index 983d4b5f34018..eec75b5712178 100644 --- a/mod/assign/overridedelete.php +++ b/mod/assign/overridedelete.php @@ -30,6 +30,7 @@ $overrideid = required_param('id', PARAM_INT); $confirm = optional_param('confirm', false, PARAM_BOOL); +$recalculate = optional_param('recalculate', false, PARAM_BOOL); if (! $override = $DB->get_record('assign_overrides', array('id' => $overrideid))) { throw new \moodle_exception('invalidoverrideid', 'assign'); @@ -66,6 +67,20 @@ if ($confirm) { require_sesskey(); + if ($recalculate) { + $assignintance = clone $assign->get_instance(); + $assignintance->cmidnumber = $assign->get_course_module()->idnumber; + if (!$override->groupid) { + assign_update_grades($assignintance, $override->userid); + } else { + // If it is group mode. + $groupmembers = groups_get_members($override->groupid); + foreach ($groupmembers as $groupmember) { + assign_update_grades($assignintance, $groupmember->id); + } + } + } + $assign->delete_override($override->id); reorder_group_overrides($assign->get_instance()->id); diff --git a/mod/assign/overrideedit.php b/mod/assign/overrideedit.php index 88f38217baf8d..17fa1a321c990 100644 --- a/mod/assign/overrideedit.php +++ b/mod/assign/overrideedit.php @@ -238,6 +238,22 @@ $event->trigger(); } + // Check if we need to recalculate penalty for existing grades. + if (!empty($fromform->recalculatepenalty) && $fromform->recalculatepenalty === 'yes') { + $assignintance = clone $assign->get_instance(); + $assignintance->cmidnumber = $assign->get_course_module()->idnumber; + // If it is user mode. + if (!$groupmode) { + assign_update_grades($assignintance, $fromform->userid); + } else { + // If it is group mode. + $groupmembers = groups_get_members($fromform->groupid); + foreach ($groupmembers as $groupmember) { + assign_update_grades($assignintance, $groupmember->id); + } + } + } + assign_update_events($assign, $fromform); if (!empty($fromform->submitbutton)) { diff --git a/mod/assign/overrides.php b/mod/assign/overrides.php index e41efdb992cfa..7bff8049959ee 100644 --- a/mod/assign/overrides.php +++ b/mod/assign/overrides.php @@ -229,11 +229,31 @@ array('id' => $override->id, 'action' => 'duplicate')); $iconstr .= '' . $OUTPUT->pix_icon('t/copy', get_string('copy')) . ' '; + // Delete. - $deleteurlstr = $overridedeleteurl->out(true, - array('id' => $override->id, 'sesskey' => sesskey())); - $iconstr .= '' . - $OUTPUT->pix_icon('t/delete', get_string('delete')) . ' '; + $deletelink = html_writer::link("#", + $OUTPUT->pix_icon('t/delete', get_string('delete')), + [ + 'class' => 'delete-override', + 'data-overrideid' => $override->id, + 'data-sesskey' => sesskey(), + ] + ); + $iconstr .= $deletelink; + + // Confirm message for deletion. + if ($override->groupid) { + $group = $DB->get_record('groups', ['id' => $override->groupid], 'id, name'); + $confirmstr = get_string("overridedeletegroupsure", "assign", format_string($group->name, true, ['context' => $context])); + } else { + $userfieldsapi = \core_user\fields::for_name(); + $namefields = $userfieldsapi->get_sql('', false, '', '', false)->selects; + $user = $DB->get_record('user', ['id' => $override->userid], + 'id, ' . $namefields); + $confirmstr = get_string("overridedeleteusersure", "assign", fullname($user)); + } + // Add js script for "override delete" button. + $PAGE->requires->js_call_amd('mod_assign/override_delete_modal', 'init', [$confirmstr, $assign->gradepenalty]); if ($groupmode) { $usergroupstr = '' . diff --git a/mod/assign/templates/override_delete_modal.mustache b/mod/assign/templates/override_delete_modal.mustache new file mode 100644 index 0000000000000..af833401d1d0c --- /dev/null +++ b/mod/assign/templates/override_delete_modal.mustache @@ -0,0 +1,52 @@ +{{! + This file is part of Moodle - http://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 mod_assign/override_delete_modal + + Example context (json): + { + } + +}} +{{< core/modal }} + {{$title}}{{#str}} confirm {{/str}}{{/title}} + {{$body}} +
+
+
+

{{confirmmessage}}

+
+ + {{#showpenaltyrecalculation}} +
+
+ + + +
+
+ {{/showpenaltyrecalculation}} +
+
+ {{/body}} + {{$footer}} + + + {{/footer}} +{{/ core/modal }} \ No newline at end of file diff --git a/mod/assign/tests/behat/assign_group_override.feature b/mod/assign/tests/behat/assign_group_override.feature index 9fe42080e4033..29169cff7b7b5 100644 --- a/mod/assign/tests/behat/assign_group_override.feature +++ b/mod/assign/tests/behat/assign_group_override.feature @@ -33,6 +33,7 @@ Feature: Assign group override | activity | name | intro | course | assignsubmission_onlinetext_enabled | | assign | Test assignment name | Submit your online text | C1 | 1 | + @javascript Scenario: Add, modify then delete a group override Given I am on the "Test assignment name" Activity page logged in as teacher1 When I navigate to "Overrides" in current page administration diff --git a/mod/assign/tests/penalty_test.php b/mod/assign/tests/penalty_test.php index 010f300001259..0e03c87ae186c 100644 --- a/mod/assign/tests/penalty_test.php +++ b/mod/assign/tests/penalty_test.php @@ -38,6 +38,36 @@ final class penalty_test extends \advanced_testcase { // Use the generator helper. use mod_assign_test_generator; + /** + * Set up test + * + * @return array The course and student. + */ + protected function set_up_test(): array { + $this->setAdminUser(); + + // Hook mock up. + require_once(__DIR__ . '/fixtures/hooks/plugin1_hook_listener.php'); + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/hooks/hooks.php', + ]), + ); + + // Enable penalty feature. + set_config('gradepenalty_enabled', 1); + set_config('gradepenalty_supportedplugins', 'assign'); + \core\plugininfo\gradepenalty::enable_plugin('fake_deduction', true); + + // Create a course with user. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $student = $this->getDataGenerator()->create_and_enrol($course); + + return [$course, $student]; + } + /** * Test penalty support. * @@ -112,28 +142,10 @@ public function test_apply_penalty($submissiondate, $duedate, global $DB; $this->resetAfterTest(); - $this->setAdminUser(); - - // Hook mock up. - require_once(__DIR__ . '/fixtures/hooks/plugin1_hook_listener.php'); - \core\di::set( - \core\hook\manager::class, - \core\hook\manager::phpunit_get_instance([ - 'test_plugin1' => __DIR__ . '/fixtures/hooks/hooks.php', - ]), - ); - - // Enable penalty feature. - set_config('gradepenalty_enabled', 1); - set_config('gradepenalty_supportedplugins', 'assign'); - \core\plugininfo\gradepenalty::enable_plugin('fake_deduction', true); - - // Create a course with 2 users. - $generator = $this->getDataGenerator(); - $course = $generator->create_course(); - $student = $this->getDataGenerator()->create_and_enrol($course); + [$course, $student] = $this->set_up_test(); // Assignment. + $generator = $this->getDataGenerator(); $assignmentgenerator = $generator->get_plugin_generator('mod_assign'); $instance = $assignmentgenerator->create_instance([ 'course' => $course->id, @@ -195,4 +207,68 @@ public function test_apply_penalty($submissiondate, $duedate, ); $this->assertEquals($expectedgrade, $gradeitem->get_final($student->id)->finalgrade); } + + /** + * Test recalculation. + * + * @covers \mod_assign\penalty\helper::apply_penalty_to_submission + * + */ + public function test_recalculate_penalty(): void { + global $DB; + + $this->resetAfterTest(); + + [$course, $student] = $this->set_up_test(); + + // Assignment. + $duedate = time() + DAYSECS; + $generator = $this->getDataGenerator(); + $assignmentgenerator = $generator->get_plugin_generator('mod_assign'); + $instance = $assignmentgenerator->create_instance([ + 'course' => $course->id, + 'duedate' => $duedate, + 'assignsubmission_onlinetext_enabled' => 1, + 'gradepenalty' => 1, + 'grade' => 200, + ]); + $cm = get_coursemodule_from_instance('assign', $instance->id); + $context = \context_module::instance($cm->id); + $assign = new mod_assign_testable_assign($context, $cm, $course); + + // Add submission and grade. + $submissiondate = $duedate + HOURSECS; + $this->add_submission($student, $assign, 'Sample text'); + $this->submit_for_grading($student, $assign); + // Submission date. + $DB->set_field('assign_submission', 'timemodified', $submissiondate, ['userid' => $student->id]); + $assign->testable_apply_grade_to_user((object)['grade' => 50.0], $student->id, 0); + + $this->assertdebuggingcalledcount(2); + + // Check the grade. + $gradeitem = grade_item::fetch( + [ + 'courseid' => $course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $instance->id, + 'itemnumber' => 0, + ] + ); + $this->assertEquals(30, $gradeitem->get_final($student->id)->finalgrade); + + // Change the due date. + $duedate = time() + DAYSECS * 2; + $DB->set_field('assign', 'duedate', $duedate, ['id' => $instance->id]); + + // Recalculate the penalty. + $clonedassign = clone $assign->get_instance(); + $clonedassign->cmidnumber = $assign->get_course_module()->idnumber; + assign_update_grades($clonedassign); + $this->assertdebuggingcalledcount(2); + + // Check the grade. + $this->assertEquals(50, $gradeitem->get_final($student->id)->finalgrade); + } } diff --git a/mod/assign/version.php b/mod/assign/version.php index 8cdae5596acbd..3f36f06304dc5 100644 --- a/mod/assign/version.php +++ b/mod/assign/version.php @@ -25,5 +25,5 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics). -$plugin->version = 2024100701; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2024100702; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2024100100; // Requires this Moodle version.