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}}
+
+
+
+ {{/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.