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 00000000000..6306bd1ed16 --- /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};const SELECTORS_RECACULATION_CHECKBOX="#recalculatepenalties";class OverrideDeleteModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!1,modalConfig.removeOnClose=!0,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":"")}}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 00000000000..3966c9720f3 --- /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":["// This file is part of Moodle - http://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 * Modal for deleting an override with the option to recalculate penalties.\n *\n * @module mod_assign/override_delete_modal\n * @copyright 2025 Catalyst IT Australia Pty Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as CustomEvents from 'core/custom_interaction_events';\nimport Modal from 'core/modal';\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 * 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 = true;\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}\n\nOverrideDeleteModal.registerModalType();\n"],"names":["SELECTORS","OverrideDeleteModal","Modal","configure","modalConfig","large","show","removeOnClose","constructor","root","recalculationCheckbox","this","getModal","find","setOverrideId","setSessionKey","id","overrideId","getOverrideId","key","sessionKey","getSessionKey","registerEventListeners","registerCloseOnCancel","on","CustomEvents","events","activate","getActionSelector","deleteOverride","recalculate","prop","window","location","href","M","cfg","wwwroot","registerModalType"],"mappings":"+3CA0BMA,gCAEqB,8BAMNC,4BAA4BC,eAS7CC,UAAUC,aAENA,YAAYC,OAAQ,EAGpBD,YAAYE,MAAO,EACnBF,YAAYG,eAAgB,QAGtBJ,UAAUC,aASpBI,YAAYC,YACFA,WAGDC,sBAAwBC,KAAKC,WAAWC,KAAKb,sCAG7Cc,cAAc,WACdC,cAAc,MAQvBD,cAAcE,SACLC,WAAaD,GAQtBE,uBACWP,KAAKM,WAQhBF,cAAcI,UACLC,WAAaD,IAQtBE,uBACWV,KAAKS,WAOhBE,+BAEUA,uBAAuBX,WAGxBY,6BAGAX,WAAWY,GAAGC,aAAaC,OAAOC,SAAUhB,KAAKiB,kBAAkB,WAAW,UAC1EC,oBAQbA,uBAEUC,YAAcnB,KAAKD,sBAAsBqB,KAAK,WAGpDC,OAAOC,SAASC,KAAOC,EAAEC,IAAIC,QAAU,qCAAuC1B,KAAKO,gBAC/E,YAAcP,KAAKU,gBAAkB,cAClCS,YAAc,iBAAmB,iEAtG3B7B,2BACH,oDADGA,+BAEC,oCAwGtBA,oBAAoBqC"} \ No newline at end of file diff --git a/mod/assign/amd/build/override_modal_manager.min.js b/mod/assign/amd/build/override_modal_manager.min.js new file mode 100644 index 00000000000..5a023b354be --- /dev/null +++ b/mod/assign/amd/build/override_modal_manager.min.js @@ -0,0 +1,10 @@ +define("mod_assign/override_modal_manager",["exports","core/str","mod_assign/override_delete_modal"],(function(_exports,_str,_override_delete_modal){var obj; +/** + * Modal manager for the override delete modal. + * + * @module mod_assign/override_modal_manager + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_override_delete_modal=(obj=_override_delete_modal)&&obj.__esModule?obj:{default:obj};const SELECTORS_DELETE_BUTTONS=".delete-override",SELECTORS_PARENT_CONTAINER="#assignoverrides",SELECTORS_USER_GROUP_NAME=".usergroupname";_exports.init=(mode,showRecalculationCheckBox)=>{document.querySelector(SELECTORS_PARENT_CONTAINER).addEventListener("click",(async event=>{const button=event.target.closest(SELECTORS_DELETE_BUTTONS);if(!button)return;event.preventDefault();const name=event.target.closest("tr").querySelector(SELECTORS_USER_GROUP_NAME).innerText,confirmMessage=await getConfirmMessage(mode,name),modal=await _override_delete_modal.default.create({templateContext:{confirmmessage:confirmMessage,showpenaltyrecalculation:showRecalculationCheckBox}});modal.setOverrideId(button.getAttribute("data-overrideid")),modal.setSessionKey(button.getAttribute("data-sesskey")),modal.show()}))};const getConfirmMessage=async(mode,name)=>{switch(mode){case"group":return await(0,_str.get_string)("overridedeletegroupsure","assign",name);case"user":return await(0,_str.get_string)("overridedeleteusersure","assign",name);default:return""}}})); + +//# sourceMappingURL=override_modal_manager.min.js.map \ No newline at end of file diff --git a/mod/assign/amd/build/override_modal_manager.min.js.map b/mod/assign/amd/build/override_modal_manager.min.js.map new file mode 100644 index 00000000000..62877901a78 --- /dev/null +++ b/mod/assign/amd/build/override_modal_manager.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"override_modal_manager.min.js","sources":["../src/override_modal_manager.js"],"sourcesContent":["// This file is part of Moodle - http://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 * Modal manager for the override delete modal.\n *\n * @module mod_assign/override_modal_manager\n * @copyright 2025 Catalyst IT Australia Pty Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {get_string as getString} from 'core/str';\nimport OverrideDeleteModal from 'mod_assign/override_delete_modal';\n\nconst SELECTORS = {\n DELETE_BUTTONS: '.delete-override',\n PARENT_CONTAINER: '#assignoverrides',\n USER_GROUP_NAME: '.usergroupname',\n};\n\n/**\n * Initialise the modal manager.\n *\n * @param {string} mode The override mode.\n * @param {boolean} showRecalculationCheckBox Whether to show the recalculation checkbox.\n */\nexport const init = (mode, showRecalculationCheckBox) => {\n document.querySelector(SELECTORS.PARENT_CONTAINER).addEventListener('click', async(event) => {\n const button = event.target.closest(SELECTORS.DELETE_BUTTONS);\n\n if (!button) {\n return;\n }\n\n event.preventDefault();\n\n // Get the name of the user or group from the first column of the row.\n const name = event.target.closest('tr').querySelector(SELECTORS.USER_GROUP_NAME).innerText;\n\n // Get the confirm message for the modal.\n const confirmMessage = await getConfirmMessage(mode, name);\n\n // Create and show the modal.\n const modal = await OverrideDeleteModal.create({\n templateContext: {\n confirmmessage: confirmMessage,\n showpenaltyrecalculation: showRecalculationCheckBox,\n },\n });\n modal.setOverrideId(button.getAttribute('data-overrideid'));\n modal.setSessionKey(button.getAttribute('data-sesskey'));\n modal.show();\n });\n};\n\n/**\n * Get the confirm message for the modal.\n *\n * @param {string} mode The override mode.\n * @param {boolean} name The name of the user or group.\n * @returns {Promise} The confirm message.\n */\nconst getConfirmMessage = async(mode, name) => {\n switch (mode) {\n case \"group\":\n return await getString('overridedeletegroupsure', 'assign', name);\n case \"user\":\n return await getString('overridedeleteusersure', 'assign', name);\n default:\n return \"\";\n }\n};\n"],"names":["SELECTORS","mode","showRecalculationCheckBox","document","querySelector","addEventListener","async","button","event","target","closest","preventDefault","name","innerText","confirmMessage","getConfirmMessage","modal","OverrideDeleteModal","create","templateContext","confirmmessage","showpenaltyrecalculation","setOverrideId","getAttribute","setSessionKey","show"],"mappings":";;;;;;;8KA0BMA,yBACc,mBADdA,2BAEgB,mBAFhBA,0BAGe,+BASD,CAACC,KAAMC,6BACvBC,SAASC,cAAcJ,4BAA4BK,iBAAiB,SAASC,MAAAA,cACnEC,OAASC,MAAMC,OAAOC,QAAQV,8BAE/BO,cAILC,MAAMG,uBAGAC,KAAOJ,MAAMC,OAAOC,QAAQ,MAAMN,cAAcJ,2BAA2Ba,UAG3EC,qBAAuBC,kBAAkBd,KAAMW,MAG/CI,YAAcC,+BAAoBC,OAAO,CAC3CC,gBAAiB,CACbC,eAAgBN,eAChBO,yBAA0BnB,6BAGlCc,MAAMM,cAAcf,OAAOgB,aAAa,oBACxCP,MAAMQ,cAAcjB,OAAOgB,aAAa,iBACxCP,MAAMS,iBAWRV,kBAAoBT,MAAML,KAAMW,eAC1BX,UACC,qBACY,mBAAU,0BAA2B,SAAUW,UAC3D,oBACY,mBAAU,yBAA0B,SAAUA,oBAEpD"} \ 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 00000000000..b3d55a8ee12 --- /dev/null +++ b/mod/assign/amd/src/override_delete_modal.js @@ -0,0 +1,141 @@ +// 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 . + +/** + * Modal for deleting an override with the option to recalculate penalties. + * + * @module mod_assign/override_delete_modal + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import * as CustomEvents from 'core/custom_interaction_events'; +import Modal from 'core/modal'; + +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"; + + /** + * 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 = true; + + // 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' : ''); + } +} + +OverrideDeleteModal.registerModalType(); diff --git a/mod/assign/amd/src/override_modal_manager.js b/mod/assign/amd/src/override_modal_manager.js new file mode 100644 index 00000000000..630ed7dcfc2 --- /dev/null +++ b/mod/assign/amd/src/override_modal_manager.js @@ -0,0 +1,84 @@ +// 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 . + +/** + * Modal manager for the override delete modal. + * + * @module mod_assign/override_modal_manager + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {get_string as getString} from 'core/str'; +import OverrideDeleteModal from 'mod_assign/override_delete_modal'; + +const SELECTORS = { + DELETE_BUTTONS: '.delete-override', + PARENT_CONTAINER: '#assignoverrides', + USER_GROUP_NAME: '.usergroupname', +}; + +/** + * Initialise the modal manager. + * + * @param {string} mode The override mode. + * @param {boolean} showRecalculationCheckBox Whether to show the recalculation checkbox. + */ +export const init = (mode, showRecalculationCheckBox) => { + document.querySelector(SELECTORS.PARENT_CONTAINER).addEventListener('click', async(event) => { + const button = event.target.closest(SELECTORS.DELETE_BUTTONS); + + if (!button) { + return; + } + + event.preventDefault(); + + // Get the name of the user or group from the first column of the row. + const name = event.target.closest('tr').querySelector(SELECTORS.USER_GROUP_NAME).innerText; + + // Get the confirm message for the modal. + const confirmMessage = await getConfirmMessage(mode, name); + + // Create and show the modal. + const modal = await OverrideDeleteModal.create({ + templateContext: { + confirmmessage: confirmMessage, + showpenaltyrecalculation: showRecalculationCheckBox, + }, + }); + modal.setOverrideId(button.getAttribute('data-overrideid')); + modal.setSessionKey(button.getAttribute('data-sesskey')); + modal.show(); + }); +}; + +/** + * Get the confirm message for the modal. + * + * @param {string} mode The override mode. + * @param {boolean} name The name of the user or group. + * @returns {Promise} The confirm message. + */ +const getConfirmMessage = async(mode, name) => { + switch (mode) { + case "group": + return await getString('overridedeletegroupsure', 'assign', name); + case "user": + return await getString('overridedeleteusersure', 'assign', name); + default: + return ""; + } +}; diff --git a/mod/assign/backup/moodle2/backup_assign_stepslib.php b/mod/assign/backup/moodle2/backup_assign_stepslib.php index be6b6dcea21..3fea53bae84 100644 --- a/mod/assign/backup/moodle2/backup_assign_stepslib.php +++ b/mod/assign/backup/moodle2/backup_assign_stepslib.php @@ -96,7 +96,8 @@ class backup_assign_activity_structure_step extends backup_activity_structure_st 'activity', 'activityformat', 'timelimit', - 'submissionattachments')); + 'submissionattachments', + 'gradepenalty')); $userflags = new backup_nested_element('userflags'); @@ -129,6 +130,7 @@ class backup_assign_activity_structure_step extends backup_activity_structure_st 'timemodified', 'grader', 'grade', + 'penalty', 'attemptnumber')); $pluginconfigs = new backup_nested_element('plugin_configs'); diff --git a/mod/assign/backup/moodle2/restore_assign_stepslib.php b/mod/assign/backup/moodle2/restore_assign_stepslib.php index 403e879d925..8f458d06107 100644 --- a/mod/assign/backup/moodle2/restore_assign_stepslib.php +++ b/mod/assign/backup/moodle2/restore_assign_stepslib.php @@ -142,6 +142,11 @@ class restore_assign_activity_structure_step extends restore_activity_structure_ $data->grade = -($this->get_mappingid('scale', abs($data->grade))); } + // Grade penalty. + if (!isset($data->gradepenalty)) { + $data->gradepenalty = 0; + } + $newitemid = $DB->insert_record('assign', $data); $this->apply_activity_instance($newitemid); diff --git a/mod/assign/classes/penalty/helper.php b/mod/assign/classes/penalty/helper.php new file mode 100644 index 00000000000..03e5da96d63 --- /dev/null +++ b/mod/assign/classes/penalty/helper.php @@ -0,0 +1,148 @@ +. + +namespace mod_assign\penalty; + +use assign; +use context_module; +use core_grades\penalty_manager; +use grade_item; + +/** + * Helper class for penalty in assignment module. + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + /** + * Check if penalty is enabled for an assignment. + * + * @param int $assignid The assignment id. + */ + public static function is_penalty_enabled(int $assignid): bool { + // Get the assignment course module. + $cm = get_coursemodule_from_instance('assign', $assignid); + $context = context_module::instance($cm->id); + + // Get the assignment instance. + $assign = new assign($context, $cm, $cm->course); + + // Check if due date is set. + if (!$assign->get_instance()->duedate) { + return false; + } + + // Check if the grade type is set to GRADE_TYPE_VALUE (grade 1 to 100). + if ($assign->get_instance()->grade < GRADE_TYPE_VALUE) { + return false; + } + + // Check if the assignment is set to use penalty. + if (!$assign->get_instance()->gradepenalty) { + return false; + } + + return true; + } + + /** + * Apply penalty to a user. + * + * @param int $assignid The assignment id. + * @param int $userid The user id. + */ + public static function apply_penalty_to_user(int $assignid, int $userid): void { + global $CFG, $DB; + + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + + // Check if penalty is enabled for this assignment. + if (!self::is_penalty_enabled($assignid)) { + return; + } + + // Get the assignment course module. + $cm = get_coursemodule_from_instance('assign', $assignid); + $context = context_module::instance($cm->id); + + // Get the assignment instance. + $assign = new assign($context, $cm, $cm->course); + + // Find the graded attempt. + $sql = "SELECT MAX(attemptnumber) as attemptnumber + FROM {assign_grades} + WHERE assignment = :assignid + AND userid = :userid + AND grade >= 0"; + $assigngrade = $DB->get_record_sql($sql, ['assignid' => $assignid, 'userid' => $userid]); + + // Get the submission. + if ($assign->get_instance()->teamsubmission) { + $submission = $assign->get_group_submission($userid, 0, false, $assigngrade->attemptnumber); + } else { + $submission = $assign->get_user_submission($userid, false, $assigngrade->attemptnumber); + } + + // Check if the submission is null. + if ($submission === null) { + debugging('Submission not found for user ' . $userid . ' in assignment ' . $assignid + . ' attempt ' . $assigngrade->attemptnumber); + return; + } + + // Get submission date. + $submissiondate = $submission->timemodified; + + // Check if we have valid submission date. + if (empty($submissiondate)) { + debugging('Invalid submission date for user ' . $userid . ' in assignment ' . $assignid + . ' attempt ' . $assigngrade->attemptnumber); + return; + } + + // Get the due date from the override if it exists. Otherwise, retrieve the date from the assignment settings. + $duedate = $assign->override_exists($userid)->duedate ?? $assign->get_instance()->duedate; + + // Get extension. + $userflags = $assign->get_user_flags($userid, false); + if (!empty($userflags)) { + $duedate = max($userflags->extensionduedate, $duedate); + } + + // Get grade item. + $gradeitem = grade_item::fetch([ + 'courseid' => $assign->get_course()->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $assign->get_instance()->id, + 'itemnumber' => 0, + ]); + + // Apply penalty. + $container = penalty_manager::apply_grade_penalty_to_user($userid, $gradeitem, $submissiondate, $duedate); + $deductedpercentage = $container->get_penalty() / $container->get_grade_before_penalties() * 100; + + // Store the assign grade penalty. + $DB->set_field_select( + 'assign_grades', + 'penalty', + $deductedpercentage, + 'assignment = :assignid AND userid = :userid AND attemptnumber = :attemptnumber', + ['assignid' => $assignid, 'userid' => $userid, 'attemptnumber' => $assigngrade->attemptnumber]); + } +} diff --git a/mod/assign/classes/penalty_recalculator.php b/mod/assign/classes/penalty_recalculator.php new file mode 100644 index 00000000000..8f128d67e86 --- /dev/null +++ b/mod/assign/classes/penalty_recalculator.php @@ -0,0 +1,58 @@ +. + +namespace mod_assign; + +use core\context; +use mod_assign\task\recalculate_penalties; + +/** + * Recalculate penalties for the assignment. + * + * @package mod_assign + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class penalty_recalculator extends \core_grades\penalty_recalculator { + + /** + * Recalculate penalties for the assignment. + * + * @param context $context the context where the penalty is being recalculated. + * @param int $usermodified the user who triggered the recalculation. + * @return void + */ + public static function recalculate_penalty(context $context, int $usermodified): void { + global $CFG, $DB; + + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + + switch ($context->contextlevel) { + case CONTEXT_MODULE: + $cmid = $context->instanceid; + $cm = get_coursemodule_from_id('assign', $cmid, 0, false, MUST_EXIST); + recalculate_penalties::queue($cm->instance, $usermodified); + break; + case CONTEXT_COURSE: + $courseid = $context->instanceid; + $assigns = $DB->get_records('assign', ['course' => $courseid]); + foreach ($assigns as $assign) { + recalculate_penalties::queue($assign->id, $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 00000000000..53c920a01af --- /dev/null +++ b/mod/assign/classes/task/recalculate_penalties.php @@ -0,0 +1,61 @@ +. + +namespace mod_assign\task; + +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 $CFG, $DB; + + require_once($CFG->dirroot . '/mod/assign/lib.php'); + require_once($CFG->dirroot . '/course/lib.php'); + + $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); + } + + /** + * 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/install.xml b/mod/assign/db/install.xml index 8d18486e143..2c630023a1f 100644 --- a/mod/assign/db/install.xml +++ b/mod/assign/db/install.xml @@ -1,5 +1,5 @@ - @@ -41,6 +41,7 @@ + @@ -48,6 +49,7 @@ + @@ -83,6 +85,7 @@ + diff --git a/mod/assign/db/upgrade.php b/mod/assign/db/upgrade.php index a2df92483f7..53e0903253c 100644 --- a/mod/assign/db/upgrade.php +++ b/mod/assign/db/upgrade.php @@ -109,5 +109,37 @@ function xmldb_assign_upgrade($oldversion) { // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2024121801) { + + // Define field gradepenalty to be added to assign. + $table = new xmldb_table('assign'); + $field = new xmldb_field('gradepenalty', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'submissionattachments'); + + // Conditionally launch add field gradepenalty. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define index gradepenalty (not unique) to be added to assign. + $index = new xmldb_index('gradepenalty', XMLDB_INDEX_NOTUNIQUE, ['gradepenalty']); + + // Conditionally launch add index gradepenalty. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Define field penalty to be added to assign_grades. + $table = new xmldb_table('assign_grades'); + $field = new xmldb_field('penalty', XMLDB_TYPE_NUMBER, '10, 5', null, XMLDB_NOTNULL, null, '0', 'grade'); + + // Conditionally launch add field penalty. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Assign savepoint reached. + upgrade_mod_savepoint(true, 2024121801, 'assign'); + } + return true; } diff --git a/mod/assign/externallib.php b/mod/assign/externallib.php index 391a50d302c..98610bf159f 100644 --- a/mod/assign/externallib.php +++ b/mod/assign/externallib.php @@ -343,6 +343,7 @@ class mod_assign_external extends \mod_assign\external\external_api { 'm.duedate, ' . 'm.allowsubmissionsfromdate, '. 'm.grade, ' . + 'm.gradepenalty, ' . 'm.timemodified, '. 'm.completionsubmit, ' . 'm.cutoffdate, ' . @@ -422,6 +423,7 @@ class mod_assign_external extends \mod_assign\external\external_api { 'duedate' => $assign->get_instance()->duedate, 'allowsubmissionsfromdate' => $assign->get_instance()->allowsubmissionsfromdate, 'grade' => $module->grade, + 'gradepenalty' => $module->gradepenalty, 'timemodified' => $module->timemodified, 'completionsubmit' => $module->completionsubmit, 'cutoffdate' => $assign->get_instance()->cutoffdate, @@ -541,6 +543,7 @@ class mod_assign_external extends \mod_assign\external\external_api { 'duedate' => new external_value(PARAM_INT, 'assignment due date'), 'allowsubmissionsfromdate' => new external_value(PARAM_INT, 'allow submissions from date'), 'grade' => new external_value(PARAM_INT, 'grade type'), + 'gradepenalty' => new external_value(PARAM_INT, 'if enabled, penalty will be applied to late submissions'), 'timemodified' => new external_value(PARAM_INT, 'last time assignment was modified'), 'completionsubmit' => new external_value(PARAM_INT, 'if enabled, set activity as complete following submission'), 'cutoffdate' => new external_value(PARAM_INT, 'date after which submission is not accepted without an extension'), diff --git a/mod/assign/gradingtable.php b/mod/assign/gradingtable.php index cc69aa0691a..7747ed2218c 100644 --- a/mod/assign/gradingtable.php +++ b/mod/assign/gradingtable.php @@ -753,9 +753,10 @@ class assign_grading_table extends table_sql implements renderable { * @param boolean $editable * @param int $userid The user id of the user this grade belongs to * @param int $modified Timestamp showing when the grade was last modified + * @param float $deductedmark The deducted mark if penalty is applied * @return string The formatted grade */ - public function display_grade($grade, $editable, $userid, $modified) { + public function display_grade($grade, $editable, $userid, $modified, float $deductedmark = 0) { if ($this->is_downloading()) { if ($this->assignment->get_instance()->grade >= 0) { if ($grade == -1 || $grade === null) { @@ -772,7 +773,7 @@ class assign_grading_table extends table_sql implements renderable { return $scale; } } - return $this->assignment->display_grade($grade, $editable, $userid, $modified); + return $this->assignment->display_grade($grade, $editable, $userid, $modified, $deductedmark); } /** @@ -1035,7 +1036,7 @@ class assign_grading_table extends table_sql implements renderable { $grade = $this->get_gradebook_data_for_user($row->userid); if ($grade) { - $o = $this->display_grade($grade->grade, false, $row->userid, $row->timemarked); + $o = $this->display_grade($grade->grade, false, $row->userid, $row->timemarked, $grade->deductedmark); } return $o; diff --git a/mod/assign/lang/en/assign.php b/mod/assign/lang/en/assign.php index 8943de320af..bbe22c5841c 100644 --- a/mod/assign/lang/en/assign.php +++ b/mod/assign/lang/en/assign.php @@ -326,6 +326,8 @@ $string['gradelocked'] = 'This grade is locked or overridden in the gradebook.'; $string['gradeoutof'] = 'Grade out of {$a}'; $string['gradeoutofhelp'] = 'Grade'; $string['gradeoutofhelp_help'] = 'Enter the grade for the student\'s submission here. You may include decimals.'; +$string['gradepenalty'] = 'Grade penalties'; +$string['gradepenalty_help'] = 'If enabled, penalties will be applied to submissions'; $string['gradestudent'] = 'Grade student: (id={$a->id}, fullname={$a->fullname}). '; $string['grading'] = 'Grading'; $string['gradingchangessaved'] = 'The grade changes were saved'; @@ -462,6 +464,7 @@ $string['overridedeletegroupsure'] = 'Are you sure you want to delete the overri $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'; @@ -470,6 +473,7 @@ $string['page-mod-assign-x'] = 'Any assignment module page'; $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 f6ce696a70b..80c1ae53a4c 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -384,6 +384,8 @@ function assign_supports($feature) { return true; case FEATURE_GRADE_HAS_GRADE: return true; + case FEATURE_GRADE_HAS_PENALTY: + return true; case FEATURE_GRADE_OUTCOMES: return true; case FEATURE_BACKUP_MOODLE2: @@ -471,6 +473,12 @@ function assign_extend_settings_navigation(settings_navigation $settings, naviga key: 'mod_assign_submissions' ); } + + // Allow changing grade penalty settings at course module level, on assignment module. + // Other modules can choose to allow this change or not. + if (\mod_assign\penalty\helper::is_penalty_enabled($cm->instance)) { + \core_grades\penalty_manager::extend_navigation_module($settings, $navref); + } } /** @@ -1063,7 +1071,7 @@ function assign_grade_item_update($assign, $grades=null) { $grades = null; } - return grade_update('mod/assign', + $result = grade_update('mod/assign', $assign->courseid, 'mod', 'assign', @@ -1071,6 +1079,31 @@ function assign_grade_item_update($assign, $grades=null) { 0, $grades, $params); + + // Get lists of users whose grades are updated. + $userids = []; + if (is_array($grades)) { + // The $grades is array/object of grade(s). + // We are checking if it is single user (array with simple values such as userid and rawgrade). + // Or it is array of grade objects, for multiple users. + if (isset($grades['userid']) && isset($grades['rawgrade'])) { + // Single user grade update. + $userids = [$grades['userid']]; + } else { + // Multiple user grade update. + foreach ($grades as $grade) { + if (is_object($grade) && isset($grade->userid) && isset($grade->rawgrade)) { + $userids[] = $grade->userid; + } + } + } + } + // Apply penalty to each user. + foreach ($userids as $userid) { + \mod_assign\penalty\helper::apply_penalty_to_user($assign->id, $userid); + } + + return $result; } /** diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php index d3a1674388d..a09969c21ef 100644 --- a/mod/assign/locallib.php +++ b/mod/assign/locallib.php @@ -790,6 +790,10 @@ class assign { $update->markinganonymous = $formdata->markinganonymous; } } + + // Grade penalties. + $update->gradepenalty = $formdata->gradepenalty ?? 0; + $returnid = $DB->insert_record('assign', $update); $this->instance = $DB->get_record('assign', array('id'=>$returnid), '*', MUST_EXIST); // Cache the course record. @@ -1579,6 +1583,9 @@ class assign { $update->markinganonymous = 0; } + // Grade penalties. + $update->gradepenalty = $formdata->gradepenalty ?? 0; + $result = $DB->update_record('assign', $update); $this->instance = $DB->get_record('assign', array('id'=>$update->id), '*', MUST_EXIST); @@ -1611,6 +1618,13 @@ class assign { $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; } @@ -2015,10 +2029,11 @@ class assign { * @param boolean $editing Are we allowing changes to this grade? * @param int $userid The user id the grade belongs to * @param int $modified Timestamp from when the grade was last modified + * @param float $deductedmark The deducted mark if penalty is applied * @return string User-friendly representation of grade */ - public function display_grade($grade, $editing, $userid=0, $modified=0) { - global $DB; + public function display_grade($grade, $editing, $userid = 0, $modified = 0, float $deductedmark = 0) { + global $DB, $PAGE; static $scalegrades = array(); @@ -2043,7 +2058,6 @@ class assign { maxlength="10" class="quickgrade"/>'; $o .= ' / ' . format_float($this->get_instance()->grade, $this->get_grade_item()->get_decimals()); - return $o; } else { if ($grade == -1 || $grade === null) { $o .= '-'; @@ -2055,9 +2069,24 @@ class assign { $o .= ' / ' . format_float($this->get_instance()->grade, $item->get_decimals()); } } - return $o; } + // Add penalty indicator, icon only. + $penaltyindicator = ''; + if ($deductedmark > 0) { + $gradeitem = $this->get_grade_item(); + $ispenaltyapplied = $gradeitem && $gradeitem->get_grade($userid)->is_penalty_applied_to_final_grade(); + // If the user is set, we need to check if the penalty is applied to overridden grade. + if ($userid == 0 || $ispenaltyapplied) { + $usergrade = new \grade_grade(); + $usergrade->deductedmark = $deductedmark; + $indicator = new \core_grades\output\penalty_indicator(2, $usergrade); + $renderer = $PAGE->get_renderer('core_grades'); + $penaltyindicator = $renderer->render_penalty_indicator($indicator); + } + } + + return $penaltyindicator . $o; } else { // Scale. if (empty($this->cache['scale'])) { @@ -3912,6 +3941,7 @@ class assign { if ($attemptnumber >= 0) { $grade->attemptnumber = $attemptnumber; } + $grade->penalty = 0.0; $gid = $DB->insert_record('assign_grades', $grade); $grade->id = $gid; @@ -5399,7 +5429,7 @@ class assign { ); $gradefordisplay = $gradebookgrade->str_long_grade; } else { - $gradefordisplay = $this->display_grade($gradebookgrade->grade, false); + $gradefordisplay = $this->display_grade($gradebookgrade->grade, false, 0, 0, $gradebookgrade->deductedmark); } $gradeddate = $gradebookgrade->dategraded; @@ -5606,16 +5636,18 @@ class assign { } } + [$penalisedgrade, $deductedmark] = $this->calculate_penalised_grade($grade); + // Now get the gradefordisplay. if ($controller) { $controller->set_grade_range(make_grades_menu($this->get_instance()->grade), $this->get_instance()->grade > 0); $grade->gradefordisplay = $controller->render_grade($PAGE, $grade->id, $gradingitem, - $grade->grade, + $penalisedgrade, $cangrade); } else { - $grade->gradefordisplay = $this->display_grade($grade->grade, false); + $grade->gradefordisplay = $this->display_grade($penalisedgrade, false, 0, 0, $deductedmark); } } @@ -5623,6 +5655,28 @@ class assign { return $grades; } + /** + * Calculate penalised grade and deducted mark. + * + * @param stdClass $grade The grade object + * @return array [$penalisedgrade, $deductedmark] the penalised grade and the deducted mark + */ + public function calculate_penalised_grade(stdClass $grade): array { + $penalisedgrade = $grade->grade; + $deductedmark = 0; + + // No calculation needed if the grade is null or negative. + if (is_null($penalisedgrade) || $penalisedgrade < 0) { + return [$penalisedgrade, $deductedmark]; + } + + if ($grade->penalty > 0) { + $deductedmark = $grade->grade * $grade->penalty / 100; + $penalisedgrade = $grade->grade - $deductedmark; + } + return [$penalisedgrade, $deductedmark]; + } + /** * Get the submissions for all previous attempts. * @@ -7697,7 +7751,7 @@ class assign { * @return void */ public function add_grade_form_elements(MoodleQuickForm $mform, stdClass $data, $params) { - global $USER, $CFG, $SESSION; + global $USER, $CFG, $SESSION, $PAGE; $settings = $this->get_instance(); $rownum = isset($params['rownum']) ? $params['rownum'] : 0; @@ -7819,6 +7873,17 @@ class assign { $gradestring = $usergrade; } + // Penalty indicator. + $userassigngrade = $gradinginfo->items[0]->grades[$userid]; + if (isset($userassigngrade->grade) && $userassigngrade->deductedmark > 0) { + $gradegrade = new \grade_grade(); + $gradegrade->deductedmark = $userassigngrade->deductedmark; + $indicator = new \core_grades\output\penalty_indicator(2, $gradegrade); + $renderer = $PAGE->get_renderer('core_grades'); + $penaltyindicator = $renderer->render_penalty_indicator($indicator); + $gradestring = $penaltyindicator . $gradestring; + } + if ($this->get_instance()->markingworkflow) { $states = $this->get_marking_workflow_states_for_current_user(); $options = array('' => get_string('markingworkflowstatenotmarked', 'assign')) + $states; diff --git a/mod/assign/mod_form.php b/mod/assign/mod_form.php index 48365ab997d..801ba2be07c 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; + global $CFG, $COURSE, $OUTPUT;; $mform = $this->_form; $mform->addElement('header', 'general', get_string('general', 'form')); @@ -83,9 +83,26 @@ class mod_assign_mod_form extends moodleform_mod { $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)); @@ -232,12 +249,53 @@ class mod_assign_mod_form extends moodleform_mod { $mform->hideIf('markinganonymous', 'markingworkflow', 'eq', 0); $mform->hideIf('markinganonymous', 'blindmarking', 'eq', 0); + // Add Penalty settings if the module supports it. + if (\core_grades\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); + + // Hide if the due date is not enabled. + $mform->hideIf('gradepenalty', 'duedate[enabled]'); + + // 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(); $this->apply_admin_defaults(); $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 8f5f7cf4901..33f2e0ee8d0 100644 --- a/mod/assign/override_form.php +++ b/mod/assign/override_form.php @@ -251,8 +251,22 @@ class assign_override_form extends moodleform { 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 @@ class assign_override_form extends moodleform { } + /** + * 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 983d4b5f340..eec75b57121 100644 --- a/mod/assign/overridedelete.php +++ b/mod/assign/overridedelete.php @@ -30,6 +30,7 @@ require_once($CFG->dirroot.'/mod/assign/override_form.php'); $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 (!empty($override->userid)) { 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 88f38217baf..17fa1a321c9 100644 --- a/mod/assign/overrideedit.php +++ b/mod/assign/overrideedit.php @@ -238,6 +238,22 @@ if ($mform->is_cancelled()) { $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 e41efdb992c..5b93bbefadd 100644 --- a/mod/assign/overrides.php +++ b/mod/assign/overrides.php @@ -174,6 +174,8 @@ $overrideediturl = new moodle_url('/mod/assign/overrideedit.php'); $hasinactive = false; // Whether there are any inactive overrides. +$PAGE->requires->js_call_amd('mod_assign/override_modal_manager', 'init', [$mode, !empty($assign->gradepenalty)]); + foreach ($overrides as $override) { $fields = array(); @@ -229,15 +231,24 @@ foreach ($overrides as $override) { 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; if ($groupmode) { - $usergroupstr = '' . - format_string($override->name, true, ['context' => $context]) . ''; + $usergroupstr = html_writer::link( + $groupurl->out(true, ['group' => $override->groupid]), + format_string($override->name, true, ['context' => $context]), + ['class' => 'usergroupname'] + ); // Move up. if ($override->sortorder > 1) { @@ -259,9 +270,11 @@ foreach ($overrides as $override) { } else { - $usergroupstr = html_writer::link($userurl->out(false, - array('id' => $override->userid, 'course' => $course->id)), - fullname($override)); + $usergroupstr = html_writer::link( + $userurl->out(false, ['id' => $override->userid, 'course' => $course->id]), + fullname($override), + ['class' => 'usergroupname'] + ); } $class = ''; diff --git a/mod/assign/templates/override_delete_modal.mustache b/mod/assign/templates/override_delete_modal.mustache new file mode 100644 index 00000000000..af833401d1d --- /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/backup/restore_date_test.php b/mod/assign/tests/backup/restore_date_test.php index a438e7a9890..c02ae086453 100644 --- a/mod/assign/tests/backup/restore_date_test.php +++ b/mod/assign/tests/backup/restore_date_test.php @@ -126,6 +126,7 @@ final class restore_date_test extends \restore_date_testcase { ], 'timelimit' => DAYSECS, 'submissionattachments' => 1, + 'gradepenalty' => 1, ]; $assign = $this->getDataGenerator()->create_module('assign', $record); diff --git a/mod/assign/tests/behat/assign_group_override.feature b/mod/assign/tests/behat/assign_group_override.feature index 9fe42080e40..29169cff7b7 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/behat/behat_mod_assign.php b/mod/assign/tests/behat/behat_mod_assign.php index 7d26105f466..e5c4d2b6955 100644 --- a/mod/assign/tests/behat/behat_mod_assign.php +++ b/mod/assign/tests/behat/behat_mod_assign.php @@ -71,4 +71,19 @@ class behat_mod_assign extends behat_base { $criteriacheck++; } } + + /** + * Enable grade penalty. + * + * @Given I enable grade penalties for assignment + */ + public function i_enable_grade_penalties_for_assignment(): void { + global $DB; + + \core_grades\penalty_manager::enable_module('assign'); + \core\plugininfo\gradepenalty::enable_plugin('duedate', true); + + $rule = ['contextid' => 1, 'overdueby' => DAYSECS, 'penalty' => 10, 'sortorder' => 0]; + $DB->insert_record('gradepenalty_duedate_rule', (object) $rule); + } } diff --git a/mod/assign/tests/behat/display_grade.feature b/mod/assign/tests/behat/display_grade.feature index ad6c265a400..f93dcae7fcd 100644 --- a/mod/assign/tests/behat/display_grade.feature +++ b/mod/assign/tests/behat/display_grade.feature @@ -1,4 +1,4 @@ -@mod @mod_assign +@mod @mod_assign @assign_grade Feature: Check that the assignment grade can be updated correctly In order to ensure that the grade is shown correctly in the grading table As a teacher @@ -70,3 +70,45 @@ Feature: Check that the assignment grade can be updated correctly And I press "Save changes" And I follow "View all submissions" Then "Student 1" row "Grade" column of "generaltable" table should contain "50.00" + + @javascript + Scenario: Update the grade for an assignment with penalty + Given the following "courses" exist: + | fullname | shortname | category | groupmode | + | Course 1 | C1 | 0 | 1 | + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student10@example.com | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | Group 1 | C1 | G1 | + And I enable grade penalties for assignment + And the following "activity" exists: + | activity | assign | + | course | C1 | + | name | Test assignment name | + | intro | Test assignment description | + | grade | 100 | + | duedate | ##yesterday## | + | gradepenalty | 1 | + | assignsubmission_onlinetext_enabled | 1 | + | submissiondrafts | 0 | + # Add a submission. + And the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | Test assignment name | student1 | I'm the student first submission | + And I am on the "Test assignment name" Activity page logged in as teacher1 + When I change window size to "large" + And I go to "Student 1" "Test assignment name" activity advanced grading page + And I set the field "Grade out of 100" to "100" + And I set the field "Notify student" to "0" + And I press "Save changes" + And I follow "View all submissions" + And "Student 1" row "Grade" column of "generaltable" table should contain "100.00" + And "Student 1" row "Final grade" column of "generaltable" table should contain "90.00" + And the "title" attribute of ".penalty-indicator-icon" "css_element" should contain "Late penalty applied -10.00 marks" diff --git a/mod/assign/tests/externallib_test.php b/mod/assign/tests/externallib_test.php index 38bc6bb736e..e7570bd9b55 100644 --- a/mod/assign/tests/externallib_test.php +++ b/mod/assign/tests/externallib_test.php @@ -189,6 +189,7 @@ final class externallib_test extends \mod_assign\externallib_advanced_testcase { 'markingallocation' => 1, 'blindmarking' => 1, 'markinganonymous' => 1, + 'gradepenalty' => 1, 'activityeditor' => [ 'text' => 'Test activity', 'format' => 1, @@ -253,6 +254,7 @@ final class externallib_test extends \mod_assign\externallib_advanced_testcase { $this->assertEquals(1, $assignment['markingallocation']); $this->assertEquals(1, $assignment['blindmarking']); $this->assertEquals(1, $assignment['markinganonymous']); + $this->assertEquals(1, $assignment['gradepenalty']); $this->assertEquals(0, $assignment['preventsubmissionnotingroup']); $this->assertEquals(0, $assignment['timelimit']); $this->assertEquals(0, $assignment['submissionattachments']); @@ -286,6 +288,7 @@ final class externallib_test extends \mod_assign\externallib_advanced_testcase { $this->assertEquals(1, $assignment['markingallocation']); $this->assertEquals(1, $assignment['blindmarking']); $this->assertEquals(1, $assignment['markinganonymous']); + $this->assertEquals(1, $assignment['gradepenalty']); $this->assertEquals(0, $assignment['preventsubmissionnotingroup']); $result = mod_assign_external::get_assignments(array($course2->id)); diff --git a/mod/assign/tests/fixtures/fakeplugins/fake_deduction/classes/penalty_calculator.php b/mod/assign/tests/fixtures/fakeplugins/fake_deduction/classes/penalty_calculator.php new file mode 100644 index 00000000000..774079f9f2a --- /dev/null +++ b/mod/assign/tests/fixtures/fakeplugins/fake_deduction/classes/penalty_calculator.php @@ -0,0 +1,46 @@ +. + +namespace gradepenalty_fake_deduction; + +use core_grades\penalty_container; + +/** + * Penalty plugins must override this class to implement their own penalty calculation. + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class penalty_calculator extends \core_grades\penalty_calculator { + /** + * Calculate the penalty for the given activity. + * + * @param penalty_container $container The penalty container. + */ + public static function calculate_penalty(penalty_container $container): void { + + // Dates. + debugging('Submission date: ' . $container->get_submission_date()); + debugging('Due date: ' . $container->get_due_date()); + + // Calculate the deducted grade based on the max grade. + if ($container->get_submission_date() > $container->get_due_date()) { + $deductedgrade = $container->get_max_grade() * 10 / 100; + $container->aggregate_penalty($deductedgrade); + } + } +} diff --git a/mod/assign/tests/fixtures/fakeplugins/fake_deduction/version.php b/mod/assign/tests/fixtures/fakeplugins/fake_deduction/version.php new file mode 100644 index 00000000000..cd1b5ea007a --- /dev/null +++ b/mod/assign/tests/fixtures/fakeplugins/fake_deduction/version.php @@ -0,0 +1,29 @@ +. + +/** + * Fake component for testing + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2022050200; +$plugin->requires = 2022041200; +$plugin->component = 'gradepenalty_fake_deduction'; diff --git a/mod/assign/tests/penalty_test.php b/mod/assign/tests/penalty_test.php new file mode 100644 index 00000000000..7173ddc68c6 --- /dev/null +++ b/mod/assign/tests/penalty_test.php @@ -0,0 +1,272 @@ +. + +namespace mod_assign; + +use core_component; +use core_grades\penalty_manager; +use grade_item; +use mod_assign_test_generator; +use mod_assign_testable_assign; +use ReflectionClass; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/assign/locallib.php'); +require_once($CFG->dirroot . '/mod/assign/tests/generator.php'); + +/** + * Penalty test. + * + * @package mod_assign + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +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 { + global $CFG; + $this->setAdminUser(); + + penalty_manager::enable_module('assign'); + + // Load a mocked grade penalty plugin. + $mockedcomponent = new ReflectionClass(core_component::class); + $mockedplugins = $mockedcomponent->getProperty('plugins'); + $plugins = $mockedplugins->getValue(); + $plugins['gradepenalty'] = ["fake_deduction" => "{$CFG->dirroot}/mod/assign/tests/fixtures/fakeplugins/fake_deduction"]; + // Load the penalty_calculator class. + require_once($CFG->dirroot . '/mod/assign/tests/fixtures/fakeplugins/fake_deduction/classes/penalty_calculator.php'); + $mockedplugins->setValue(null, $plugins); + + \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. + * + * @covers ::assign_supports + */ + public function test_penalty_support(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + // Assign should be in the supported list. + $this->assertTrue(in_array('assign', penalty_manager::get_supported_modules())); + + // Penalty is not enabled for any modules by default. + $this->assertFalse(penalty_manager::is_penalty_enabled_for_module('assign')); + + // Enable penalty for assign. + penalty_manager::enable_module('assign'); + + // Assign should be enabled by now. + $this->assertTrue(penalty_manager::is_penalty_enabled_for_module('assign')); + } + + /** + * Data provider for test_hook_callback. + * + * @return array + */ + public static function apply_penalty_provider(): array { + return [ + // Submission date, Due date, User override, Group override, Extension due date, Expected messages, Expected grade. + // No overrides. + [DAYSECS, DAYSECS, null, null, null, ['Submission date: 86400', 'Due date: 86400'], 50], + [DAYSECS + 1, DAYSECS, null, null, null, ['Submission date: 86401', 'Due date: 86400'], 30], + // User override. + [DAYSECS + 1, DAYSECS, DAYSECS + 1, null, null, ['Submission date: 86401', 'Due date: 86401'], 50], + [DAYSECS + 2, DAYSECS, DAYSECS + 1, null, null, ['Submission date: 86402', 'Due date: 86401'], 30], + // Group override. + [DAYSECS + 1, DAYSECS, null, DAYSECS + 1, null, ['Submission date: 86401', 'Due date: 86401'], 50], + [DAYSECS + 2, DAYSECS, null, DAYSECS + 1, null, ['Submission date: 86402', 'Due date: 86401'], 30], + // User and group override. + [DAYSECS + 1, DAYSECS, DAYSECS + 1, DAYSECS + 2, null, ['Submission date: 86401', 'Due date: 86401'], 50], + [DAYSECS + 2, DAYSECS, DAYSECS + 1, DAYSECS + 2, null, ['Submission date: 86402', 'Due date: 86401'], 30], + // User, group override and extension. + [DAYSECS + 3, DAYSECS, DAYSECS + 1, DAYSECS + 2, DAYSECS + 3, ['Submission date: 86403', 'Due date: 86403'], 50], + [DAYSECS + 4, DAYSECS, DAYSECS + 1, DAYSECS + 2, DAYSECS + 3, ['Submission date: 86404', 'Due date: 86403'], 30], + ]; + } + + /** + * Test for hook_listener class. + * + * @dataProvider apply_penalty_provider + * + * @covers \mod_assign\penalty\helper::apply_penalty_to_submission + * + * @param int $submissiondate The submission date. + * @param int $duedate The due date. + * @param int $useroverrideduedate The user override due date. + * @param int $groupoverrideduedate The group override due date. + * @param int $extensionduedate The extension due date. + * @param array $expectedmessages The expected debug messages. + * @param float $expectedgrade The expected final grade. + * + */ + public function test_apply_penalty($submissiondate, $duedate, + $useroverrideduedate, $groupoverrideduedate, $extensionduedate, + $expectedmessages, $expectedgrade): void { + global $DB; + + $this->resetAfterTest(); + [$course, $student] = $this->set_up_test(); + + // Assignment. + $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); + + // If there is user override. + if ($useroverrideduedate) { + $assignmentgenerator->create_override([ + 'assignid' => $instance->id, + 'userid' => $student->id, + 'duedate' => $useroverrideduedate, + ]); + } + + // If there is extension. + if ($extensionduedate) { + $flags = $assign->get_user_flags($student->id, true); + $flags->extensionduedate = $extensionduedate; + $assign->update_user_flags($flags); + } + + // If there is group override. + if ($groupoverrideduedate) { + $group = $generator->create_group(['courseid' => $course->id]); + $generator->create_group_member(['groupid' => $group->id, 'userid' => $student->id]); + $assignmentgenerator->create_override([ + 'assignid' => $instance->id, + 'groupid' => $group->id, + 'duedate' => $groupoverrideduedate, + ]); + } + + // Add submission and grade. + $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); + + // Expect debug messages. + $this->assertdebuggingcalledcount(count($expectedmessages), $expectedmessages); + + // The expected final grade. + $gradeitem = grade_item::fetch( + [ + 'courseid' => $course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $instance->id, + 'itemnumber' => 0, + ] + ); + $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 4bd9bc28112..b6da78faf5f 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 = 2024121800; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2024121801; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2024100100; // Requires this Moodle version.