MDL-80984 gradepenalty: add penalty feature

This commit is contained in:
Nathan Nguyen 2024-06-24 09:43:42 +10:00 committed by Andrew Nicols
parent e4c7a5cb64
commit 224b6eb39a
No known key found for this signature in database
GPG Key ID: 6D1E3157C8CFBF14
35 changed files with 1846 additions and 14 deletions

View File

@ -2,6 +2,8 @@
// This file defines settingpages and externalpages under the "grades" section
use core\plugininfo\gradepenalty;
if (has_capability('moodle/grade:manage', $systemcontext)
or has_capability('moodle/grade:manageletters', $systemcontext)) { // speedup for non-admins, add all caps used on this page
@ -91,7 +93,7 @@ if (has_capability('moodle/grade:manage', $systemcontext)
$temp->add(new admin_setting_my_grades_report());
$temp->add(new admin_setting_configtext('gradereport_mygradeurl', new lang_string('externalurl', 'grades'),
new lang_string('externalurl_desc', 'grades'), ''));
new lang_string('externalurl_desc', 'grades'), ''));
}
$ADMIN->add('grades', $temp);
@ -221,5 +223,48 @@ if (has_capability('moodle/grade:manage', $systemcontext)
}
}
} // end of speedup
// Penalty.
$ADMIN->add('grades', new admin_category('gradepenalty', new lang_string('gradepenalty', 'grades')));
// Supported modules.
$modules = core_grades\penalty_manager::get_supported_modules();
if (!empty($modules)) {
$temp = new admin_settingpage('supportedplugins', new lang_string('gradepenalty_supportedplugins', 'grades'),
'moodle/grade:manage');
$options = [];
foreach ($modules as $module) {
$options[$module] = new lang_string('modulename', $module);
}
$temp->add(new admin_setting_configmultiselect('gradepenalty_enabledmodules',
new lang_string('gradepenalty_enabledmodules', 'grades'),
new lang_string('gradepenalty_enabledmodules_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);
}
// External page to manage the penalty plugins.
$temp = new admin_externalpage(
'managepenaltyplugins',
get_string('managepenaltyplugins', 'grades'),
new moodle_url('/grade/penalty/manage_penalty_plugins.php'),
'moodle/grade:manage'
);
$ADMIN->add('gradepenalty', $temp);
// Settings from each penalty plugin.
foreach (core_component::get_plugin_list('gradepenalty') as $plugin => $plugindir) {
// Check if the plugin is enabled.
if (gradepenalty::is_plugin_enabled($plugin)) {
// Include all the settings commands for this plugin if there are any.
if (file_exists($plugindir . '/settings.php')) {
include($plugindir . '/settings.php');
}
}
}
} // end of speedup

View File

@ -0,0 +1,91 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades\output;
use core\output\renderer_base;
use core\output\templatable;
use core\output\renderable;
use grade_grade;
/**
* The base class for the action bar in the gradebook pages.
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class penalty_indicator implements templatable, renderable {
/**
* The class constructor.
*
* @param int $decimals the decimal places
* @param grade_grade $grade user grade
* @param bool $showfinalgrade whether to show the final grade (or show icon only)
* @param bool $showgrademax whether to show the max grade
* @param array|null $penaltyicon icon to show if penalty is applied
*/
public function __construct(
/** @var int $decimals the decimal places */
protected int $decimals,
/** @var grade_grade $grade user grade */
protected grade_grade $grade,
/** @var bool $showfinalgrade whether to show the final grade (or show icon only) */
protected bool $showfinalgrade = false,
/** @var bool $showgrademax whether to show the max grade */
protected bool $showgrademax = false,
/** @var array|null $penaltyicon icon to show if penalty is applied */
protected ?array $penaltyicon = null
) {
}
/**
* Returns the template for the actions bar.
*
* @return string
*/
public function get_template(): string {
return 'core_grades/penalty_indicator';
}
/**
* Export the data for the mustache template.
*
* @param \renderer_base $output renderer to be used to render the penalty indicator.
* @return array
*/
public function export_for_template(renderer_base $output): array {
$penalty = format_float($this->grade->deductedmark, $this->decimals);
$finalgrade = $this->showfinalgrade ? format_float($this->grade->finalgrade , $this->decimals) : null;
$grademax = $this->showgrademax ? format_float($this->grade->get_grade_max(), $this->decimals) : null;
$icon = $this->penaltyicon ?: ['name' => 'i/risk_xss', 'component' => 'core'];
$info = get_string('gradepenalty_indicator_info', 'core_grades', format_float($penalty, $this->decimals));
$context = [
'penalty' => $penalty,
'finalgrade' => $finalgrade,
'grademax' => $grademax,
'icon' => $icon,
'info' => $info,
];
return $context;
}
}

View File

@ -0,0 +1,37 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades;
use core_grades\penalty_container;
/**
* Abstract class for defining the interface between the core penalty system and penalty plugins.
* Penalty plugins must override this class under their own namespace to receive calls from the core penalty system.
*
* @package core_grades
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class penalty_calculator {
/**
* Calculate the grade penalty based on the information provided in the penalty container.
* The result should be stored in the penalty container.
*
* @param penalty_container $container
*/
abstract public static function calculate_penalty(penalty_container $container): void;
}

View File

@ -0,0 +1,190 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades;
use grade_grade;
use grade_item;
use moodle_exception;
/**
* An object for storing and aggregating penalty information.
*
* @package core_grades
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class penalty_container {
/** @var float $penalty The number of points to deduct from the grade */
private float $penalty = 0.0;
/**
* Constructor for the class.
*
* @param grade_item $gradeitem The grade item object
* @param grade_grade $gradegrade The grade object
* @param int $submissiondate The date and time the submission was made
* @param int $duedate The date and time the activity is due
*/
public function __construct(
/** @var grade_item $gradeitem The grade item object*/
private readonly grade_item $gradeitem,
/** @var grade_grade $gradegrade The grade object */
private readonly grade_grade $gradegrade,
/** @var int $submissiondate The date and time the submission was made */
private readonly int $submissiondate,
/** @var int $duedate The date and time the activity is due */
private readonly int $duedate,
) {
}
/**
* Get the user id.
*
* @return int The user id
*/
public function get_userid(): int {
return $this->gradegrade->userid;
}
/**
* Get the submission date.
*
* @return int The date and time the submission was made
*/
public function get_submission_date(): int {
return $this->submissiondate;
}
/**
* Get the due date.
*
* @return int The date and time the activity is due
*/
public function get_due_date(): int {
return $this->duedate;
}
/**
* Get the grade item object.
* This object should not be modified.
*
* @return grade_item The grade item object
*/
public function get_grade_item(): grade_item {
return $this->gradeitem;
}
/**
* Get the grade object.
* This object should not be modified.
*
* @return grade_grade The grade object
*/
public function get_grade_grade(): grade_grade {
return $this->gradegrade;
}
/**
* Get the grade before penalties are applied.
*
* @return float The grade before penalties are applied
*/
public function get_grade_before_penalties(): float {
return $this->gradegrade->finalgrade;
}
/**
* Get the penalised grade.
*
* The penalised grade is clamped between the minimum and maximum grade for the grade item.
*
* @return float The penalised grade
*/
public function get_grade_after_penalties(): float {
// Prevent grades from becoming out of bounds which would otherwise be a fairly common occurrence.
return self::clamp(
$this->get_grade_before_penalties() - $this->penalty,
$this->get_min_grade(),
$this->get_max_grade()
);
}
/**
* Get the current penalty value.
*
* @return float The number of points to deduct from the grade
*/
public function get_penalty(): float {
return $this->penalty;
}
/**
* Get the minimum grade for the grade item.
*
* @return float The minimum grade for the grade item
*/
public function get_min_grade(): int {
return $this->gradeitem->grademin;
}
/**
* Get the maximum grade for the grade item.
*
* @return float The maximum grade for the grade item
*/
public function get_max_grade(): float {
return $this->gradeitem->grademax;
}
/**
* Aggregate the number of points to deduct from the grade.
* Each penalty plugin is expected to call this method from their calculate_penalty() method.
*
* For example, if a grade item has a maximum grade of 200 and a penalty plugin wants to deduct 10% from the maximum grade,
* the penalty plugin should call this method with a penalty value of 20.
*
* Percentages must not be passed to this method. Any percentage values must be converted to points before calling this method.
* Penalty values cannot be negative or an exception will be thrown.
* After all penalty plugins have been called, the core penalty system will apply the aggregated penalty to the grade,
* clamping the grade between the minimum and maximum grade for the grade item.
*
* @param float $penalty The number of points to deduct from the grade
* @throws moodle_exception Thrown if the penalty value is negative
*/
public function aggregate_penalty(float $penalty): void {
if ($penalty < 0.0) {
throw new moodle_exception('errornegativepenalty', 'core_grades', '', $this->get_grade_grade()->id);
}
$this->penalty += $penalty;
}
/**
* Clamp a value between a minimum and maximum value.
*
* @param float $value The value to clamp
* @param float $min The minimum value
* @param float $max The maximum value
* @return float The clamped value
*/
private static function clamp(float $value, float $min, float $max): float {
return max($min, min($max, $value));
}
}

View File

@ -0,0 +1,354 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades;
use core\context;
use core\plugininfo\gradepenalty;
use core_plugin_manager;
use grade_grade;
use grade_item;
use moodle_url;
use navigation_node;
use pix_icon;
use settings_navigation;
use stdClass;
/**
* Manager class for grade penalty.
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class penalty_manager {
/**
* List the modules that support the grade penalty feature.
*
* @return array list of supported modules.
*/
public static function get_supported_modules(): array {
$plugintype = 'mod';
$mods = \core_component::get_plugin_list($plugintype);
$supported = [];
foreach ($mods as $mod => $plugindir) {
if (plugin_supports($plugintype, $mod, FEATURE_GRADE_HAS_PENALTY)) {
$supported[] = $mod;
}
}
return $supported;
}
/**
* List the modules that currently have the grade penalty feature enabled.
*
* @return array List of enabled modules.
*/
public static function get_enabled_modules(): array {
return array_filter(explode(',', get_config('core', 'gradepenalty_enabledmodules')));
}
/**
* Enable the grade penalty feature for a module.
*
* @param string $module The module name (e.g. 'assign').
*/
public static function enable_module(string $module): void {
self::enable_modules([$module]);
}
/**
* Enable the grade penalty feature for multiple modules.
*
* @param array $modules List of module names.
*/
public static function enable_modules(array $modules): void {
$result = array_unique(array_merge(self::get_enabled_modules(), $modules));
set_config('gradepenalty_enabledmodules', implode(',', $result));
}
/**
* Disable the grade penalty feature for a module.
*
* @param string $module The module name (e.g. 'assign').
*/
public static function disable_module(string $module): void {
self::disable_modules([$module]);
}
/**
* Disable the grade penalty feature for multiple modules.
*
* @param array $modules List of module names.
*/
public static function disable_modules(array $modules): void {
$result = array_diff(self::get_enabled_modules(), $modules);
set_config('gradepenalty_enabledmodules', implode(',', $result));
}
/**
* Check if the module has the grade penalty feature enabled.
*
* @param string $module The module name (e.g. 'assign').
* @return bool Whether grade penalties are enabled for the module.
*/
public static function is_penalty_enabled_for_module(string $module): bool {
return in_array($module, self::get_enabled_modules());
}
/**
* Whether the grade penalty feature is enabled for a grade.
*
* @param grade_grade $grade
* @return bool
*/
private static function is_penalty_enabled_for_grade(grade_grade $grade): bool {
if (empty($grade)) {
return false;
}
$grademin = $grade->get_grade_min();
// No penalty for minimum grades.
if ($grade->rawgrade <= $grademin) {
return false;
}
if ($grade->finalgrade <= $grademin) {
return false;
}
// No penalty for overridden grades.
// We may need a separate setting to allow grade penalties for overridden grades.
if (!empty($grade->overridden)) {
return false;
}
// No penalty for locked grades.
if (!empty($grade->locked)) {
return false;
}
return true;
}
/**
* Calculate grade penalties for a user and their grade via the enabled penalty plugins.
*
* @param penalty_container $container The penalty container.
* @return penalty_container The penalty container with the calculated penalties.
*/
private static function calculate_penalties(penalty_container $container): penalty_container {
// Iterate through all the penalty plugins to calculate the total penalty.
foreach (core_plugin_manager::instance()->get_plugins_of_type('gradepenalty') as $pluginname => $plugin) {
if (gradepenalty::is_plugin_enabled($pluginname)) {
$classname = "\\gradepenalty_{$pluginname}\\penalty_calculator";
if (class_exists($classname)) {
$classname::calculate_penalty($container);
}
}
}
// Returning the container is not strictly necessary but makes it clear the container is being modified.
return $container;
}
/**
* Apply grade penalties to a user.
*
* Grade penalties are determined by the enabled penalty plugin.
* This function should be called each time a module creates or updates a grade item for a user.
*
* @param int $userid The user ID
* @param grade_item $gradeitem grade item
* @param int $submissiondate submission date
* @param int $duedate due date
* @param bool $previewonly do not update the grade if true, only return the penalty
* @return penalty_container Information about the applied penalty.
*/
public static function apply_grade_penalty_to_user(
int $userid,
grade_item $gradeitem,
int $submissiondate,
int $duedate,
bool $previewonly = false
): penalty_container {
try {
$container = self::apply_penalty($userid, $gradeitem, $submissiondate, $duedate, $previewonly);
} catch (\core\exception\moodle_exception $e) {
debugging($e->getMessage(), DEBUG_DEVELOPER);
}
return $container;
}
/**
* Fetch the penalty for a user based on the submission date and due date and deduct marks from the grade item accordingly.
*
* @param int $userid The user ID.
* @param grade_item $gradeitem The grade item.
* @param int $submissiondate The date and time of the user submission.
* @param int $duedate The date and time the submission is due.
* @param bool $previewonly If true, the grade will not be updated.
* @return penalty_container The penalty container containing information about the applied penalty.
*/
private static function apply_penalty(
int $userid,
grade_item $gradeitem,
int $submissiondate,
int $duedate,
bool $previewonly = false
): penalty_container {
// Get the grade and create a penalty container.
$grade = $gradeitem->get_grade($userid);
$container = new penalty_container($gradeitem, $grade, $submissiondate, $duedate);
// Do not apply penalties if the module is disabled.
if (!self::is_penalty_enabled_for_module($gradeitem->itemmodule)) {
return $container;
}
// Do not apply penalties if the grade is not eligible.
if (!self::is_penalty_enabled_for_grade($grade)) {
return $container;
}
// Call all penalty plugins to calculate the penalty.
$container = self::calculate_penalties($container);
// Update the grade if not in preview mode.
if (!$previewonly) {
// Update the raw grade and store the deducted mark.
$gradeitem->update_raw_grade($userid, $container->get_grade_after_penalties(), 'gradepenalty');
$gradeitem->update_deducted_mark($userid, $container->get_penalty());
}
return $container;
}
/**
* Returns the penalty indicator HTML code if a penalty is applied to the grade.
* Otherwise, returns an empty string.
*
* @param grade_grade $grade Grade object
* @return string HTML code for penalty indicator
*/
public static function show_penalty_indicator(grade_grade $grade): string {
global $PAGE;
// Show penalty indicator if penalty is greater than 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);
}
return '';
}
/**
* Allow penalty plugin to extend course navigation.
*
* @param navigation_node $navigation The navigation node
* @param stdClass $course The course object
* @param context $coursecontext The course context
*/
public static function extend_navigation_course(navigation_node $navigation,
stdClass $course,
context $coursecontext): void {
// Create new navigation node for grade penalty.
$penaltynav = $navigation->add(get_string('gradepenalty', 'core_grades'),
new moodle_url('/grade/penalty/view.php', ['contextid' => $coursecontext->id]),
navigation_node::TYPE_CONTAINER, null, 'gradepenalty', new pix_icon('i/grades', ''));
// Allow plugins to extend the navigation.
$pluginfunctions = get_plugin_list_with_function('gradepenalty', 'extend_navigation_course');
foreach ($pluginfunctions as $plugin => $function) {
if (gradepenalty::is_plugin_enabled($plugin)) {
$function($penaltynav, $course, $coursecontext);
}
}
// Do not display the node if there are no children.
if (!$penaltynav->has_children()) {
$penaltynav->remove();
}
}
/**
* Allow penalty plugin to extend navigation module.
*
* @param settings_navigation $settings The settings navigation object
* @param navigation_node $navref The navigation node
* @return void
*/
public static function extend_navigation_module(settings_navigation $settings, navigation_node $navref): void {
$context = $settings->get_page()->context;
$cm = $settings->get_page()->cm;
// Create new navigation node for grade penalty.
$penaltynav = $navref->add(get_string('gradepenalty', 'core_grades'),
new moodle_url('/grade/penalty/view.php', ['contextid' => $context->id, 'cm' => $cm->id]),
navigation_node::TYPE_CONTAINER, null, 'gradepenalty', new pix_icon('i/grades', ''));
// Allow plugins to extend the navigation.
$pluginfunctions = get_plugin_list_with_function('gradepenalty', 'extend_navigation_module');
foreach ($pluginfunctions as $plugin => $function) {
if (gradepenalty::is_plugin_enabled($plugin) && self::is_penalty_enabled_for_module($cm->modname)) {
$function($penaltynav, $cm);
}
}
// Do not display the node if there are no children.
if (!$penaltynav->has_children()) {
$penaltynav->remove();
}
}
/**
* Recalculate grade penalties
*
* @param context $context The context
* @param int $usermodified The user who triggered the recalculation
* return void
*/
public static function recalculate_penalty(context $context, int $usermodified = 0): void {
if ($usermodified == 0) {
global $USER;
$usermodified = $USER->id;
}
// Get enabled modules.
$enabledmodules = self::get_enabled_modules();
foreach ($enabledmodules as $module) {
// If it is in a module context, make sure the module is the same as the enabled module.
if ($context->contextlevel == CONTEXT_MODULE) {
$cmid = $context->instanceid;
$cm = get_coursemodule_from_id($module, $cmid);
if (empty($cm)) {
continue;
}
}
// Check if the module supports has penalty recalculator class.
$classname = "\\mod_{$module}\\penalty_recalculator";
if (class_exists($classname)) {
$classname::recalculate_penalty($context, $usermodified);
}
}
}
}

View File

@ -0,0 +1,38 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades;
use core\context;
/**
* Abstract class for defining the interface between the core penalty system and activity plugins.
* Activity plugins must override this class and implement their own recalculate_penalty method.
*
* @package core_grades
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class penalty_recalculator {
/**
* Calculate the grade penalty based on the information provided in the penalty container.
* The result should be stored in the penalty container.
*
* @param context $context the context where the penalty is being recalculated.
* @param int $usermodified the user who triggered the recalculation.
*/
abstract public static function recalculate_penalty(context $context, int $usermodified): void;
}

View File

@ -0,0 +1,49 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades\table;
use core_admin\table\plugin_management_table;
use core\url;
/**
* Table to manage grade penalty plugin.
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class gradepenalty_management_table extends plugin_management_table {
/**
* Return the penalty plugin type.
*
* @return string
*/
protected function get_plugintype(): string {
return 'gradepenalty';
}
/**
* Get the URL to manage the penalty plugin.
*
* @param array $params
* @return url
*/
protected function get_action_url(array $params = []): url {
return new url('/grade/penalty/manage_penalty_plugins.php', $params);
}
}

View File

@ -24,5 +24,6 @@
*/
$string['csv:view'] = 'Import grades from CSV';
$string['gradepenalties'] = 'Grade penalties will not be applied to imported grades.';
$string['pluginname'] = 'CSV file';
$string['privacy:metadata'] = 'The import grades from CSV plugin does not store any personal data.';

View File

@ -992,6 +992,17 @@ function print_grade_page_head(int $courseid, string $active_type, ?string $acti
$output = $OUTPUT->heading($heading);
}
// If any grade penalty plugins are enabled, notify the user that grade penalties will not be applied to imported grades.
if ($active_type === 'import') {
foreach (core_plugin_manager::instance()->get_plugins_of_type('gradepenalty') as $plugin) {
if ($plugin->is_enabled()) {
$output .= $OUTPUT->notification(get_string('gradepenalties', 'gradeimport_csv'),
\core\output\notification::NOTIFY_INFO, false);
break;
}
}
}
if ($return) {
$returnval .= $output;
} else {

View File

@ -0,0 +1,67 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Manage penalty plugins
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_grades\table\gradepenalty_management_table;
use core\notification;
use core\url;
require_once('../../config.php');
require_once('../../course/lib.php');
require_once("$CFG->libdir/adminlib.php");
require_once("$CFG->libdir/tablelib.php");
admin_externalpage_setup('managepenaltyplugins');
$plugin = optional_param('plugin', '', PARAM_PLUGIN);
$action = optional_param('action', '', PARAM_ALPHA);
// If Javascript is disabled, we need to handle the form submission.
if (!empty($action) && !empty($plugin) && confirm_sesskey()) {
$manager = core_plugin_manager::resolve_plugininfo_class('gradepenalty');
$pluginname = get_string('pluginname', 'gradepenalty_' . $plugin);
if ($action === 'disable' && $manager::enable_plugin($plugin, 0)) {
notification::add(
get_string('plugin_disabled', 'core_admin', $pluginname),
notification::SUCCESS
);
admin_get_root(true, false);
} else if ($action === 'enable' && $manager::enable_plugin($plugin, 1)) {
notification::add(
get_string('plugin_enabled', 'core_admin', $pluginname),
notification::SUCCESS
);
admin_get_root(true, false);
}
// Redirect back to the settings page.
redirect(new url('/grade/penalty/manage_penalty_plugins.php'));
}
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string("gradepenalty", 'core_grades'));
$table = new gradepenalty_management_table();
$table->out();
echo $OUTPUT->footer();

110
grade/penalty/view.php Normal file
View File

@ -0,0 +1,110 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Page to view the course reports
*
* @package core_grades
* @subpackage report
* @copyright 2021 Sujith Haridasan
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
use core\output\notification;
use core\url;
// Course id.
$contextid = required_param('contextid', PARAM_INT);
$cmid = optional_param('cm', null, PARAM_INT);
$recalculate = optional_param('recalculate', 0, PARAM_INT);
// Page URL.
$url = new moodle_url('/grade/penalty/view.php', ['contextid' => $contextid]);
if ($cmid !== null) {
$url->param('cm', $cmid);
}
$PAGE->set_url($url);
$context = context::instance_by_id($contextid);
$courseid = $context->get_course_context()->instanceid;
$course = get_course($courseid);
$cm = null;
if (!is_null($cmid)) {
$cm = get_coursemodule_from_id(null, $cmid, $course->id, false, MUST_EXIST);
}
require_login($course, false, $cm);
$PAGE->set_title(get_string('gradepenalty', 'core_grades'));
$PAGE->set_heading($course->fullname);
$PAGE->activityheader->disable();
// Check if the recalculate button is clicked.
if ($recalculate) {
// Show message for user confirmation.
$confirmurl = new url($url->out(), [
'contextid' => $contextid,
'recalculateconfirm' => 1,
'sesskey' => sesskey(),
]);
echo $OUTPUT->header();
echo $OUTPUT->confirm(get_string('recalculatepenaltyconfirm', 'core_grades'), $confirmurl, $url);
echo $OUTPUT->footer();
die;
} else if (optional_param('recalculateconfirm', 0, PARAM_INT) && confirm_sesskey()) {
\core_grades\penalty_manager::recalculate_penalty($context);
redirect($url, get_string('recalculatepenaltysuccess', 'core_grades'), 0, notification::NOTIFY_SUCCESS);
}
// Show the page.
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('gradepenalty', 'core_grades'));
// Display the penalty recalculation button at course/module context.
if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_MODULE) {
$buttonurl = $url;
$buttonurl->params(['contextid' => $contextid, 'recalculate' => 1]);
echo $OUTPUT->single_button($buttonurl, get_string('recalculatepenalty', 'core_grades'), 'get',
['type' => 'secondary']);
// The empty paragraph is used as a spacer.
echo $OUTPUT->paragraph('');
}
// Penalty plugins.
$haspenaltypluginnode = false;
if ($penaltynode = $PAGE->settingsnav->find('gradepenalty', \navigation_node::TYPE_CONTAINER)) {
foreach ($penaltynode->children as $child) {
if ($child->display) {
$haspenaltypluginnode = true;
break;
}
}
}
if ($haspenaltypluginnode) {
echo $OUTPUT->heading(get_string('settings'));
// Reuse the report link template.
echo $OUTPUT->render_from_template('core/report_link_page', ['node' => $penaltynode]);
}
echo $OUTPUT->footer();

View File

@ -17,7 +17,8 @@
defined('MOODLE_INTERNAL') || die;
use core\output\comboboxsearch;
use \core_grades\output\action_bar;
use core_grades\output\action_bar;
use core_grades\output\penalty_indicator;
use core_message\helper;
use core_message\api;
@ -236,4 +237,15 @@ class core_grades_renderer extends plugin_renderer_base {
return $this->render_from_template('core_grades/user_heading', $headingdata);
}
/**
* Renders the penalty indicator.
*
* @param penalty_indicator $penaltyindicator
* @return string The HTML output
*/
public function render_penalty_indicator(penalty_indicator $penaltyindicator): string {
$data = $penaltyindicator->export_for_template($this);
return $this->render_from_template($penaltyindicator->get_template(), $data);
}
}

View File

@ -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 <span class="badge rounded-pill bg-primary text-white ms-1" data-collapse="count">{$a}</span>';

View File

@ -25,6 +25,8 @@
require_once($CFG->dirroot . '/grade/report/lib.php');
require_once($CFG->libdir.'/tablelib.php');
use core_grades\penalty_manager;
/**
* Class providing an API for the grader report building and displaying.
* @uses grade_report
@ -241,17 +243,29 @@ class grade_report_grader extends grade_report {
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 +340,19 @@ class grade_report_grader extends grade_report {
}
}
// 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);
}
}
}
}
@ -1154,6 +1179,26 @@ class grade_report_grader extends grade_report {
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->penaltyexempt = !$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);
@ -1200,6 +1245,7 @@ class grade_report_grader extends grade_report {
$context->extraclasses = 'gradevalue ' . $hidden . $gradepass;
$context->text = grade_format_gradevalue($gradeval, $item, true,
$gradedisplaytype, null);
$context->text .= penalty_manager::show_penalty_indicator($grade);
}
}

View File

@ -30,7 +30,11 @@
"extraclasses": "statusicons",
"value": "Text information",
"tabindex": "1",
"name": "grade[313][624]"
"name": "grade[313][624]",
"canapplypenalty": "false",
"exemptionname": "exemption[3][2]",
"exemptionid": "exemption3_2",
"exemptionlabel": "Exempt penalty"
}
}}
<div class="d-flex flex-column h-100" data-collapse="content">
@ -41,7 +45,12 @@
{{>core_grades/grades/grader/scale}}
{{/scale}}
{{^scale}}
{{>core_grades/grades/grader/input}}
{{#canapplypenalty}}
{{>core_grades/grades/grader/overridden_with_penalty}}
{{/canapplypenalty}}
{{^canapplypenalty}}
{{>core_grades/grades/grader/input}}
{{/canapplypenalty}}
{{/scale}}
{{/iseditable}}
{{^iseditable}}

View File

@ -104,4 +104,13 @@ class behat_gradereport_grader extends behat_base {
),
];
}
/**
* Enable penalty for overridden grade.
*
* @Given I enable grade penalties for overridden grades
*/
public function i_enable_grade_penalties_for_overridden_grades(): void {
set_config('gradepenalty_overriddengrade', 1);
}
}

View File

@ -0,0 +1,51 @@
@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 grade penalties for overridden grades
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 |
When the following "grade grades" exist:
| gradeitem | user | grade | deductedmark |
| Manual grade 01 | student1 | 60 | 10 |
| Manual grade 02 | student1 | 80 | 20 |
@javascript
Scenario: Override a grade with a deduction and check the gradebook
Given I am on the "Course 1" course page logged in as "teacher1"
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 |

View File

@ -468,7 +468,10 @@ class tablelog extends \table_sql implements \renderable {
FROM {grade_grades_history} h
WHERE h.itemid = ggh.itemid
AND h.userid = ggh.userid
AND h.timemodified < ggh.timemodified
AND (
h.timemodified < ggh.timemodified
OR (h.timemodified = ggh.timemodified AND h.source != ggh.source AND h.id < ggh.id)
)
AND NOT EXISTS (
SELECT 1
FROM {grade_grades_history} h2

View File

@ -554,6 +554,64 @@ final class report_test extends \advanced_testcase {
$this->assertCount(1, $graders); // Including "all graders" .
}
/**
* Test grade history with grade updated by different sources
*
* @covers \gradereport_history\output\tablelog::get_sql_and_params
*
* @return void
*/
public function test_grade_history_with_different_sources(): void {
$this->resetAfterTest();
// Create a course.
$course = $this->getDataGenerator()->create_course();
// Create a user and enrol them in the course.
$user = $teacher = $this->getDataGenerator()->create_and_enrol($course, 'student');
// Create an assignment.
$assign = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
// Create a grade item.
$gi = \grade_item::fetch(['iteminstance' => $assign->id, 'itemtype' => 'mod', 'itemmodule' => 'assign']);
// Create some grade history entries with same time modifies.
$time = time();
$this->create_grade_history([
'itemid' => $gi->id,
'userid' => $user->id,
'usermodified' => $teacher->id,
'finalgrade' => 50,
'source' => 'mod/assign',
'timemodified' => $time,
]);
$this->create_grade_history([
'itemid' => $gi->id,
'userid' => $user->id,
'usermodified' => $teacher->id,
'finalgrade' => 60,
'source' => 'cli',
'timemodified' => $time,
]);
// Fetch the grade history.
$results = $this->get_tablelog_results(\context_course::instance($course->id));
// Check the grade history.
$this->assertCount(2, $results);
$assigngrade = array_pop($results);
$cligrade = array_pop($results);
// Check their values.
$this->assertEquals(60, $cligrade->finalgrade);
$this->assertEquals(50, $cligrade->prevgrade);
$this->assertEquals('cli', $cligrade->source);
$this->assertEquals(50, $assigngrade->finalgrade);
$this->assertEquals(null, $assigngrade->prevgrade);
$this->assertEquals('mod/assign', $assigngrade->source);
}
/**
* Asserts that the array of grade objects contains exactly the right IDs.
*

View File

@ -18,6 +18,7 @@ namespace gradereport_user\report;
use cache;
use context_course;
use core_grades\penalty_manager;
use course_modinfo;
use grade_grade;
use grade_helper;
@ -702,7 +703,7 @@ class user extends grade_report {
$gradeitemdata['graderaw'] = $gradeval;
$data['grade']['content'] = grade_format_gradevalue($gradeval,
$gradegrade->grade_item,
true) . $gradestatus;
true) . penalty_manager::show_penalty_indicator($gradegrade) . $gradestatus;
}
} else {
$gradestatusclass = '';
@ -729,7 +730,7 @@ class user extends grade_report {
$data['grade']['class'] = "{$class} {$gradestatusclass}";
$data['grade']['content'] = $gradepassicon . grade_format_gradevalue($gradeval,
$gradegrade->grade_item, true) . $gradestatus;
$gradegrade->grade_item, true) . penalty_manager::show_penalty_indicator($gradegrade) . $gradestatus;
$gradeitemdata['graderaw'] = $gradeval;
}
$data['grade']['headers'] = "$headercat $headerrow grade$userid";

View File

@ -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 <http://www.gnu.org/licenses/>.
}}
{{!
@template core_grades/grades/grader/overridden_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]",
"penaltyexempt": "true",
"id": "grade_313_624",
"label": "grade_313_624",
"name": "grade[313][624]"
}
}}
<div class="container">
<div class="row">
<div class="mr-0">{{#str}}originalgrade, core_grades{{/str}}: </div>
{{>core_grades/grades/grader/input}}
</div>
<div class="row">
<div class="mr-0">{{#str}}currentgrade, core_grades{{/str}}: {{{effectivegrade}}}</div>
</div>
<div class="row">
<div class="mr-0" title="{{exemptiontooltip}}">{{#str}}exemptpenalty, core_grades{{/str}}:</div>
<label for="{{exemptionid}}" class="accesshide">{{{exemptionlabel}}}</label>
<input type="checkbox" name="{{exemptionname}}"
value="{{deductedmark}}" id="{{exemptionid}}"
{{#penaltyexempt}}checked{{/penaltyexempt}}>
</div>
</div>

View File

@ -0,0 +1,43 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template core_grades/penalty_indicator
This template is used to render the penalty indicator.
Example context (json):
{
"penalty": 20,
"finalgrade": 50,
"maxgrade": 100,
"info": "Late penalty applied -20 marks"
}
}}
{{#icon}}
<span class="penalty-indicator-icon" title="{{info}}">
{{#pix}}{{name}}, {{component}}{{/pix}}
</span>
{{/icon}}
{{#finalgrade}}
<span class="penalty-indicator-value">
{{#grademax}}
{{finalgrade}} / {{grademax}}
{{/grademax}}
{{^grademax}}
{{finalgrade}}
{{/grademax}}
</span>
{{/finalgrade}}

View File

@ -0,0 +1,154 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades\output;
use advanced_testcase;
use grade_grade;
/**
* Test class for penalty_indicator
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class penalty_indicator_test extends advanced_testcase {
/**
* Data provider for test_export_for_template
* @return array
*/
public static function export_for_template_provider(): array {
return [
// Default icon, with final grade and max grade.
[
'expectedexport' => [
'penalty' => '10.00',
'finalgrade' => '90.00',
'icon' => [
'name' => 'i/risk_xss',
'component' => 'core',
],
'info' => 'Late penalty applied -10.00 marks',
'grademax' => '100.00',
],
'icon' => [],
'penalty' => 10,
'finalgrade' => 90,
'grademax' => 100,
'showfinalgrade' => true,
'showgrademax' => true,
],
// Custom icon, without max grade.
[
'expectedexport' => [
'penalty' => '10.00',
'finalgrade' => '90.00',
'icon' => [
'name' => 'i/flagged',
'component' => 'core',
],
'info' => 'Late penalty applied -10.00 marks',
'grademax' => null,
],
'icon' => ['name' => 'i/flagged', 'component' => 'core'],
'penalty' => 10,
'finalgrade' => 90,
'grademax' => 100,
'showfinalgrade' => true,
'showgrademax' => false,
],
// Icon only.
[
'expectedexport' => [
'penalty' => '10.00',
'icon' => [
'name' => 'i/risk_xss',
'component' => 'core',
],
'info' => 'Late penalty applied -10.00 marks',
'grademax' => null,
'finalgrade' => null,
],
'icon' => [],
'penalty' => 10,
'finalgrade' => 90,
'grademax' => 100,
'showfinalgrade' => false,
'showgrademax' => false,
],
];
}
/**
* Test penalty_indicator
*
* @dataProvider export_for_template_provider
*
* @covers \core_grades\output\penalty_indicator
*
* @param array $expectedexport The expected export data
* @param array $icon icon to display before the penalty
* @param float $penalty The penalty
* @param float $finalgrade The final grade
* @param float $grademax The max grade
* @param bool $showfinalgrade Whether to show the final grade
* @param bool $showgrademax Whether to show the max grade
*/
public function test_export_for_template(
array $expectedexport,
array $icon,
float $penalty,
float $finalgrade,
float $grademax,
bool $showfinalgrade,
bool $showgrademax
): void {
global $PAGE, $DB;
$this->resetAfterTest();
// Create a course.
$course = $this->getDataGenerator()->create_course();
// Create a user and enrol them in the course.
$user = $this->getDataGenerator()->create_and_enrol($course);
// Create an assignment.
$assign = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
// Create a grade item.
$gradeitem = \grade_item::fetch(['iteminstance' => $assign->id, 'itemtype' => 'mod', 'itemmodule' => 'assign']);
$DB->set_field('grade_items', 'grademax', $grademax, ['id' => $gradeitem->id]);
// Create a grade.
$grade = new grade_grade();
$grade->itemid = $gradeitem->id;
$grade->timemodified = time();
$grade->userid = $user->id;
$grade->finalgrade = $finalgrade;
$grade->deductedmark = $penalty;
$grade->insert();
$indicator = new \core_grades\output\penalty_indicator(2, $grade, $showfinalgrade, $showgrademax, $icon);
$renderer = $PAGE->get_renderer('core_grades');
$data = $indicator->export_for_template($renderer);
$this->assertEquals($expectedexport, $data);
}
}

View File

@ -0,0 +1,108 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_grades;
use advanced_testcase;
use grade_item;
/**
* Unit tests for penalty_manager class.
*
* @package core_grades
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_grades\penalty_manager
*/
final class penalty_manager_test extends advanced_testcase {
/**
* Test is_penalty_enabled_for_module method.
*
* @covers \core_grades\penalty_manager::is_penalty_enabled_for_module
*/
public function test_is_penalty_enabled_for_module(): void {
$this->resetAfterTest();
$this->setAdminUser();
// No modules are enabled by default.
$this->assertEmpty(penalty_manager::get_enabled_modules());
// Enable a module.
penalty_manager::enable_module('assign');
$this->assertCount(1, penalty_manager::get_enabled_modules());
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('assign'));
// Enable multiple modules.
penalty_manager::enable_modules(['quiz', 'forum', 'page']);
$this->assertCount(4, penalty_manager::get_enabled_modules());
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('assign'));
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('quiz'));
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('forum'));
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('page'));
// Disable a module.
penalty_manager::disable_module('assign');
$this->assertCount(3, penalty_manager::get_enabled_modules());
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('quiz'));
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('forum'));
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('page'));
// Disable multiple modules.
penalty_manager::disable_modules(['quiz', 'forum']);
$this->assertCount(1, penalty_manager::get_enabled_modules());
$this->assertTrue(penalty_manager::is_penalty_enabled_for_module('page'));
}
/**
* Test apply_grade_penalty_to_user method.
*
* @covers \core_grades\penalty_manager::apply_grade_penalty_to_user
*/
public function test_apply_grade_penalty_to_user(): void {
$this->resetAfterTest();
$this->setAdminUser();
// Create user, course and assignment.
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
// Get grade item.
$gradeitemparams = [
'courseid' => $course->id,
'itemtype' => 'mod',
'itemmodule' => 'assign',
'iteminstance' => $assign->id,
'itemnumber' => 0,
];
$gradeitem = grade_item::fetch($gradeitemparams);
grade_update('mod/assign',
$course->id,
'mod',
'assign',
$assign->id,
0,
['userid' => $user->id, 'rawgrade' => 90]);
$submissiondate = time();
$duedate = time();
$container = penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, $submissiondate, $duedate);
// No penalty by default.
$this->assertEquals(90, $container->get_grade_after_penalties());
}
}

View File

@ -142,6 +142,7 @@ $string['createcategory'] = 'Create category';
$string['createcategoryerror'] = 'Could not create a new category';
$string['creatinggradebooksettings'] = 'Creating gradebook settings';
$string['csv'] = 'CSV';
$string['currentgrade'] = 'Current grade';
$string['currentparentaggregation'] = 'Current parent aggregation';
$string['curveto'] = 'Curve to';
$string['decimalpoints'] = 'Overall decimal places';
@ -184,6 +185,7 @@ $string['errorcalculationnoequal'] = 'Formula must start with equal sign (=1+2)'
$string['errorcalculationunknown'] = 'Invalid formula';
$string['errorcalculationbroken'] = 'Error in the calculation of grade item {$a}.';
$string['errorgradevaluenonnumeric'] = 'Received non-numeric for low or high grade for';
$string['errornegativepenalty'] = 'Received negative value during penalty aggregation for grade ID {$a}';
$string['errornocalculationallowed'] = 'Calculations are not allowed for this item';
$string['errornocategorisedid'] = 'Could not get an uncategorised id!';
$string['errornocourse'] = 'Could not get course information';
@ -211,6 +213,7 @@ $string['eventscaleupdated'] = 'Scale updated';
$string['eventusergraded'] = 'User graded';
$string['excluded'] = 'Excluded';
$string['excluded_help'] = 'If ticked, the grade will not be included in any aggregation.';
$string['exemptpenalty'] = 'Exempt penalty';
$string['expand'] = 'Expand category';
$string['expandcriterion'] = 'Expand criterion';
$string['export'] = 'Export';
@ -330,6 +333,13 @@ $string['gradeoutcomescourses'] = 'Course outcomes';
$string['gradepass'] = 'Grade to pass';
$string['gradepass_help'] = 'This setting determines the minimum grade required to pass. The value is used in activity and course completion, and in the gradebook, where pass grades are highlighted in green and fail grades in red.';
$string['gradepassgreaterthangrade'] = 'The grade to pass can not be greater than the maximum possible grade {$a}';
$string['gradepenalty'] = 'Grade penalties';
$string['gradepenalty_enabledmodules'] = 'Enabled activities';
$string['gradepenalty_enabledmodules_help'] = 'Enable grade penalties for the selected activities.';
$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'] = 'Manage supported activities';
$string['gradepointdefault'] = 'Grade point default';
$string['gradepointdefault_help'] = 'This setting determines the default value for the grade point value available in a grade item.';
$string['gradepointdefault_validateerror'] = 'This setting must be an integer between 1 and the grade point maximum.';
@ -480,6 +490,7 @@ $string['lockverbose'] = 'Lock {$a->category} {$a->itemmodule} {$a->itemname}';
$string['lowest'] = 'Lowest';
$string['lowgradeletter'] = 'Low';
$string['manageoutcomes'] = 'Manage outcomes';
$string['managepenaltyplugins'] = 'Manage penalty plugins';
$string['manualitem'] = 'Manual item';
$string['mapfrom'] = 'Map from';
$string['mapfrom_help'] = 'Select the column in the spreadsheet containing data for identifying the user, such as username, user ID or email address.';
@ -520,6 +531,8 @@ $string['modgradedonotmodify'] = 'Do not modify existing grades';
$string['modgradeerrorbadpoint'] = 'Invalid grade value. This must be an integer between 1 and {$a}';
$string['modgradeerrorbadscale'] = 'Invalid scale selected. Please make sure you select a scale from the selections below.';
$string['modgrademaxgrade'] = 'Maximum grade';
$string['modgraderecalculatepenalty'] = 'Recalculate penalty';
$string['modgraderecalculatepenalty_help'] = 'The penalty will be recalculated for all users.';
$string['modgraderescalegrades'] = 'Rescale existing grades';
$string['modgraderescalegrades_help'] = 'When changing the maximum grades on a gradebook item you need to specify whether or not this will cause existing percentage grades to change as well.
@ -587,6 +600,7 @@ $string['numberofgrades'] = 'Number of grades';
$string['onascaleof'] = 'on a scale of {$a->grademin} to {$a->grademax}';
$string['operations'] = 'Operations';
$string['options'] = 'Options';
$string['originalgrade'] = 'Original grade';
$string['others'] = 'Others';
$string['otheruser'] = 'User';
$string['outcome'] = 'Outcome';
@ -706,6 +720,9 @@ $string['rawpct'] = 'Raw %';
$string['real'] = 'Real';
$string['realletter'] = 'Real (letter)';
$string['realpercentage'] = 'Real (percentage)';
$string['recalculatepenalty'] = 'Recalculate penalties';
$string['recalculatepenaltyconfirm'] = 'This will recalculate and apply penalties for all submissions in this context. Are you sure you want to continue?';
$string['recalculatepenaltysuccess'] = 'Successfully initiated penalty recalculation. There may be a delay before grades are updated.';
$string['recalculatinggrades'] = 'Recalculating grades';
$string['recalculatinggradesadhoc'] = 'The report will update automatically. You don\'t need to do anything.';
$string['recovergradesdefault'] = 'Recover grades default';

View File

@ -0,0 +1,146 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Defines classes used for plugin info.
*
* @package core
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\plugininfo;
use core\url;
/**
* Class for admin tool plugins.
*/
class gradepenalty extends base {
/**
* Allow the plugin to be uninstalled.
*
* @return true
*/
public function is_uninstall_allowed(): bool {
return true;
}
/**
* Get the URL to manage the penalty plugin.
*
* @return url
*/
public static function get_manage_url(): url {
return new url('/grade/penalty/manage_penalty_plugins.php');
}
/**
* Support disabling the plugin.
*
* @return bool
*/
public static function plugintype_supports_disabling(): bool {
return true;
}
/**
* Get the enabled plugins.
*
* @return array
*/
public static function get_enabled_plugins(): array {
// List of enabled plugins, string delimited.
$plugins = get_config('core_grades', 'gradepenalty_enabled_plugins');
// Return empty array if no plugins are enabled.
return $plugins ? array_flip(explode(',', $plugins)) : [];
}
/**
* Enable or disable a plugin.
*
* @param string $pluginname The name of the plugin.
* @param int $enabled Whether to enable or disable the plugin.
* @return bool
*/
public static function enable_plugin(string $pluginname, int $enabled): bool {
// Current enabled plugins.
$enabledplugins = self::get_enabled_plugins();
// If we are enabling the plugin.
if ($enabled) {
$enabledplugins[$pluginname] = $pluginname;
} else {
unset($enabledplugins[$pluginname]);
}
// Convert to string.
$enabledplugins = implode(',', array_keys($enabledplugins));
// Save the new list of enabled plugins.
set_config('gradepenalty_enabled_plugins', $enabledplugins, 'core_grades');
return true;
}
/**
* Check if the plugin is enabled.
*
* @return bool
*/
public function is_enabled(): bool {
return self::is_plugin_enabled($this->name);
}
/**
* If the provided plugin is enabled.
*
* @param string $pluginname The name of the plugin.
* @return bool if the plugin is enabled.
*/
public static function is_plugin_enabled(string $pluginname): bool {
// Check if the plugin contains plugin type, remove it.
$pluginname = str_replace('gradepenalty_', '', $pluginname);
return key_exists($pluginname, self::get_enabled_plugins());
}
/**
* Get the settings section name.
* Required for the settings page.
*
* @return string
*/
public function get_settings_section_name(): string {
return $this->component;
}
/**
* Setting url for the plugin.
*
*/
public function get_settings_url(): url {
$plugins = get_plugin_list_with_function('gradepenalty', 'get_settings_url');
if (isset($plugins[$this->component])) {
return component_callback($this->component, 'get_settings_url');
} else {
// Use the default settings page.
return parent::get_settings_url();
}
}
}

View File

@ -23,6 +23,7 @@
"coursereport": "course\/report",
"gradeexport": "grade\/export",
"gradeimport": "grade\/import",
"gradepenalty": "grade\/penalty",
"gradereport": "grade\/report",
"gradingform": "grade\/grading\/form",
"mlbackend": "lib\/mlbackend",

View File

@ -2060,6 +2060,8 @@
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="the time this grade was last modified"/>
<FIELD NAME="aggregationstatus" TYPE="char" LENGTH="10" NOTNULL="true" DEFAULT="unknown" SEQUENCE="false" COMMENT="One of several values describing how this grade_grade was used when calculating the aggregation. Possible values are &quot;unknown&quot;, &quot;dropped&quot;, &quot;novalue&quot;, &quot;used&quot;"/>
<FIELD NAME="aggregationweight" TYPE="number" LENGTH="10" NOTNULL="false" SEQUENCE="false" DECIMALS="5" COMMENT="If the aggregationstatus == 'included', then this is the percent this item contributed to the aggregation."/>
<FIELD NAME="deductedmark" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="The mark deducted from final grade"/>
<FIELD NAME="overriddenmark" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="The overridden mark before applied penalty"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>

View File

@ -1600,5 +1600,37 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025031800.00);
}
if ($oldversion < 2025031800.01) {
// Define field penalty to be added to grade_grades.
$table = new xmldb_table('grade_grades');
$field = new xmldb_field('deductedmark', XMLDB_TYPE_NUMBER, '10, 5', null,
XMLDB_NOTNULL, null, '0', 'aggregationweight');
// Conditionally launch add field penalty.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2025031800.01);
}
if ($oldversion < 2025031800.02) {
// 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, 2025031800.02);
}
return true;
}

View File

@ -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');
'timemodified', 'aggregationstatus', 'aggregationweight', 'deductedmark', 'overriddenmark');
/**
* Array of optional fields with default values (these should match db defaults)
@ -218,6 +218,12 @@ class grade_grade extends grade_object {
*/
public $label;
/** @var float $deductedmark mark deducted from final grade */
public float $deductedmark = 0;
/** @var float $overriddenmark mark overridden by teacher */
public float $overriddenmark = 0;
/**
* Returns array of grades for given grade_item+users
*
@ -1284,4 +1290,45 @@ class grade_grade extends grade_object {
$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;
}
}
}

View File

@ -2086,6 +2086,12 @@ class grade_item extends grade_object {
}
// end of hack alert
// Default deduction is 0.
$grade->deductedmark = 0;
// Default original overridden mark is 0.
$grade->overriddenmark = 0;
$gradechanged = false;
if (empty($grade->id)) {
$result = (bool)$grade->insert($source, $isbulkupdate);
@ -2146,6 +2152,36 @@ class grade_item extends grade_object {
return $result;
}
/**
* Update penalty value for given user
*
* @param int $userid The graded user
* @param float $deductedmark The mark deducted from final grade
*/
public function update_deducted_mark(int $userid, float $deductedmark): void {
$grade = new grade_grade([
'itemid' => $this->id,
'userid' => $userid,
]);
$grade->deductedmark = $deductedmark;
$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.

View File

@ -22,6 +22,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_grades\penalty_manager;
defined('MOODLE_INTERNAL') || die();
global $CFG;
@ -505,6 +507,7 @@ function grade_get_grades($courseid, $itemtype, $itemmodule, $iteminstance, $use
$grade->usermodified = $grade_grades[$userid]->usermodified;
$grade->datesubmitted = $grade_grades[$userid]->get_datesubmitted();
$grade->dategraded = $grade_grades[$userid]->get_dategraded();
$grade->deductedmark = $grade_grades[$userid]->deductedmark;
// create text representation of grade
if ($grade_item->gradetype == GRADE_TYPE_TEXT or $grade_item->gradetype == GRADE_TYPE_NONE) {

View File

@ -408,6 +408,8 @@ define ('PEPPER_ENTROPY', 112);
/** True if module can provide a grade */
define('FEATURE_GRADE_HAS_GRADE', 'grade_has_grade');
/** True if module can support grade penalty */
define('FEATURE_GRADE_HAS_PENALTY', 'grade_has_penalty');
/** True if module supports outcomes */
define('FEATURE_GRADE_OUTCOMES', 'outcomes');
/** True if module supports advanced grading methods */

View File

@ -4901,6 +4901,9 @@ class settings_navigation extends navigation_node {
}
}
// Grade penalty navigation.
\core_grades\penalty_manager::extend_navigation_course($coursenode, $course, $coursecontext);
// Check if we can view the gradebook's setup page.
if ($adminoptions->gradebook) {
$url = new moodle_url('/grade/edit/tree/index.php', array('id' => $course->id));
@ -4956,8 +4959,8 @@ class settings_navigation extends navigation_node {
// Let plugins hook into course navigation.
$pluginsfunction = get_plugins_with_function('extend_navigation_course', 'lib.php');
foreach ($pluginsfunction as $plugintype => $plugins) {
// Ignore the report plugin as it was already loaded above.
if ($plugintype == 'report') {
// Ignore the report and gradepenalty plugins as they were already loaded above.
if ($plugintype == 'report' || $plugintype == 'gradepenalty') {
continue;
}
foreach ($plugins as $pluginfunction) {

View File

@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025031800.01; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025031800.02; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.0dev+ (Build: 20250318)'; // Human-friendly version name