diff --git a/admin/settings/grades.php b/admin/settings/grades.php
index 2d6e86874a71b..c99121cc5f04c 100644
--- a/admin/settings/grades.php
+++ b/admin/settings/grades.php
@@ -246,6 +246,11 @@
new lang_string('gradepenalty_supportedplugins', 'grades'),
new lang_string('gradepenalty_supportedplugins_help', 'grades'), [], $options));
+ // Option to apply penalty to overridden grades.
+ $temp->add(new admin_setting_configcheckbox('gradepenalty_overriddengrade',
+ new lang_string('gradepenalty_overriddengrade', 'grades'),
+ new lang_string('gradepenalty_overriddengrade_help', 'grades'), 0));
+
$ADMIN->add('gradepenalty', $temp);
}
diff --git a/grade/report/grader/lang/en/gradereport_grader.php b/grade/report/grader/lang/en/gradereport_grader.php
index bf27242c63cd8..207932dd864e8 100644
--- a/grade/report/grader/lang/en/gradereport_grader.php
+++ b/grade/report/grader/lang/en/gradereport_grader.php
@@ -23,6 +23,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+$string['applypenaltytext'] = 'Penalty exemption';
+$string['applypenaltytooltip'] = 'If the box is checked, penalty will not be applied to the overridden grade.';
$string['aria:dropdowncolumns'] = 'Collapsed columns found';
$string['clearsearch'] = 'Clear searched users';
$string['collapsedcolumns'] = 'Collapsed columns {$a}';
diff --git a/grade/report/grader/lib.php b/grade/report/grader/lib.php
index 413bbf02a113a..d6df8a9032b9e 100644
--- a/grade/report/grader/lib.php
+++ b/grade/report/grader/lib.php
@@ -241,17 +241,29 @@ public function process_data($data) {
continue;
}
+ // Detect changes in exemption checkbox.
+ if ($oldvalue->can_apply_penalty_to_overridden_mark()) {
+ if (!isset($data->exemption[$userid][$itemid])) {
+ $newvalue = format_float($postedvalue - $oldvalue->deductedmark,
+ $oldvalue->grade_item->get_decimals());
+ } else {
+ $newvalue = $postedvalue;
+ }
+ } else {
+ $newvalue = $postedvalue;
+ }
+
// If the grade item uses a custom scale
if (!empty($oldvalue->grade_item->scaleid)) {
- if ((int)$oldvalue->finalgrade === (int)$postedvalue) {
+ if ((int)$oldvalue->finalgrade === (int)$newvalue) {
continue;
}
} else {
// The grade item uses a numeric scale
// Format the finalgrade from the DB so that it matches the grade from the client
- if ($postedvalue === format_float($oldvalue->finalgrade, $oldvalue->grade_item->get_decimals())) {
+ if ($newvalue === format_float($oldvalue->finalgrade, $oldvalue->grade_item->get_decimals())) {
continue;
}
}
@@ -326,8 +338,19 @@ public function process_data($data) {
}
}
+ // Save final grade, without penalty.
$gradeitem->update_final_grade($userid, $finalgrade, 'gradebook', false,
FORMAT_MOODLE, null, null, true);
+
+ // Save overridden mark, without penalty.
+ $gradeitem->update_overridden_mark($userid, $finalgrade);
+
+ // Apply penalty.
+ if ($oldvalue->can_apply_penalty_to_overridden_mark() && !isset($data->exemption[$userid][$itemid])) {
+ // Apply penalty.
+ $gradeitem->update_final_grade($userid, $newvalue, 'gradepenalty', false,
+ FORMAT_MOODLE, null, null, true);
+ }
}
}
}
@@ -1146,6 +1169,26 @@ public function get_right_rows(bool $displayaverages): array {
if ($context->statusicons) {
$context->extraclasses .= ' statusicons';
}
+
+ // Show option for user to apply penalty or not.
+ if ($grade->can_apply_penalty_to_overridden_mark()) {
+ $context->canapplypenalty = true;
+ if ($grade->is_penalty_applied_to_final_grade()) {
+ // We are editing the original grade value, ie, before applying penalty.
+ $context->value = format_float($gradeval + $grade->deductedmark, $decimalpoints);
+ } else {
+ $context->value = $value;
+ }
+ // Current grade.
+ $context->effectivegrade = $value;
+ $context->deductedmark = format_float($grade->deductedmark, $decimalpoints);
+ $context->penaltyexempted = !$grade->is_penalty_applied_to_final_grade();
+ $context->exemptionid = 'exemption' . $userid . '_' . $item->id;
+ $context->exemptionname = 'exemption[' . $userid . '][' . $item->id .']';
+ $context->exemptionlabel = $gradelabel . ' ' .
+ get_string('applypenaltytext', 'gradereport_grader');
+ $context->exemptiontooltip = get_string('applypenaltytooltip', 'gradereport_grader');
+ }
} else {
$context->extraclasses = 'gradevalue' . $hidden . $gradepass;
$context->text = format_float($gradeval, $decimalpoints);
diff --git a/grade/report/grader/templates/cell.mustache b/grade/report/grader/templates/cell.mustache
index 52b8910a844b2..3114041a0af87 100644
--- a/grade/report/grader/templates/cell.mustache
+++ b/grade/report/grader/templates/cell.mustache
@@ -30,7 +30,8 @@
"extraclasses": "statusicons",
"value": "Text information",
"tabindex": "1",
- "name": "grade[313][624]"
+ "name": "grade[313][624]",
+ "canapplypenalty": "false"
}
}}
@@ -41,7 +42,12 @@
{{>core_grades/grades/grader/scale}}
{{/scale}}
{{^scale}}
- {{>core_grades/grades/grader/input}}
+ {{#canapplypenalty}}
+ {{>core_grades/grades/grader/overriden_with_penalty}}
+ {{/canapplypenalty}}
+ {{^canapplypenalty}}
+ {{>core_grades/grades/grader/input}}
+ {{/canapplypenalty}}
{{/scale}}
{{/iseditable}}
{{^iseditable}}
diff --git a/grade/report/grader/tests/behat/behat_gradereport_grader.php b/grade/report/grader/tests/behat/behat_gradereport_grader.php
index 46acb8c3fc1f7..24132ed6e5a02 100644
--- a/grade/report/grader/tests/behat/behat_gradereport_grader.php
+++ b/grade/report/grader/tests/behat/behat_gradereport_grader.php
@@ -144,4 +144,14 @@ public static function get_partial_named_selectors(): array {
),
];
}
+
+ /**
+ * Enable penalty for overridden grade.
+ *
+ * @Given I enable penalty for overridden grade
+ */
+ public function i_enable_penalty_for_overridden_grade(): void {
+ set_config('gradepenalty_enabled', 1);
+ set_config('gradepenalty_overriddengrade', 1);
+ }
}
diff --git a/grade/report/grader/tests/behat/grade_override_with_deduction.feature b/grade/report/grader/tests/behat/grade_override_with_deduction.feature
new file mode 100644
index 0000000000000..e87896e87d965
--- /dev/null
+++ b/grade/report/grader/tests/behat/grade_override_with_deduction.feature
@@ -0,0 +1,52 @@
+@gradereport @gradereport_grader @gradereport_grader_deduction
+Feature: As a teacher, I want to override a grade with a deduction and check the gradebook.
+
+ Background:
+ Given the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ And I enable penalty for overridden grade
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | student1 | C1 | student |
+ And the following "grade items" exist:
+ | itemname | grademin | grademax | course |
+ | Manual grade 01 | 0 | 100 | C1 |
+ | Manual grade 02 | 0 | 100 | C1 |
+ And the following "grade grades" exist:
+ | gradeitem | user | grade | deductedmark |
+ | Manual grade 01 | student1 | 60 | 10 |
+ | Manual grade 02 | student1 | 80 | 20 |
+ When I log in as "teacher1"
+
+ @javascript
+ Scenario: Override a grade with a deduction and check the gradebook
+ Given I am on "Course 1" course homepage
+ And I navigate to "View > Grader report" in the course gradebook
+ And the following should exist in the "user-grades" table:
+ | -1- | -2- | -3- | -4- | -5- |
+ | Student 1 | student1@example.com | 60 | 80 | 140 |
+ And I turn editing mode on
+ And I set the following fields to these values:
+ | Student 1 Manual grade 01 grade | 80 |
+ | Student 1 Manual grade 01 Penalty exemption | 0 |
+ | Student 1 Manual grade 02 Penalty exemption | 1 |
+ And I click on "Save changes" "button"
+ When I turn editing mode off
+ Then the following should exist in the "user-grades" table:
+ | -1- | -2- | -3- | -4- | -5- |
+ | Student 1 | student1@example.com | 70 | 100 | 170 |
+ When I turn editing mode on
+ And I set the following fields to these values:
+ | Student 1 Manual grade 02 grade | 100 |
+ | Student 1 Manual grade 02 Penalty exemption | 0 |
+ And I click on "Save changes" "button"
+ And I turn editing mode off
+ Then the following should exist in the "user-grades" table:
+ | -1- | -2- | -3- | -4- | -5- |
+ | Student 1 | student1@example.com | 70 | 80 | 150 |
diff --git a/grade/templates/grades/grader/overriden_with_penalty.mustache b/grade/templates/grades/grader/overriden_with_penalty.mustache
new file mode 100644
index 0000000000000..7f9f20cb86557
--- /dev/null
+++ b/grade/templates/grades/grader/overriden_with_penalty.mustache
@@ -0,0 +1,54 @@
+{{!
+ 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 core_grades/grades/grader/overriden_with_penalty
+
+ Input template for grader report cell.
+
+ Example context (json):
+ {
+ "deductedmark": "30.00",
+ "effectivegrade": "70.00",
+ "exemptionid": "exemption3_2",
+ "exemptionlabel": "Exempt penalty",
+ "exemptiontooltip": "Exempt penalty",
+ "exemptionname": "exemption[3][2]",
+ "penaltyexempted": "true",
+ "id": "grade_313_624",
+ "label": "grade_313_624",
+ "name": "grade[313][624]"
+ }
+}}
+
+
+
+
Original grade:
+ {{>core_grades/grades/grader/input}}
+
+
+
+
Current grade: {{{effectivegrade}}}
+
+
+
+
Exempt penalty:
+
+
+
+
diff --git a/lang/en/grades.php b/lang/en/grades.php
index a886c4a7d36d5..7bed40640f2de 100644
--- a/lang/en/grades.php
+++ b/lang/en/grades.php
@@ -334,6 +334,8 @@
$string['gradepenalty_enabled'] = 'Grade penalty';
$string['gradepenalty_enabled_help'] = 'If enabled, the penalty will be applied to the grades of supported modules.';
$string['gradepenalty_indicator_info'] = 'Late penalty applied -{$a} marks';
+$string['gradepenalty_overriddengrade'] = 'Apply penalty to overridden grades';
+$string['gradepenalty_overriddengrade_help'] = 'If enabled, the penalty will be applied to overridden grades.';
$string['gradepenalty_supportedplugins'] = 'Supported modules';
$string['gradepenalty_supportedplugins_help'] = 'Enable the grade penalty for the selected modules.';
$string['gradepointdefault'] = 'Grade point default';
diff --git a/lib/db/install.xml b/lib/db/install.xml
index 813fe9beb96a7..444391fb1df56 100644
--- a/lib/db/install.xml
+++ b/lib/db/install.xml
@@ -2060,6 +2060,7 @@
+
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php
index 3779ae19bbc6d..5bc45491cb9c7 100644
--- a/lib/db/upgrade.php
+++ b/lib/db/upgrade.php
@@ -1338,5 +1338,21 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2024120500.03);
}
+ if ($oldversion < 2024121301.00) {
+
+ // Define field overriddenmark to be added to grade_grades.
+ $table = new xmldb_table('grade_grades');
+ $field = new xmldb_field('overriddenmark', XMLDB_TYPE_NUMBER, '10, 5', null,
+ XMLDB_NOTNULL, null, '0', 'deductedmark');
+
+ // Conditionally launch add field penalty.
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2024121301.00);
+ }
+
return true;
}
diff --git a/lib/grade/grade_grade.php b/lib/grade/grade_grade.php
index 1ac340ba0aaae..91e4b5d00a651 100644
--- a/lib/grade/grade_grade.php
+++ b/lib/grade/grade_grade.php
@@ -50,7 +50,7 @@ class grade_grade extends grade_object {
public $required_fields = array('id', 'itemid', 'userid', 'rawgrade', 'rawgrademax', 'rawgrademin',
'rawscaleid', 'usermodified', 'finalgrade', 'hidden', 'locked',
'locktime', 'exported', 'overridden', 'excluded', 'timecreated',
- 'timemodified', 'aggregationstatus', 'aggregationweight', 'deductedmark');
+ 'timemodified', 'aggregationstatus', 'aggregationweight', 'deductedmark', 'overriddenmark');
/**
* Array of optional fields with default values (these should match db defaults)
@@ -221,6 +221,9 @@ class grade_grade extends grade_object {
/** @var float $deductedmark mark deducted from final grade */
public $deductedmark = 0;
+ /** @var float $overriddenmark mark overridden by teacher */
+ public $overriddenmark = 0;
+
/**
* Returns array of grades for given grade_item+users
*
@@ -1287,4 +1290,45 @@ public function get_context() {
$this->load_grade_item();
return $this->grade_item->get_context();
}
+
+ /**
+ * Determine if penalty is applied to this overridden mark.
+ *
+ * @return bool whether penalty is applied
+ */
+ public function can_apply_penalty_to_overridden_mark(): bool {
+ // Check config.
+ if (!get_config('core', 'gradepenalty_overriddengrade')) {
+ return false;
+ }
+
+ // Check if the raw grade was deducted.
+ if ($this->deductedmark <= 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Whether the penalty is applied to this overridden mark.
+ *
+ * @return bool whether penalty is applied
+ */
+ public function is_penalty_applied_to_overridden_mark(): bool {
+ return $this->overridden > 0 && $this->overriddenmark > $this->finalgrade;
+ }
+
+ /**
+ * Whether the penalty is applied to this final grade.
+ *
+ * @return bool whether penalty is applied
+ */
+ public function is_penalty_applied_to_final_grade(): bool {
+ if ($this->overridden > 0) {
+ return $this->is_penalty_applied_to_overridden_mark();
+ } else {
+ return $this->deductedmark > 0;
+ }
+ }
}
diff --git a/lib/grade/grade_item.php b/lib/grade/grade_item.php
index a57d577d0816a..09ce089bc0008 100644
--- a/lib/grade/grade_item.php
+++ b/lib/grade/grade_item.php
@@ -2161,6 +2161,21 @@ public function update_deducted_mark(int $userid, float $deductedmark): void {
$grade->update();
}
+ /**
+ * Update overridden mark for given user
+ *
+ * @param int $userid The graded user
+ * @param float $overriddenmark The mark deducted from final grade
+ */
+ public function update_overridden_mark(int $userid, float $overriddenmark): void {
+ $grade = new grade_grade([
+ 'itemid' => $this->id,
+ 'userid' => $userid,
+ ]);
+ $grade->overriddenmark = $overriddenmark;
+ $grade->update();
+ }
+
/**
* Calculates final grade values using the formula in the calculation property.
* The parameters are taken from final grades of grade items in current course only.
diff --git a/lib/gradelib.php b/lib/gradelib.php
index 2bc2b877e33f4..48b91e230a91e 100644
--- a/lib/gradelib.php
+++ b/lib/gradelib.php
@@ -859,7 +859,7 @@ function show_penalty_indicator(grade_grade $grade): string {
global $PAGE;
// Show penalty indicator if penalty is greater than 0.
- if ($grade->deductedmark > 0) {
+ if ($grade->is_penalty_applied_to_final_grade()) {
$indicator = new \core_grades\output\penalty_indicator(2, $grade);
$renderer = $PAGE->get_renderer('core_grades');
return $renderer->render_penalty_indicator($indicator);
diff --git a/version.php b/version.php
index c00b3a8a5bd1c..9e38399b3b1ae 100644
--- a/version.php
+++ b/version.php
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
-$version = 2024121300.00; // YYYYMMDD = weekly release date of this DEV branch.
+$version = 2024121301.00; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.0dev (Build: 20241213)'; // Human-friendly version name