diff --git a/admin/settings/grades.php b/admin/settings/grades.php index b84239e5fc74b..86630922f1e7d 100644 --- a/admin/settings/grades.php +++ b/admin/settings/grades.php @@ -92,6 +92,11 @@ $temp->add(new admin_setting_configtext('gradereport_mygradeurl', new lang_string('externalurl', 'grades'), new lang_string('externalurl_desc', 'grades'), '')); + + // Enable grade penalty or not. + $temp->add(new admin_setting_configcheckbox('gradepenalty_enabled', + new lang_string('gradepenalty_enabled', 'grades'), + new lang_string('gradepenalty_enabled_help', 'grades'), 0)); } $ADMIN->add('grades', $temp); @@ -221,5 +226,42 @@ } } -} // end of speedup + // Penalty. + if (get_config('core', 'gradepenalty_enabled')) { + $ADMIN->add('grades', new admin_category('gradepenalty', new lang_string('gradepenalty', 'grades'))); + + // Supported modules. + $modules = core_grades\local\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_supportedplugins', + new lang_string('gradepenalty_supportedplugins', 'grades'), + new lang_string('gradepenalty_supportedplugins_help', 'grades'), [], $options)); + + $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) { + // 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 diff --git a/grade/classes/hook/after_penalty_applied.php b/grade/classes/hook/after_penalty_applied.php new file mode 100644 index 0000000000000..3db4342bac648 --- /dev/null +++ b/grade/classes/hook/after_penalty_applied.php @@ -0,0 +1,35 @@ +. + +namespace core_grades\hook; + +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook after penalty is applied. + * + * This hook will be dispatched after the penalty is applied to the grade. + * Allow plugins to perform further action after penalty is applied. + * + * @package core_grades + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allow plugins to perform further action after penalty is applied.')] +#[\core\attribute\tags('grade')] +class after_penalty_applied implements StoppableEventInterface { + use grade_penalty_handler; +} diff --git a/grade/classes/hook/before_penalty_applied.php b/grade/classes/hook/before_penalty_applied.php new file mode 100644 index 0000000000000..587778a43be12 --- /dev/null +++ b/grade/classes/hook/before_penalty_applied.php @@ -0,0 +1,62 @@ +. + +namespace core_grades\hook; + +use core\plugininfo\gradepenalty; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook before penalty is applied. + * + * This hook will be dispatched before the penalty is applied to the grade. + * Allow plugins to do penalty calculations. + * + * @package core_grades + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allow plugins to do penalty calculations.')] +#[\core\attribute\tags('grade')] +class before_penalty_applied implements StoppableEventInterface { + use grade_penalty_handler; + + /** + * Set deducted grade. + * We restrict the hook to be used by grade penalty plugins only. + * + * @param string $pluginname the plugin name + * @param float $deductedgrade The deducted grade + */ + public function apply_penalty(string $pluginname, float $deductedgrade): void { + // Check if the plugin is enabled. + if (gradepenalty::is_plugin_enabled($pluginname)) { + // Aggregate the deducted grade. + $this->deductedgrade += $deductedgrade; + + // Update the final grade. + $this->gradeafterpenalty = $this->gradebeforepenalty - $this->deductedgrade; + // Cannot be negative. + $this->gradeafterpenalty = max($this->gradeitem->grademin, $this->gradeafterpenalty); + // Cannot be greater than the maximum grade. + $this->gradeafterpenalty = min($this->gradeafterpenalty, $this->gradeitem->grademax); + + // Update the deducted percentage. + // The percentage can be used by modules to calculate penalty for their own grade, such as assign_grade. + $this->deductedpercentage = $this->deductedgrade / $this->gradebeforepenalty * 100; + } + } +} diff --git a/grade/classes/hook/grade_penalty_handler.php b/grade/classes/hook/grade_penalty_handler.php new file mode 100644 index 0000000000000..04a045329d774 --- /dev/null +++ b/grade/classes/hook/grade_penalty_handler.php @@ -0,0 +1,101 @@ +. + +namespace core_grades\hook; + +use core\hook\stoppable_trait; +use grade_item; + +/** + * Trait for providing the common methods for the grade penalty hooks. + * + * @package core_grades + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait grade_penalty_handler { + use stoppable_trait; + + /** + * Constructor for the hook. + * + * @param int $userid The user id + * @param grade_item $gradeitem The grade item object + * @param int $submissiondate the submission date + * @param int $duedate the due date + * @param float $gradebeforepenalty original final grade + * @param float $deductedpercentage the deducted percentage from final grade + * @param float $deductedgrade the deducted grade + * @param ?float $gradeafterpenalty grade after deduction + */ + public function __construct( + /** @var int The user id */ + public readonly int $userid, + /** @var grade_item $gradeitem the grade item object*/ + public readonly grade_item $gradeitem, + /** @var float the submission date */ + public readonly int $submissiondate, + /** @var float the due date */ + public readonly int $duedate, + /** @var float original final grade */ + private float $gradebeforepenalty, + /** @var int the deducted percentage from final grade */ + private float $deductedpercentage = 0.0, + /** @var float the deducted grade */ + private float $deductedgrade = 0.0, + /** @var ?float grade after deduction */ + private ?float $gradeafterpenalty = null, + ) { + if ($this->gradeafterpenalty === null) { + $this->gradeafterpenalty = $this->gradebeforepenalty; + } + } + + /** + * Get grade before penalty is applied. + * + * @return float The penalized grade + */ + public function get_grade_before_penalty(): float { + return $this->gradebeforepenalty; + } + + /** + * Get the penalized grade. + * + * @return float The penalized grade + */ + public function get_grade_after_penalty(): float { + return $this->gradeafterpenalty; + } + + /** + * Get the deducted percentage. + * + */ + public function get_deducted_percentage(): float { + return $this->deductedpercentage; + } + + /** + * Get the deducted grade. + * + */ + public function get_deducted_grade(): float { + return $this->deductedgrade; + } + +} diff --git a/grade/classes/local/penalty/manager.php b/grade/classes/local/penalty/manager.php new file mode 100644 index 0000000000000..550b077cd4bc0 --- /dev/null +++ b/grade/classes/local/penalty/manager.php @@ -0,0 +1,136 @@ +. + +namespace core_grades\local\penalty; + +use core\di; +use core\hook; +use core_grades\hook\after_penalty_applied; +use core_grades\hook\before_penalty_applied; +use grade_item; + +/** + * 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 manager { + /** + * Lists of modules which support 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; + } + + /** + * Whether penalty feature is enabled. + * + * @return bool if penalty is enabled + */ + public static function is_penalty_enabled(): bool { + return (bool) get_config('core', 'gradepenalty_enabled'); + } + + /** + * Whether penalty is enabled for a module. + * + * @param string $module the module name. + * @return bool if penalty is enabled for the module. + */ + public static function is_penalty_enabled_for_module(string $module): bool { + // Return false if the penalty feature is disabled. + if (!self::is_penalty_enabled()) { + return false; + } + + // Check if the module is in the enable list. + $supportedmodules = get_config('core', 'gradepenalty_supportedplugins'); + if (!in_array($module, explode(',', $supportedmodules))) { + return false; + } + return true; + } + + /** + * This function should be run after a raw grade is updated/created for a user. + * + * @param int $userid ID of user + * @param grade_item $gradeitem the grade item object + * @param int $submissiondate submission date + * @param int $duedate due date + * @param bool $previewonly do not update the grade if true + * @return float returns the deducted percentage. + */ + public static function apply_penalty(int $userid, grade_item $gradeitem, + int $submissiondate, int $duedate, bool $previewonly = false): float { + // If the grade item belong to a supported module. + if (!self::is_penalty_enabled_for_module($gradeitem->itemmodule)) { + return 0; + } + + // Check if there is any existing grade. + $grade = $gradeitem->get_final($userid); + if (!$grade || !$grade->rawgrade) { + debugging('No raw grade found for user ' . $userid . ' and grade item ' . $gradeitem->id, DEBUG_DEVELOPER); + return 0; + } else if ($grade->rawgrade <= 0 || $grade->finalgrade <= 0) { + // There is no penalty for zero or negative grades. + return 0; + } else if ($grade->overridden > 0 || $grade->locked > 0) { + // Do not apply penalty if the grade is overridden or locked. + // We may need a separate setting to allow penalty for overridden grades. + return 0; + } + + // Hook for plugins to calculate the penalty. + $beforepenaltyhook = new before_penalty_applied($userid, $gradeitem, $submissiondate, $duedate, $grade->finalgrade); + di::get(hook\manager::class)->dispatch($beforepenaltyhook); + + // Apply the penalty to the grade. + if (!$previewonly) { + // Update the final grade after the penalty is applied. + $gradeitem->update_raw_grade($userid, $beforepenaltyhook->get_grade_after_penalty()); + + // Hook for plugins to process further after the penalty is applied to the grade. + $afterpenaltyhook = new after_penalty_applied($userid, $gradeitem, $submissiondate, $duedate, + $beforepenaltyhook->get_grade_before_penalty(), + $beforepenaltyhook->get_deducted_percentage(), + $beforepenaltyhook->get_deducted_grade(), + $beforepenaltyhook->get_grade_after_penalty() + ); + di::get(hook\manager::class)->dispatch($afterpenaltyhook); + } + + // Clamp the deducted percentage between 0% and 100%. + $deductedpercentage = $beforepenaltyhook->get_deducted_percentage(); + $deductedpercentage = max(0, $deductedpercentage); + $deductedpercentage = min(100, $deductedpercentage); + + return $deductedpercentage; + } +} diff --git a/grade/classes/table/gradepenalty_management_table.php b/grade/classes/table/gradepenalty_management_table.php new file mode 100644 index 0000000000000..20d1aa0bbefba --- /dev/null +++ b/grade/classes/table/gradepenalty_management_table.php @@ -0,0 +1,49 @@ +. + +namespace core_grades\table; + +use core_admin\table\plugin_management_table; +use moodle_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 moodle_url + */ + protected function get_action_url(array $params = []): moodle_url { + return new moodle_url('/grade/penalty/manage_penalty_plugins.php', $params); + } +} diff --git a/grade/penalty/manage_penalty_plugins.php b/grade/penalty/manage_penalty_plugins.php new file mode 100644 index 0000000000000..bd2a0ceb3f6a8 --- /dev/null +++ b/grade/penalty/manage_penalty_plugins.php @@ -0,0 +1,66 @@ +. + +/** + * 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\notification; +use core_grades\table\gradepenalty_management_table; + +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 moodle_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(); diff --git a/grade/tests/fixtures/hooks/hooks.php b/grade/tests/fixtures/hooks/hooks.php new file mode 100644 index 0000000000000..c389c203817ee --- /dev/null +++ b/grade/tests/fixtures/hooks/hooks.php @@ -0,0 +1,42 @@ +. + +/** + * Hook fixtures registered for testing. + * + * @package core_grades + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => \core_grades\hook\before_penalty_applied::class, + 'callback' => \core_grades\local\penalty\test\hooks\plugin1_hook_listener::class . '::apply_penalty', + 'priority' => 1000, + ], + [ + 'hook' => \core_grades\hook\before_penalty_applied::class, + 'callback' => \core_grades\local\penalty\test\hooks\plugin2_hook_listener::class . '::apply_penalty', + 'priority' => 800, + ], + [ + 'hook' => \core_grades\hook\after_penalty_applied::class, + 'callback' => \core_grades\local\penalty\test\hooks\plugin3_hook_listener::class . '::show_debugging', + ], +]; diff --git a/grade/tests/fixtures/hooks/plugin1_hook_listener.php b/grade/tests/fixtures/hooks/plugin1_hook_listener.php new file mode 100644 index 0000000000000..bde8afb8ccad6 --- /dev/null +++ b/grade/tests/fixtures/hooks/plugin1_hook_listener.php @@ -0,0 +1,48 @@ +. + +namespace core_grades\local\penalty\test\hooks; + +use core_grades\hook\before_penalty_applied; + +/** + * Hook fixtures for testing of hooks. + * + * @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 plugin1_hook_listener { + /** + * Apply penalty. + * + * @param before_penalty_applied $hook + * @return void + */ + public static function apply_penalty( + \core_grades\hook\before_penalty_applied $hook + ): void { + // Dates are available in the hook. + debugging('Submission date: ' . $hook->submissiondate); + debugging('Due date: ' . $hook->duedate); + + // Deduct 10% of the maximum grade. + $deductedgrade = $hook->gradeitem->grademax * 0.2; + $grademax = format_float($hook->gradeitem->grademax); + debugging("fake_deduction: Deducting 20% of the maximum grade"); + $hook->apply_penalty('fake_deduction', $deductedgrade); + } +} diff --git a/grade/tests/fixtures/hooks/plugin2_hook_listener.php b/grade/tests/fixtures/hooks/plugin2_hook_listener.php new file mode 100644 index 0000000000000..fbbff3bf53840 --- /dev/null +++ b/grade/tests/fixtures/hooks/plugin2_hook_listener.php @@ -0,0 +1,42 @@ +. + +namespace core_grades\local\penalty\test\hooks; + +use core_grades\hook\before_penalty_applied; + +/** + * Hook fixtures for testing of hooks. + * + * @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 plugin2_hook_listener { + /** + * Apply penalty. + * + * @param before_penalty_applied $hook + * @return void + */ + public static function apply_penalty( + \core_grades\hook\before_penalty_applied $hook + ): void { + // Apply bonus grade. + debugging('fake_bonus: a fixed bonus grade of 10'); + $hook->apply_penalty('fake_bonus', -10); + } +} diff --git a/grade/tests/fixtures/hooks/plugin3_hook_listener.php b/grade/tests/fixtures/hooks/plugin3_hook_listener.php new file mode 100644 index 0000000000000..15be06e68ad0d --- /dev/null +++ b/grade/tests/fixtures/hooks/plugin3_hook_listener.php @@ -0,0 +1,43 @@ +. + +namespace core_grades\local\penalty\test\hooks; + +use core_grades\hook\after_penalty_applied; + +/** + * Hook fixtures for testing of hooks. + * + * @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 plugin3_hook_listener { + /** + * Apply penalty. + * + * @param after_penalty_applied $hook + * @return void + */ + public static function show_debugging( + \core_grades\hook\after_penalty_applied $hook + ): void { + debugging('Grade before: ' . $hook->get_grade_before_penalty()); + debugging('Grade after: ' . $hook->get_grade_after_penalty()); + debugging('Deducted percentage: ' . $hook->get_deducted_percentage()); + debugging('Deducted grade: ' . $hook->get_deducted_grade()); + } +} diff --git a/grade/tests/local/penalty/penalty_test.php b/grade/tests/local/penalty/penalty_test.php new file mode 100644 index 0000000000000..c20b6a6a66230 --- /dev/null +++ b/grade/tests/local/penalty/penalty_test.php @@ -0,0 +1,332 @@ +. + +namespace core_grades\local\penalty; + +use grade_item; +use stdClass; + +/** + * Test for 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 + */ +final class penalty_test extends \advanced_testcase { + /** @var stdClass $user user whose grade will be updated */ + private $user; + + /** @var grade_item $gradeitem grade item */ + private $gradeitem; + + /** @var stdClass $course course */ + private $course; + + /** @var stdClass $assign assignment */ + private $assign; + + /** + * Setup required fixtures, course, assign, user. + */ + private function setup_test(): void { + // Hook mock up. + require_once(__DIR__ . '/../../fixtures/hooks/plugin1_hook_listener.php'); + require_once(__DIR__ . '/../../fixtures/hooks/plugin2_hook_listener.php'); + require_once(__DIR__ . '/../../fixtures/hooks/plugin3_hook_listener.php'); + + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => 'grade/tests/fixtures/hooks/hooks.php', + ]), + ); + + // Create user, course and assignment. + $this->user = $this->getDataGenerator()->create_user(); + $this->course = $this->getDataGenerator()->create_course(); + $this->assign = $this->getDataGenerator()->create_module('assign', ['course' => $this->course->id]); + + // Get grade item. + $gradeitemparams = [ + 'courseid' => $this->course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $this->assign->id, + 'itemnumber' => 0, + ]; + $this->gradeitem = grade_item::fetch($gradeitemparams); + } + + /** + * Update grade. + * + * @param float $rawgrade raw grade. + */ + private function update_grade(float $rawgrade): void { + grade_update('mod/assign', $this->course->id, 'mod', 'assign', $this->assign->id, 0, + ['userid' => $this->user->id, 'rawgrade' => $rawgrade]); + } + + /** + * Get final grade + * + * @return float the final grade for current user. + */ + private function get_final_grade(): float { + return $this->gradeitem->get_final($this->user->id)->finalgrade; + } + + /** + * Data provider for test_apply_penalty. + * + * @return array test data. + */ + public static function apply_penalty_provider(): array { + return [ + // Deduction: 20% of max grade and bonus is a fixed grade of 10. + [ + // The params: submissiondate, duedate, grademin, grademax. + DAYSECS * 1, DAYSECS + 1, 0, 100, + // And rawgrade, finalgrade, $enabledplugins. + 100, 100, ['fake_deduction', 'fake_bonus'], + // And deductedgrade, deductedpercentage, expectedgrade. + 10, 10, 90, + ], + [ + // Zero grade, no penalty. + DAYSECS * 1, DAYSECS + 1, 0, 100, + 0, 0, ['fake_deduction', 'fake_bonus'], + 0, 0, 0, + ], + // Bonus grade only. + [ + // Cannot be more than max grade. + DAYSECS * 1, DAYSECS + 1, 0, 100, + 100, 100, ['fake_bonus'], + -10, -10, 100, + ], + [ + // Grade with 50 plus bonus of 10. + DAYSECS * 1, DAYSECS + 1, 0, 100, + 50, 50, ['fake_bonus'], + -10, -20, 60, + ], + // Deduction only. + [ + // Deduct 20% of max grade. + DAYSECS * 1, DAYSECS + 1, 0, 100, + 100, 100, ['fake_deduction'], + 20, 20, 80, + ], + [ + // Cannot be less than min grade. + DAYSECS * 1, DAYSECS + 1, 0, 100, + 10, 10, ['fake_deduction'], + 20, 200, 0, + ], + // Max grade of 80. + [ + // Cannot be more than max grade. + DAYSECS * 1, DAYSECS + 1, 0, 80, + 80, 80, ['fake_bonus'], + -10, -12.5, 80, + ], + [ + // Deduct 20% of max grade. + DAYSECS * 1, DAYSECS + 1, 0, 80, + 80, 80, ['fake_deduction'], + 16, 20, 64, + ], + [ + // Cannot be less than min grade. + DAYSECS * 1, DAYSECS + 1, 0, 80, + 10, 10, ['fake_deduction'], + 16, 160, 0, + ], + // Min grade of 50. + [ + // Deduct 20% of max grade. + DAYSECS * 1, DAYSECS + 1, 50, 100, + 100, 100, ['fake_deduction'], + 20, 20, 80, + ], + [ + // Cannot be less than min grade. + DAYSECS * 1, DAYSECS + 1, 50, 100, + 50, 50, ['fake_deduction'], + 20, 40, 50, + ], + ]; + } + + /** + * Test apply_penalty. + * + * @dataProvider apply_penalty_provider + * + * @covers \core_grades\local\penalty\manager::apply_penalty + * @covers \core_grades\hook\before_penalty_applied + * @covers \core_grades\hook\after_penalty_applied + * + * @param int $submissiondate submission date + * @param int $duedate due date + * @param float $grademin grade min + * @param float $grademax grade max + * @param float $rawgrade raw grade + * @param float $finalgrade final grade + * @param array $enabledplugins enabled plugins + * @param float $deductedgrade deducted grade + * @param float $deductedpercentage deducted percentage + * @param float $expectedgrade expected grade + */ + public function test_apply_penalty(int $submissiondate, int $duedate, float $grademin, float $grademax, + float $rawgrade, float $finalgrade, array $enabledplugins, + float $deductedgrade, float $deductedpercentage, float $expectedgrade): void { + global $DB; + $this->resetAfterTest(); + $this->setup_test(); + + // Update max/min grade. + $DB->set_field('grade_items', 'grademin', $grademin, ['id' => $this->gradeitem->id]); + $DB->set_field('grade_items', 'grademax', $grademax, ['id' => $this->gradeitem->id]); + $this->gradeitem = grade_item::fetch(['id' => $this->gradeitem->id]); + + // Grade for the user. + $this->update_grade($rawgrade); + + // Penalty is not enabled. + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, $submissiondate, $duedate); + $this->assertEquals($finalgrade, $this->get_final_grade()); + + // Enable penalty. But the assign module is not supported/enabled. + set_config('gradepenalty_enabled', 1); + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, $submissiondate, $duedate); + $this->assertEquals($finalgrade, $this->get_final_grade()); + + // Enable assign module. + set_config('gradepenalty_supportedplugins', 'quiz,assign'); + + // Enable fake grade penalty plugins. + foreach ($enabledplugins as $plugin) { + \core\plugininfo\gradepenalty::enable_plugin($plugin, true); + } + + // Apply penalty. + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, $submissiondate, $duedate); + + // Expect debugging messages from hooks. + if ($rawgrade <= 0) { + $expecteddebugmessages = []; + } else { + $expecteddebugmessages = [ + "Submission date: $submissiondate", + "Due date: $duedate", + "fake_deduction: Deducting 20% of the maximum grade", + "fake_bonus: a fixed bonus grade of 10", + "Grade before: $finalgrade", + "Grade after: $expectedgrade", + "Deducted percentage: $deductedpercentage", + "Deducted grade: $deductedgrade", + ]; + } + $this->assertdebuggingcalledcount(count($expecteddebugmessages), $expecteddebugmessages); + + // Check expected final grade. + $this->assertEquals($expectedgrade, $this->get_final_grade()); + } + + /** + * Test with no grade. + * The penalty should be only applied on existing grade. + * + * @covers \core_grades\local\penalty\manager::apply_penalty + * @covers \core_grades\hook\before_penalty_applied + * @covers \core_grades\hook\after_penalty_applied + */ + public function test_no_grade(): void { + $this->resetAfterTest(); + $this->setup_test(); + // Enable grade penalty. + set_config('gradepenalty_enabled', 1); + set_config('gradepenalty_supportedplugins', 'quiz,assign'); + foreach (['test_plugin1', 'test_plugin2', 'test_plugin3'] as $plugin) { + \core\plugininfo\gradepenalty::enable_plugin($plugin, true); + } + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, DAYSECS, DAYSECS * 2); + // There should be one debugging message. + $messages = $this->getDebuggingMessages(); + $this->assertdebuggingcalledcount(1); + $this->assertStringContainsString('No raw grade found for user', $messages[0]->message); + } + + /** + * Test when penalty is should not be applied + * + * @covers \core_grades\local\penalty\manager::apply_penalty + */ + public function test_no_penalty(): void { + global $DB; + $this->resetAfterTest(); + $this->setup_test(); + + // Enable grade penalty. + set_config('gradepenalty_enabled', 1); + set_config('gradepenalty_supportedplugins', 'quiz,assign'); + foreach (['test_plugin1', 'test_plugin2', 'test_plugin3'] as $plugin) { + \core\plugininfo\gradepenalty::enable_plugin($plugin, true); + } + + // Zero grade. + $this->update_grade(0); + // No penalty should be applied. + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, DAYSECS, DAYSECS * 2); + // No penalty hook should be called. + $this->assertdebuggingcalledcount(0); + + // Overridden grade. + $this->update_grade(100); + // Set it as overridden. + $DB->set_field('grade_grades', 'overridden', time(), [ + 'itemid' => $this->gradeitem->id, + 'userid' => $this->user->id, + ]); + // No penalty should be applied. + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, DAYSECS, DAYSECS * 2); + // No penalty hook should be called. + $this->assertdebuggingcalledcount(0); + // Remove overridden. + $DB->set_field('grade_grades', 'overridden', 0, [ + 'itemid' => $this->gradeitem->id, + 'userid' => $this->user->id, + ]); + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, DAYSECS, DAYSECS * 2); + // Expect debugging messages from hooks. + $this->assertdebuggingcalledcount(8); + + // Locked grade. + $this->update_grade(100); + // Set it as locked. + $DB->set_field('grade_grades', 'locked', time(), [ + 'itemid' => $this->gradeitem->id, + 'userid' => $this->user->id, + ]); + // No penalty should be applied. + apply_grade_penalty_to_user($this->user->id, $this->gradeitem, DAYSECS, DAYSECS * 2); + // No penalty hook should be called. + $this->assertdebuggingcalledcount(0); + } +} diff --git a/lang/en/grades.php b/lang/en/grades.php index ac1620e57c379..728b09b4aca13 100644 --- a/lang/en/grades.php +++ b/lang/en/grades.php @@ -334,6 +334,11 @@ $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_enabled'] = 'Grade penalty'; +$string['gradepenalty_enabled_help'] = 'If enabled, the penalty will be applied to the grades of supported modules.'; +$string['gradepenalty_supportedplugins'] = 'Supported modules'; +$string['gradepenalty_supportedplugins_help'] = 'Enable the grade penalty for the selected modules.'; $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.'; @@ -484,6 +489,7 @@ $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.'; diff --git a/lib/classes/plugininfo/gradepenalty.php b/lib/classes/plugininfo/gradepenalty.php new file mode 100644 index 0000000000000..fd6031f5255bb --- /dev/null +++ b/lib/classes/plugininfo/gradepenalty.php @@ -0,0 +1,130 @@ +. + +/** + * 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_component; +use moodle_url; + +/** + * Class for admin tool plugins. + */ +class gradepenalty extends base { + + + /** + * Allow the plugin to be uninstalled. + * + * @return true + */ + public function is_uninstall_allowed() { + return true; + } + + /** + * Get the URL to manage the penalty plugin. + * + * @return moodle_url + */ + public static function get_manage_url() { + return new moodle_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() { + // 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 { + 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() { + return $this->component; + } +} diff --git a/lib/components.json b/lib/components.json index 72680af683e9d..68cdc4573c57f 100644 --- a/lib/components.json +++ b/lib/components.json @@ -21,6 +21,7 @@ "coursereport": "course\/report", "gradeexport": "grade\/export", "gradeimport": "grade\/import", + "gradepenalty": "grade\/penalty", "gradereport": "grade\/report", "gradingform": "grade\/grading\/form", "mlbackend": "lib\/mlbackend", diff --git a/lib/gradelib.php b/lib/gradelib.php index a0e444744d1d9..8af5c4f3516bd 100644 --- a/lib/gradelib.php +++ b/lib/gradelib.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core_grades\local\penalty\manager as penalty_manager; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -1667,3 +1669,24 @@ function grade_get_date_for_user_grade(\stdClass $grade, \stdClass $user): ?int return $grade->datesubmitted; } } + +/** + * Apply penalty to 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 float deducted penalty percentage + */ +function apply_grade_penalty_to_user(int $userid, grade_item $gradeitem, + int $submissiondate, int $duedate, bool $previewonly = false): float { + try { + $deductedpercentage = penalty_manager::apply_penalty($userid, $gradeitem, $submissiondate, $duedate, $previewonly); + } catch (\core\exception\moodle_exception $e) { + debugging($e->getMessage(), DEBUG_DEVELOPER); + return 0; + } + return $deductedpercentage; +} diff --git a/lib/moodlelib.php b/lib/moodlelib.php index a74d9a4b26136..a7cfe8727563a 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -416,6 +416,8 @@ /** 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 */