ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç db/install.php000064400000002377152266501530007326 0ustar00. /** * Grade penalty duedate installation script. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use gradepenalty_duedate\penalty_rule; /** * Create a default 0% penalty rule. */ function xmldb_gradepenalty_duedate_install(): void { // Default penalty rule. $rule = new penalty_rule(); $rule->set('contextid', context_system::instance()->id); $rule->set('overdueby', 1); $rule->set('penalty', 0); $rule->set('sortorder', 0); $rule->save(); } db/access.php000064400000002162152266501530007111 0ustar00. /** * Grade penalty duedate caps. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'gradepenalty/duedate:manage' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, 'archetypes' => [ 'manager' => CAP_ALLOW, ], ], ]; db/install.xml000064400000003170152266501530007327 0ustar00
lang/en/gradepenalty_duedate.php000064400000006467152266501530012774 0ustar00. /** * Strings for component 'gradepenalty_duedate', language 'en'. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['addrule'] = 'Add rule'; $string['deleteallrules'] = 'Delete all rules'; $string['duedate:manage'] = 'Permission to manage penalty rules'; $string['duedaterule'] = 'Penalty rules'; $string['editduedaterule'] = 'Edit penalty rules'; $string['error_overdueby_abovevalue'] = 'The overdue amount must be greater than the amount for the rule above ({$a}).'; $string['error_overdueby_maxvalue'] = 'The overdue amount cannot be greater than {$a}.'; $string['error_overdueby_minvalue'] = 'The overdue amount must be greater than or equal to {$a}.'; $string['error_penalty_abovevalue'] = 'The penalty must be greater than the penalty for the rule above ({$a}%).'; $string['error_penalty_maxvalue'] = 'The penalty cannot be greater than {$a}%.'; $string['error_penalty_minvalue'] = 'The penalty must be greater than or equal to {$a}%.'; $string['existingrule'] = 'Existing rules'; $string['finalpenaltyrule'] = 'Final penalty rule'; $string['finalpenaltyrule_help'] = 'The final penalty rule specifies the penalty that is applied to submissions overdue longer than for any other penalty rule.'; $string['insertrule'] = 'Insert below'; $string['overdueby'] = 'Overdue'; $string['overdueby_help'] = 'The time in seconds after the due date that the penalty will be applied.'; $string['overdueby_label'] = 'Overdue:'; $string['overdueby_lastrow'] = '> {$a}'; $string['overdueby_onerow'] = 'All late submissions'; $string['overdueby_row'] = '≤ {$a}'; $string['penalty'] = 'Penalty'; $string['penalty_help'] = 'The penalty in percent that will be applied for late submissions.'; $string['penalty_label'] = 'Penalty:'; $string['penaltyrule'] = 'Penalty rules'; $string['penaltyrule_group'] = 'Penalty rule {no}'; $string['penaltyrule_inherited'] = 'The penalty rules in this context are inherited from a parent context. Click Edit to override the values.'; $string['penaltyrule_not_inherited'] = 'Click Edit to change or add rules.'; $string['penaltyrule_overridden'] = 'The penalty rules are overridden. Click Reset to remove overridden rules. This will remove all rules if there is no rule in the parent context.'; $string['pluginname'] = 'Late submission penalties'; $string['privacy:metadata:gradepenalty_duedate_rule'] = 'Grade penalty due date table'; $string['privacy:metadata:gradepenalty_duedate_rule:usermodified'] = 'User who modified the rule'; $string['resetconfirm'] = 'This will remove all rules in this context. Are you sure you want to continue?'; lib.php000064400000006415152266501530006036 0ustar00. /** * The gradepenalty_duedate lib file. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\url; /** * Extend the course navigation with a penalty rule settings. * * @param navigation_node $navigation The settings navigation object * @param stdClass $course The course * @param context $context Course context * @return void */ function gradepenalty_duedate_extend_navigation_course(navigation_node $navigation, stdClass $course, context $context): void { if (has_capability('gradepenalty/duedate:manage', $context)) { $url = new url('/grade/penalty/duedate/manage_penalty_rule.php', ['contextid' => $context->id]); $name = get_string('penaltyrule', 'gradepenalty_duedate'); $navigation->add($name, $url, navigation_node::TYPE_SETTING, null, null, new pix_icon('i/settings', '')); } } /** * Extend the module navigation with a penalty rule settings. * * @param navigation_node $navigation The settings navigation object * @param cm_info $cm The course module * @return void */ function gradepenalty_duedate_extend_navigation_module(navigation_node $navigation, cm_info $cm): void { $context = context_module::instance($cm->id); if (has_capability('gradepenalty/duedate:manage', $context)) { $url = new url('/grade/penalty/duedate/manage_penalty_rule.php', ['contextid' => $context->id]); $name = get_string('penaltyrule', 'gradepenalty_duedate'); $navigation->add($name, $url, navigation_node::TYPE_SETTING, null, null, new pix_icon('i/settings', '')); } } /** * Load penalty rule form. * * @param array $args parameters to load the form * @return string html and js of the form */ function gradepenalty_duedate_output_fragment_penalty_rule_form(array $args): string { $context = $args['context']; $params = [ 'contextid' => $context->id, 'action' => new url('/grade/penalty/duedate/manage_penalty_rule.php', ['contextid' => $context->id]), 'penaltyrules' => json_decode($args['penaltyrules'], true), 'finalpenaltyrule' => $args['finalpenaltyrule'], ]; // Load edit penalty form. $form = new gradepenalty_duedate\output\form\edit_penalty_form($params['action'], $params); // Return html and js. return $form->render(); } /** * Define the setting page for the penalty rule. */ function gradepenalty_duedate_get_settings_url(): url { return new url('/grade/penalty/duedate/manage_penalty_rule.php', [ 'contextid' => \core\context\system::instance()->id, ]); } version.php000064400000002302152266501530006744 0ustar00. /** * Version details for the duedate penalty * * @package gradepenalty_duedate * @copyright 1999 onwards Martin Dougiamas (http://dougiamas.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041400; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2025040800; // Requires this Moodle version. $plugin->component = 'gradepenalty_duedate'; // Full name of the plugin (used for diagnostics). classes/privacy/provider.php000064400000014041152266501530012226 0ustar00. namespace gradepenalty_duedate\privacy; use context; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; /** * Privacy Subsystem for gradepenalty_duedate implementing null_provider. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist A list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { $sql = "SELECT ctx.id FROM {gradepenalty_duedate_rule} gdr JOIN {context} ctx ON ctx.instanceid = gdr.usermodified AND ctx.contextlevel = :contextlevel WHERE gdr.usermodified = :userid"; $params = ['userid' => $userid, 'contextlevel' => CONTEXT_USER]; $contextlist = new contextlist(); $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist): void { global $DB; $user = $contextlist->get_user(); // Get all the rules edited by the user. $rules = $DB->get_records('gradepenalty_duedate_rule', ['usermodified' => $user->id]); $data = (object) $rules; // Write out the data. writer::with_context(\context_user::instance($user->id))->export_data([ get_string('privacy:metadata:gradepenalty_duedate_rule', 'gradepenalty_duedate'), 'rules', ], $data); } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(context $context): void { if ($context->contextlevel != CONTEXT_USER) { return; } static::delete_user_data($context->instanceid); } /** * Delete user data in the list of given contexts. * * @param approved_contextlist $contextlist the list of contexts. */ public static function delete_data_for_user(approved_contextlist $contextlist): void { if (empty($contextlist->count())) { return; } $userid = $contextlist->get_user()->id; foreach ($contextlist->get_contexts() as $context) { if ($context->contextlevel != CONTEXT_USER) { continue; } if ($context->instanceid == $userid) { static::delete_user_data($context->instanceid); } } } /** * Get the list of users within a specific context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist): void { $context = $userlist->get_context(); if (!$context instanceof \context_user) { return; } $sql = "SELECT DISTINCT u.id FROM {gradepenalty_duedate_rule} gdr JOIN {user} u ON u.id = gdr.usermodified WHERE gdr.usermodified = :userid"; $params = ['userid' => $context->instanceid]; $userlist->add_from_sql('id', $sql, $params); } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist): void { $context = $userlist->get_context(); if ($context instanceof \context_user) { static::delete_user_data($context->instanceid); } } /** * Retrieve the user metadata stored by plugin. * * @param collection $collection Collection of metadata. * @return collection Collection of metadata. */ public static function get_metadata(collection $collection): collection { $collection->add_database_table( 'gradepenalty_duedate_rule', [ 'usermodified' => 'privacy:metadata:gradepenalty_duedate_rule:usermodified', ], 'privacy:metadata:gradepenalty_duedate_rule' ); return $collection; } /** * Set the usermodified to 0 instead of deleting the data. * * @param int $userid The id of the user. */ protected static function delete_user_data(int $userid): void { global $DB; // Set the usermodified to 0. $DB->set_field_select('gradepenalty_duedate_rule', 'usermodified', 0, "usermodified = :userid", ['userid' => $userid]); } } classes/penalty_rule.php000064400000012520152266501530011422 0ustar00. namespace gradepenalty_duedate; use context; use context_system; use core\lang_string; use core\persistent; /** * To create/load/update/delete penalty rules. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class penalty_rule extends persistent { /** The table name this persistent object maps to. */ const TABLE = 'gradepenalty_duedate_rule'; /** * Return the definition of the properties of this model. */ protected static function define_properties(): array { return [ 'contextid' => [ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, ], 'overdueby' => [ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, ], 'penalty' => [ 'type' => PARAM_FLOAT, 'null' => NULL_NOT_ALLOWED, ], 'sortorder' => [ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0, ], ]; } /** * Validate the overdueby before saving. * * @param int $value overdueby value. * @return true|lang_string error message if overdueby is invalid. */ protected function validate_overdueby($value): bool|lang_string { if ($value < constants::OVERDUEBY_MIN) { return new lang_string('error_overdueby_minvalue', 'gradepenalty_duedate', constants::OVERDUEBY_MIN); } return true; } /** * Validate the penalty before saving. * * @param int $value penalty value. * @return true|lang_string error message if penalty is invalid. */ protected function validate_penalty($value): bool|lang_string { if ($value < constants::PENALTY_MIN) { return new lang_string('error_penalty_minvalue', 'gradepenalty_duedate', constants::PENALTY_MIN); } else if ($value > constants::PENALTY_MAX) { return new lang_string('error_penalty_maxvalue', 'gradepenalty_duedate', constants::PENALTY_MAX); } return true; } /** * Get the penalty rules for a context. * If not found, it will get the rules from the parent context. * * @param int $contextid context id * @return array penalty_rule records. */ public static function get_rules(int $contextid): array { $rules = []; $currentcontext = context::instance_by_id($contextid); while (empty($rules) && $currentcontext) { $rules = self::get_records(['contextid' => $currentcontext->id], 'sortorder'); $currentcontext = $currentcontext->get_parent_context(); } return $rules; } /** * Reset rules for a context. * Delete all rules for the context. * * @param int $contextid context id. * @return void */ public static function reset_rules(int $contextid): void { // Get rules for the context. $rules = self::get_records(['contextid' => $contextid]); // Delete all rules. foreach ($rules as $rule) { $rule->delete(); } // Check if it is system context, create a default rule. if ($contextid == context_system::instance()->id) { $rule = new penalty_rule(); $rule->set('contextid', $contextid); $rule->set('overdueby', 1); $rule->set('penalty', 0); $rule->set('sortorder', 0); $rule->save(); } } /** * Check if rules are overridden in a context. * * @param int $contextid context id. * @return bool true if rules are overridden. */ public static function is_overridden(int $contextid): bool { // Exclude system context. if ($contextid == context_system::instance()->id) { return false; } $rules = self::get_records(['contextid' => $contextid]); // If there is no rules in parent contexts, still consider they are overridden. return count($rules) > 0; } /** * Check if the rules are inherited from the parent context. * * @param int $contextid context id. * @return bool true if rules are inherited. */ public static function is_inherited(int $contextid): bool { // Exclude system context. if ($contextid == context_system::instance()->id) { return false; } $rules = self::get_records(['contextid' => $contextid]); $parentrules = self::get_rules($contextid); return count($rules) == 0 && count($parentrules) > 0; } } classes/constants.php000064400000002505152266501530010735 0ustar00. namespace gradepenalty_duedate; /** * Constants for the gradepenalty_duedate plugin. * * @package gradepenalty_duedate * @copyright 2025 Catalyst IT Australia * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class constants { /** * @var int The minimum overdueby value. */ public const OVERDUEBY_MIN = 1; /** * @var int The maximum overdueby value. */ public const OVERDUEBY_MAX = YEARSECS; /** * @var int The minimum penalty value. */ public const PENALTY_MIN = 0; /** * @var int The maximum penalty value. */ public const PENALTY_MAX = 100; } classes/output/edit_penalty_rule_action_bar.php000064400000003671152266501530016157 0ustar00. namespace gradepenalty_duedate\output; use core\output\single_button; use core\url; /** * Renderable class for the action bar elements in the penalty rule page. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_penalty_rule_action_bar extends view_penalty_rule_action_bar { #[\Override] public function get_template(): string { return 'gradepenalty_duedate/edit_penalty_rule_action_bar'; } #[\Override] public function export_for_template(\renderer_base $output): array { $data = []; $contextid = $this->context->id; // Title. $data['title'] = $output->heading($this->title); // Delete all rules button. $deleteallruleurl = new url($this->url->out(), [ 'contextid' => $contextid, 'deleteallrules' => 1, ]); $deleteallrulebutton = new single_button( $deleteallruleurl, get_string('deleteallrules', 'gradepenalty_duedate'), 'get', single_button::BUTTON_DANGER ); $data['deleteallrulebutton'] = $deleteallrulebutton->export_for_template($output); return $data; } } classes/output/form/edit_penalty_form.php000064400000032117152266501530014732 0ustar00. namespace gradepenalty_duedate\output\form; use MoodleQuickForm; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); require_once(__DIR__ . '/../../../lib.php'); use action_menu_link; use core\output\action_menu; use core\output\html_writer; use core\output\pix_icon; use core\url; use gradepenalty_duedate\constants; use gradepenalty_duedate\penalty_rule; use moodleform; /** * Form to set up the penalty rules for the gradepenalty_duedate plugin. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_penalty_form extends moodleform { /** @var int contextid context id where the penalty rules are edited */ protected int $contextid = 0; #[\Override] public function definition(): void { global $PAGE; $mform = $this->_form; // Hidden context id, value is stored in $mform. $this->contextid = $this->_customdata['contextid'] ?? 1; $mform->addElement('hidden', 'contextid'); $mform->setType('contextid', PARAM_INT); $mform->setDefault('contextid', $this->contextid); // Edit mode. $mform->addElement('hidden', 'edit', 1); $mform->setType('edit', PARAM_INT); // Get existing penalty rules, clone from parent context if not found. $finalpenaltyrule = $this->_customdata['finalpenaltyrule'] ?? null; if (!is_null($finalpenaltyrule)) { $repeatedrules = $this->_customdata['penaltyrules']; } else { $existingrules = penalty_rule::get_rules($this->contextid); if (!empty($existingrules)) { // Clone from parent context. // Extract the final rule. $finalpenaltyrule = array_pop($existingrules); // We need the penalty value only. $finalpenaltyrule = $finalpenaltyrule->get('penalty'); // Other rules, turn to array so that we can use them in form repeat element. $repeatedrules = []; foreach ($existingrules as $rule) { $repeatedrules[] = [ 'overdueby' => $rule->get('overdueby'), 'penalty' => $rule->get('penalty'), ]; } } } // Rule group repeater. Show default of 5 rules if there is no rule. $repeatcount = !is_null($finalpenaltyrule) ? count($repeatedrules) : 0; // Create rule element. [$group, $options] = self::rule_element($mform); // Create repeatable elements. $this->repeat_elements([$group], $repeatcount, $options, 'rulegroupcount', 'addrules', 0); // We don't need "addrules" button. $mform->removeElement('addrules'); // Final rule input. $mform->addElement('text', 'finalpenaltyrule', get_string('finalpenaltyrule', 'gradepenalty_duedate'), ['size' => 3]); $mform->setType('finalpenaltyrule', PARAM_FLOAT); $mform->setDefault('finalpenaltyrule', 0); $mform->addHelpButton('finalpenaltyrule', 'finalpenaltyrule', 'gradepenalty_duedate'); // Set data. if (!is_null($finalpenaltyrule)) { $data = []; $data['finalpenaltyrule'] = $finalpenaltyrule; foreach ($repeatedrules as $rulenumber => $rule) { $data['overdueby[' . $rulenumber . ']'] = $rule['overdueby']; $data['penalty[' . $rulenumber . ']'] = $rule['penalty']; } $this->set_data($data); } // Add submit and cancel buttons. $this->add_action_buttons(); } #[\Override] public function validation($data, $files): array { $errors = parent::validation($data, $files); // Skip if there is no data. if (empty($data['overdueby'])) { return $errors; } // The late for and penalty values must be in ascending order. $overduebylowerbound = constants::OVERDUEBY_MIN - 1; $overduebyupperbound = constants::OVERDUEBY_MAX + 1; $penaltylowerbound = constants::PENALTY_MIN - 1; $penaltyupperbound = constants::PENALTY_MAX + 1; // Go to each group. foreach ($data['overdueby'] as $rulenumber => $overdueby) { $rulegroupid = 'rulegroup[' . $rulenumber . ']'; // Skip validation if user did not change default overdue value. We will remove those rules later. if ($overdueby < constants::OVERDUEBY_MIN) { continue; } // Validate overdue field. if ($overdueby <= $overduebylowerbound) { if ($overduebylowerbound == constants::OVERDUEBY_MIN - 1) { // Minimum value of overdue field. $errormessage = get_string('error_overdueby_minvalue', 'gradepenalty_duedate', format_time(constants::OVERDUEBY_MIN)); } else { // Must be greater than the previous overdue value. $errormessage = get_string('error_overdueby_abovevalue', 'gradepenalty_duedate', format_time($overduebylowerbound)); } $errors[$rulegroupid] = $errormessage; } else if ($overdueby >= $overduebyupperbound) { // Validate max value of overdue. $errors[$rulegroupid] = get_string('error_overdueby_maxvalue', 'gradepenalty_duedate', format_time(constants::OVERDUEBY_MAX)); } else { $overduebylowerbound = $overdueby; } // Validate penalty. $penalty = $data['penalty'][$rulenumber]; if ($penalty <= $penaltylowerbound) { if ($penaltylowerbound == constants::PENALTY_MIN - 1) { // Minimum value a penalty can have. $errormessage = get_string('error_penalty_minvalue', 'gradepenalty_duedate', format_float(constants::PENALTY_MIN)); } else { // Must be greater than the previous penalty. $errormessage = get_string('error_penalty_abovevalue', 'gradepenalty_duedate', format_float($penaltylowerbound)); } if (isset($errors[$rulegroupid])) { // Append to existing error message. $errors[$rulegroupid] .= ' ' . $errormessage; } else { // Create new error message. $errors[$rulegroupid] = $errormessage; } } else if ($penalty >= $penaltyupperbound) { // Validate max value of penalty. $errors[$rulegroupid] = get_string('error_penalty_maxvalue', 'gradepenalty_duedate', format_float(constants::PENALTY_MAX)); } else { $penaltylowerbound = $penalty; } } // Check the penalty of the final rule. It must be greater than the last rule. $finalpenalty = $data['finalpenaltyrule']; if ($finalpenalty <= $penaltylowerbound) { $errors['finalpenaltyrule'] = get_string('error_penalty_abovevalue', 'gradepenalty_duedate', format_float($penaltylowerbound)); } else if ($finalpenalty >= $penaltyupperbound) { $errors['finalpenaltyrule'] = get_string('error_penalty_maxvalue', 'gradepenalty_duedate', format_float(constants::PENALTY_MAX)); } return $errors; } /** * Save the form data. * * @param object $data form data * @return void */ public function save_data($data): void { // Get penalty rules. $rules = penalty_rule::get_records(['contextid' => $this->contextid], 'sortorder', 'ASC'); // There could be deleted rules, so we will reindex the array. $newdata = []; if (isset($data->overdueby)) { foreach ($data->overdueby as $rulenumber => $overdueby) { // Remove the invalid default rule that we skipped validating. if ($overdueby >= constants::OVERDUEBY_MIN) { $newdata[$overdueby] = $data->penalty[$rulenumber]; } } // Sort by overdueby. ksort($newdata); } // Update or create new rules. $numofrulesinrepeater = count($newdata); $overdueby = array_keys($newdata); $penalty = array_values($newdata); for ($i = 0; $i < $numofrulesinrepeater; $i++) { // Create new rule if it does not exist. $rule = $rules[$i] ?? new penalty_rule(); // Set the values. $rule->set('contextid', $this->contextid); $rule->set('sortorder', $i); $rule->set('overdueby', $overdueby[$i]); $rule->set('penalty', $penalty[$i]); // Save the rule. $rule->save(); } // Save the final rule. // Check if the final rule exists. $finalrule = $rules[$numofrulesinrepeater] ?? new penalty_rule(); $finalrule->set('contextid', $this->contextid); $finalrule->set('sortorder', $numofrulesinrepeater); if (!empty($overdueby)) { // We can set to any date/time that greater than the last rule in the repeater. $finalrule->set('overdueby', end($overdueby) + DAYSECS); } else { $finalrule->set('overdueby', constants::OVERDUEBY_MIN); } $finalrule->set('penalty', $data->finalpenaltyrule); $finalrule->save(); // Number of updated rules. Plus one, due to final rule. $numofupdatedrules = $numofrulesinrepeater + 1; // Delete rules if there are more rules than the form data. if (count($rules) > $numofupdatedrules) { for ($i = $numofupdatedrules; $i < count($rules); $i++) { $rules[$i]->delete(); } } } /** * Create the rule element. * * @param MoodleQuickForm $mform The form object. * @return array The rule element and options. */ private static function rule_element(MoodleQuickForm $mform): array { global $PAGE; $elements = []; $options = []; // Overdue. $elements[] = $mform->createElement('static', '', '', html_writer::span(get_string('overdueby_label', 'gradepenalty_duedate'), 'me-2')); // Less than or equal. $elements[] = $mform->createElement('static', '', '', html_writer::span('≤', 'me-2')); // Duration value element. $elements[] = ($mform->createElement('duration', 'overdueby', get_string('overdueby_label', 'gradepenalty_duedate'), ['optional' => false, 'defaultunit' => DAYSECS])); // Penalty. $elements[] = $mform->createElement('static', '', '', html_writer::span(get_string('penalty_label', 'gradepenalty_duedate'), 'ms-4 me-2')); // Penalty value element. $elements[] = $mform->createElement('text', 'penalty', get_string('penalty_label', 'gradepenalty_duedate'), ['size' => 3, 'maxlength' => 3]); $options['penalty']['type'] = PARAM_FLOAT; // Percentage. $elements[] = $mform->createElement('static', '', '', html_writer::span('%', 'me-4')); // Action menu. $output = $PAGE->get_renderer('core'); $menu = new action_menu(); $menu->set_kebab_trigger(); // Insert below button. $menu->add(new action_menu_link( new url('#'), new pix_icon('t/add', ''), get_string('insertrule', 'gradepenalty_duedate'), false, ['class' => 'insertbelow'] )); // Delete button. $menu->add(new action_menu_link( new url('#'), new pix_icon('i/trash', ''), get_string('delete'), false, ['class' => 'deleterulebuttons text-danger'] )); $actionmenu = $output->render($menu); $elements[] = $mform->createElement('static', 'name1', 'name2', $actionmenu); // Group. return [ $mform->createElement( 'group', 'rulegroup', get_string('penaltyrule_group', 'gradepenalty_duedate'), $elements, [''], false ), $options, ]; } } classes/output/view_penalty_rule_action_bar.php000064400000007642152266501530016206 0ustar00. namespace gradepenalty_duedate\output; use core_grades\output\action_bar; use core\output\notification; use core\output\single_button; use core\url; use gradepenalty_duedate\penalty_rule; /** * Renderable class for the action bar elements in the penalty rule page. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class view_penalty_rule_action_bar extends action_bar { /** @var string $title The title of the page. */ protected string $title; /** @var url $url The URL of the page. */ protected url $url; /** * Constructor. * * @param \context $context The context object. * @param string $title The title of the page. * @param url $url The URL of the page. */ public function __construct(\context $context, string $title, url $url) { parent::__construct($context); $this->title = $title; $this->url = $url; } #[\Override] public function get_template(): string { return 'gradepenalty_duedate/view_penalty_rule_action_bar'; } #[\Override] public function export_for_template(\renderer_base $output): array { $data = []; $contextid = $this->context->id; // Title. $data['title'] = $output->heading($this->title); // If the context is not system context, show the reset button when rules are overridden. if (penalty_rule::is_overridden($contextid)) { // Show information about the overridden rules. $info = new notification( get_string('penaltyrule_overridden', 'gradepenalty_duedate'), notification::NOTIFY_INFO, ); $data['info'] = $info->export_for_template($output); // Reset button. $reseturl = new url($this->url->out(), [ 'contextid' => $contextid, 'reset' => 1, ]); $resetbutton = new single_button($reseturl, get_string('reset'), 'get', single_button::BUTTON_DANGER); $data['resetbutton'] = $resetbutton->export_for_template($output); } else { if (penalty_rule::is_inherited($contextid)) { // Show information about the inherited rules. $info = new notification( get_string('penaltyrule_inherited', 'gradepenalty_duedate'), notification::NOTIFY_INFO, ); $data['info'] = $info->export_for_template($output); } else { // No rules from parent context. $info = new notification( get_string('penaltyrule_not_inherited', 'gradepenalty_duedate'), notification::NOTIFY_INFO, ); $data['info'] = $info->export_for_template($output); } } // Edit button. $editurl = new url($this->url->out(), [ 'contextid' => $contextid, 'edit' => 1, ]); $editbutton = new single_button($editurl, get_string('edit'), 'get', single_button::BUTTON_PRIMARY); $data['editbutton'] = $editbutton->export_for_template($output); return $data; } } classes/penalty_calculator.php000064400000011031152266501530012600 0ustar00. namespace gradepenalty_duedate; use cm_info; use context_course; use context_module; use context_system; use core_grades\penalty_container; /** * Penalty plugins must override this class to implement their own penalty calculation. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class penalty_calculator extends \core_grades\penalty_calculator { /** * Calculate the penalty for the given activity. * * @param penalty_container $container The penalty container. */ public static function calculate_penalty(penalty_container $container): void { // Calculate the deducted grade based on the max grade. $gradeitem = $container->get_grade_item(); $modinfo = get_fast_modinfo($gradeitem->courseid); $cm = $modinfo->instances[$gradeitem->itemmodule][$gradeitem->iteminstance]; $deductedpercentage = self::get_penalty_from_rules($cm, $container->get_submission_date(), $container->get_due_date()); $deductedgrade = $container->get_max_grade() * $deductedpercentage / 100; $container->aggregate_penalty($deductedgrade); } /** * Get the penalty percentage from the most appropriate penalty rule based on the submission date and the due date. * * @param cm_info $cm The course module object. * @param int $submissiondate The submission date. * @param int $duedate The due date. * @return float the deducted percentage. */ public static function get_penalty_from_rules(cm_info $cm, int $submissiondate, int $duedate): float { // Get the difference between the submission date and the due date. $diff = $submissiondate - $duedate; // Return if the due date is after the submission date. if ($diff <= 0) { return 0; } // Get all penalty rules, ordered by the highest penalty first. $penaltyrules = self::find_effective_penalty_rules($cm); // Return if there are no penalty rules. if (empty($penaltyrules)) { return 0; } // Return the first applicable penalty rule. foreach ($penaltyrules as $penaltyrule) { if ($diff <= $penaltyrule->get('overdueby')) { return $penaltyrule->get('penalty'); } } // Return the last penalty rule if the difference exceeds the overdueby value of the last rule. $lastrule = end($penaltyrules); if ($diff > $lastrule->get('overdueby')) { return $lastrule->get('penalty'); } // Return if no penalty rule is applicable. return 0; } /** * Find effective penalty rule which will be applied the course module. * * @param cm_info $cm The course module object. * @return array */ private static function find_effective_penalty_rules(cm_info $cm): array { // Course module context id. $modulecontext = context_module::instance($cm->id); $coursecontext = context_course::instance($cm->course); $systemcontext = context_system::instance(); $contextids = [ $modulecontext->id, $coursecontext->id, $systemcontext->id, ]; // Get all penalty rules in one query. $select = 'contextid IN (?, ?, ?)'; $penaltyrules = penalty_rule::get_records_select($select, $contextids, 'sortorder'); // Filter the penalty rules based on the context hierarchy. foreach ($contextids as $contextid) { $rules = array_filter($penaltyrules, function ($penaltyrule) use ($contextid): bool { return (int) $penaltyrule->get('contextid') === (int) $contextid; }); if (!empty($rules)) { return $rules; } } return []; } } classes/table/penalty_rule_table.php000064400000011420152266501530013656 0ustar00. namespace gradepenalty_duedate\table; use context; use context_system; use core_table\sql_table; /** * Table for penalty rule. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class penalty_rule_table extends sql_table { /** @var context context */ protected context $context; /** * Sets up the table_log parameters. * * @param string $uniqueid unique id of form. * @param string $contextid context id. */ public function __construct($uniqueid, $contextid) { parent::__construct($uniqueid); $this->context = context::instance_by_id($contextid); // Add columns. $this->define_columns([ 'overdueby', 'penalty', ]); // Add columns header. $this->define_headers([ get_string('overdueby', 'gradepenalty_duedate'), get_string('penalty', 'gradepenalty_duedate'), ]); // Disable sorting. $this->sortable(false); // Disable hiding columns. $this->collapsible(false); } #[\Override] public function query_db($pagesize, $useinitialsbar = true): void { global $DB; // Contexts to find the penalty rules. $contextlevel = $this->context->contextlevel; $contextids = []; if ($contextlevel == CONTEXT_MODULE) { // Module context. $contextids[] = $this->context->id; // Course context. $contextids[] = $this->context->get_parent_context()->id; // System context. $contextids[] = context_system::instance()->id; } else if ($contextlevel == CONTEXT_COURSE) { // Course context. $contextids[] = $this->context->id; // System context. $contextids[] = context_system::instance()->id; } else if ($contextlevel == CONTEXT_SYSTEM) { // System context. $contextids[] = $this->context->id; } // Find the penalty rules. foreach ($contextids as $contextid) { $params = ['contextid' => $contextid]; $total = $DB->count_records_sql($this->get_sql(true), $params); if ($total > 0) { break; } } $this->pagesize($pagesize, $total); $this->rawdata = $DB->get_records_sql($this->get_sql(), $params, $this->get_page_start(), $this->get_page_size()); } /** * Builds the SQL query. * * @param bool $count When true, return the count SQL. * @return string the SQL query. */ protected function get_sql($count = false): string { if ($count) { $select = "COUNT(1)"; $order = ""; } else { $select = "r.overdueby, r.penalty, r.sortorder"; $order = " order by r.sortorder ASC"; } $sql = "SELECT $select FROM {gradepenalty_duedate_rule} r WHERE r.contextid = :contextid"; return $sql . $order; } /** * Overdue column. * * @param object $row row object. */ public function col_overdueby($row): string { // If this is the last rule, show the last row. if (count($this->rawdata) === ($row->sortorder + 1)) { // Find the previous rule. foreach ($this->rawdata as $rule) { if ($rule->sortorder == $row->sortorder - 1) { return get_string('overdueby_lastrow', 'gradepenalty_duedate', format_time($rule->overdueby)); } } // There is only one row. return get_string('overdueby_onerow', 'gradepenalty_duedate'); } // This is not the last rule. return get_string('overdueby_row', 'gradepenalty_duedate', format_time($row->overdueby)); } /** * Penalty column. * * @param object $row row object. */ public function col_penalty($row): string { return get_string('percents', 'moodle', format_float($row->penalty, -1)); } } templates/view_penalty_rule_action_bar.mustache000064400000004270152266501530016223 0ustar00{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template gradepenalty_duedate/penalty_rule_action_bar Actions bar for the penalty rule page. Example context (json): { "title": "Penalty rule", "editbutton": { "id": "single_button123", "method" : "get", "classes": "singlebutton", "formid": null, "url" : "#", "primary" : true, "tooltip" : null, "label" : "Edit", "attributes": [] }, "resetbutton": { "id": "single_button456", "method" : "get", "classes": "singlebutton", "formid": null, "url" : "#", "primary" : true, "tooltip" : null, "label" : "Reset", "attributes": [] }, "info": { "message": "This is a notification message.", "classes": "alert-info" } } }}
{{{title}}}
{{#info}} {{>core/notification}} {{/info}}
templates/edit_penalty_rule_action_bar.mustache000064400000003643152266501530016201 0ustar00{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template gradepenalty_duedate/penalty_rule_action_bar Actions bar for the penalty rule page. Example context (json): { "title": "Penalty rule", "deleteallrulebutton": { "id": "single_button12345", "method" : "get", "classes": "singlebutton", "formid": null, "url" : "#", "primary" : true, "tooltip" : null, "label" : "Reset", "attributes": [] }, "info": { "message": "This is a notification message.", "classes": "alert-info" } } }}
{{{title}}}
manage_penalty_rule.php000064400000011764152266501530011306 0ustar00. /** * Site configuration settings for the gradepenalty_duedate plugin * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\output\notification; use core\url; use gradepenalty_duedate\output\edit_penalty_rule_action_bar; use gradepenalty_duedate\output\form\edit_penalty_form; use gradepenalty_duedate\output\view_penalty_rule_action_bar; use gradepenalty_duedate\penalty_rule; use gradepenalty_duedate\table\penalty_rule_table; require_once(__DIR__ . '/../../../config.php'); require_once("$CFG->libdir/adminlib.php"); // Page parameters. $contextid = optional_param('contextid', 1, PARAM_INT); $returnurl = optional_param('returnurl', '', PARAM_LOCALURL); $edit = optional_param('edit', 0, PARAM_INT); $reset = optional_param('reset', 0, PARAM_INT); $deleteeall = optional_param('deleteallrules', 0, PARAM_INT); // Check login and permissions. [$context, $course, $cm] = get_context_info_array($contextid); if ($context->contextlevel == CONTEXT_SYSTEM) { require_admin(); } else { require_login($course, false, $cm); require_capability('gradepenalty/duedate:manage', $context); } $PAGE->set_context($context); $url = new url('/grade/penalty/duedate/manage_penalty_rule.php', ['contextid' => $contextid]); $PAGE->set_url($url); // Return to this page without edit mode. if (!$returnurl) { $returnurl = $url; } // Display page according to context. if ($context->contextlevel == CONTEXT_COURSE) { $course = get_course($context->instanceid); $PAGE->set_heading($course->fullname); } else if ($context->contextlevel == CONTEXT_MODULE) { $PAGE->set_heading($PAGE->activityrecord->name); } else { $PAGE->set_heading(get_string('administrationsite')); } // Print the header and tabs. $PAGE->set_cacheable(false); if (!$edit) { $title = get_string('duedaterule', 'gradepenalty_duedate'); } else { $title = get_string('editduedaterule', 'gradepenalty_duedate'); // Add edit navigation node. $PAGE->navbar->add(get_string('editduedaterule', 'gradepenalty_duedate')); } $PAGE->set_title($title); $PAGE->set_pagelayout('admin'); $PAGE->activityheader->disable(); // If reset button is clicked, reset the penalty rules. if ($reset || $deleteeall) { // Show message for user confirmation. $confirmurl = new url($url->out(), [ 'contextid' => $contextid, 'resetconfirm' => 1, ]); echo $OUTPUT->header(); echo $OUTPUT->confirm(get_string('resetconfirm', 'gradepenalty_duedate'), $confirmurl, $url); echo $OUTPUT->footer(); die; } else if (optional_param('resetconfirm', 0, PARAM_INT)) { // Reset the penalty rules. penalty_rule::reset_rules($contextid); } // Only initialize the form if we are in edit mode. if ($edit) { // Create a form to add / edit penalty rules. $mform = new edit_penalty_form($url->out(), [ 'contextid' => $contextid, 'edit' => $edit, ]); if ($mform->is_cancelled()) { redirect($returnurl); } else if ($fromform = $mform->get_data()) { // Save the form data. $mform->save_data($fromform); // Redirect to the same page. redirect($url, get_string('changessaved'), 0, notification::NOTIFY_SUCCESS); } // Add javascript to handle the repeater. $PAGE->requires->js_call_amd('gradepenalty_duedate/edit_penalty_form', 'init'); } // Start output. echo $OUTPUT->header(); if (!$edit) { $actionbar = new view_penalty_rule_action_bar($context, $title, $url); $renderer = $PAGE->get_renderer('core_grades'); echo $renderer->render_action_bar($actionbar); // Display the penalty table. echo $OUTPUT->heading(get_string('existingrule', 'gradepenalty_duedate'), 5); $penaltytable = new penalty_rule_table('penalty_rule_table', $contextid); $penaltytable->define_baseurl($url); $penaltytable->out(30, true); } else { $actionbar = new edit_penalty_rule_action_bar($context, $title, $url); $renderer = $PAGE->get_renderer('core_grades'); echo $renderer->render_action_bar($actionbar); // Wrap the form in a container, so we can replace the form. echo $OUTPUT->box_start('generalbox', 'penalty_rule_form_container'); // Display the form. $mform->display(); // End of the box. echo $OUTPUT->box_end(); } // Footer. echo $OUTPUT->footer(); amd/src/edit_penalty_form.js000064400000014740152266501530012151 0ustar00// 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 . /** * Handles edit penalty form. * * @module gradepenalty_duedate/edit_penalty_form * @copyright 2024 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as notification from 'core/notification'; import Fragment from 'core/fragment'; import Templates from 'core/templates'; /** * Rule js class. */ class PenaltyRule { constructor( overdueby = 0, penalty = 0, ) { this.overdueby = overdueby; this.penalty = penalty; } } /** * Selectors */ const SELECTORS = { FORM_CONTAINER: '#penalty_rule_form_container', ACTION_MENU: '.action-menu', ADD_BUTTON: '#addrulebutton', INSERT_BUTTON: '.insertbelow', DELETE_BUTTON: '.deleterulebuttons', DELETE_ALL_BUTTON_CONTAINER: '#deleteallrulesbuttoncontainer', }; /** * Register click event for delete and insert buttons. */ const registerEventListeners = () => { // Find all action menus in penalty rule form. const container = document.querySelector(SELECTORS.FORM_CONTAINER); container.addEventListener('click', (e) => { if (e.target.closest(SELECTORS.DELETE_BUTTON)) { e.preventDefault(); deleteRule(e.target); return; } if (e.target.closest(SELECTORS.INSERT_BUTTON)) { e.preventDefault(); insertRule(e.target); return; } }); document.querySelector(SELECTORS.ADD_BUTTON).addEventListener('click', (e) => { e.preventDefault(); insertRuleAtIndex(container.querySelectorAll(SELECTORS.ACTION_MENU).length); return; }); }; /** * Delete a rule group represented by thenode. * * @param {NodeElement} target */ const deleteRule = (target) => { // Get all form data. const {contextid, penaltyRules, finalPenaltyRule} = buildFormParams(); const ruleNumber = getRuleNumber(target); // Remove the penalty rule. const updatedPenaltyRules = penaltyRules.filter((rule, index) => index !== ruleNumber); loadPenaltyRuleForm( contextid, updatedPenaltyRules, finalPenaltyRule, ); }; /** * Insert a rule group below the clicked button. * * @param {NodeElement} target */ const insertRule = (target) => insertRuleAtIndex(getRuleNumber(target) + 1); /** * Add a new rule group at the specified index. * * @param {Number} ruleNumber */ const insertRuleAtIndex = (ruleNumber) => { // Get all form data. const {contextid, penaltyRules, finalPenaltyRule} = buildFormParams(); // Insert a new penalty rule. penaltyRules.splice(ruleNumber, 0, new PenaltyRule()); loadPenaltyRuleForm( contextid, penaltyRules, finalPenaltyRule, ); }; /** * Get the rule number from the target. * * @param {Object} target * @return {Number} rule number */ const getRuleNumber = (target) => { const allRules = target .closest(SELECTORS.FORM_CONTAINER) .querySelectorAll(SELECTORS.ACTION_MENU); const foundIndex = Array.prototype.findIndex.call( allRules, (element) => element.contains(target), ); if (foundIndex === -1) { throw new Error('Rule number not found on target', target); } return foundIndex; }; /** * Build form parameters for loading fragment. * * @return {Object} form params */ const buildFormParams = () => { // Get the penalty rule form in its container. const container = document.querySelector(SELECTORS.FORM_CONTAINER); const form = container.querySelector('form'); // Get all form data const formData = new FormData(form); // Get context id. const contextid = formData.get('contextid'); // Get group count. const groupCount = formData.get('rulegroupcount'); // Create list of penalty rules. const penaltyRules = []; // Current penalty rules. for (let i = 0; i < groupCount; i++) { penaltyRules.push(new PenaltyRule( formData.get(`overdueby[${i}][number]`) * formData.get(`overdueby[${i}][timeunit]`), formData.get(`penalty[${i}]`) )); } return { contextid, penaltyRules, finalPenaltyRule: formData.get('finalpenaltyrule'), }; }; /** * Load the penalty rule form. * * @param {Number} contextId * @param {Array} penaltyRules * @param {Number} finalPenaltyRule */ const loadPenaltyRuleForm = ( contextId, penaltyRules, finalPenaltyRule, ) => { // Disable the form while loading to improve UX. const container = document.querySelector(SELECTORS.FORM_CONTAINER); const form = container.querySelector('form'); form.querySelectorAll('input, select').forEach(input => { input.disabled = true; }); // Disable the add rule button. const addButton = document.querySelector(SELECTORS.ADD_BUTTON); if (addButton) { addButton.disabled = true; } // Disable the delete all rules button. const deleteAllButton = document.querySelector(SELECTORS.DELETE_ALL_BUTTON_CONTAINER).querySelector('button'); if (deleteAllButton) { deleteAllButton.disabled = true; } // Replace the form with the new form. Fragment.loadFragment( 'gradepenalty_duedate', 'penalty_rule_form', contextId, { penaltyrules: JSON.stringify(penaltyRules), finalpenaltyrule: finalPenaltyRule, }, ) .then((html, js) => { Templates.replaceNodeContents(document.querySelector(SELECTORS.FORM_CONTAINER), html, js); if (addButton) { addButton.disabled = false; } if (deleteAllButton) { deleteAllButton.disabled = false; } return; }) .catch(notification.exception); }; /** * Initialize the js. */ export const init = () => { registerEventListeners(); }; amd/build/edit_penalty_form.min.js000064400000011513152266501530013236 0ustar00define("gradepenalty_duedate/edit_penalty_form",["exports","core/notification","core/fragment","core/templates"],(function(_exports,notification,_fragment,_templates){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,notification=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj} /** * Handles edit penalty form. * * @module gradepenalty_duedate/edit_penalty_form * @copyright 2024 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */(notification),_fragment=_interopRequireDefault(_fragment),_templates=_interopRequireDefault(_templates);class PenaltyRule{constructor(){let overdueby=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,penalty=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;this.overdueby=overdueby,this.penalty=penalty}}const SELECTORS_FORM_CONTAINER="#penalty_rule_form_container",SELECTORS_ACTION_MENU=".action-menu",SELECTORS_ADD_BUTTON="#addrulebutton",SELECTORS_INSERT_BUTTON=".insertbelow",SELECTORS_DELETE_BUTTON=".deleterulebuttons",SELECTORS_DELETE_ALL_BUTTON_CONTAINER="#deleteallrulesbuttoncontainer",deleteRule=target=>{const{contextid:contextid,penaltyRules:penaltyRules,finalPenaltyRule:finalPenaltyRule}=buildFormParams(),ruleNumber=getRuleNumber(target),updatedPenaltyRules=penaltyRules.filter(((rule,index)=>index!==ruleNumber));loadPenaltyRuleForm(contextid,updatedPenaltyRules,finalPenaltyRule)},insertRule=target=>insertRuleAtIndex(getRuleNumber(target)+1),insertRuleAtIndex=ruleNumber=>{const{contextid:contextid,penaltyRules:penaltyRules,finalPenaltyRule:finalPenaltyRule}=buildFormParams();penaltyRules.splice(ruleNumber,0,new PenaltyRule),loadPenaltyRuleForm(contextid,penaltyRules,finalPenaltyRule)},getRuleNumber=target=>{const allRules=target.closest(SELECTORS_FORM_CONTAINER).querySelectorAll(SELECTORS_ACTION_MENU),foundIndex=Array.prototype.findIndex.call(allRules,(element=>element.contains(target)));if(-1===foundIndex)throw new Error("Rule number not found on target",target);return foundIndex},buildFormParams=()=>{const form=document.querySelector(SELECTORS_FORM_CONTAINER).querySelector("form"),formData=new FormData(form),contextid=formData.get("contextid"),groupCount=formData.get("rulegroupcount"),penaltyRules=[];for(let i=0;i{document.querySelector(SELECTORS_FORM_CONTAINER).querySelector("form").querySelectorAll("input, select").forEach((input=>{input.disabled=!0}));const addButton=document.querySelector(SELECTORS_ADD_BUTTON);addButton&&(addButton.disabled=!0);const deleteAllButton=document.querySelector(SELECTORS_DELETE_ALL_BUTTON_CONTAINER).querySelector("button");deleteAllButton&&(deleteAllButton.disabled=!0),_fragment.default.loadFragment("gradepenalty_duedate","penalty_rule_form",contextId,{penaltyrules:JSON.stringify(penaltyRules),finalpenaltyrule:finalPenaltyRule}).then(((html,js)=>{_templates.default.replaceNodeContents(document.querySelector(SELECTORS_FORM_CONTAINER),html,js),addButton&&(addButton.disabled=!1),deleteAllButton&&(deleteAllButton.disabled=!1)})).catch(notification.exception)};_exports.init=()=>{(()=>{const container=document.querySelector(SELECTORS_FORM_CONTAINER);container.addEventListener("click",(e=>e.target.closest(SELECTORS_DELETE_BUTTON)?(e.preventDefault(),void deleteRule(e.target)):e.target.closest(SELECTORS_INSERT_BUTTON)?(e.preventDefault(),void insertRule(e.target)):void 0)),document.querySelector(SELECTORS_ADD_BUTTON).addEventListener("click",(e=>{e.preventDefault(),insertRuleAtIndex(container.querySelectorAll(SELECTORS_ACTION_MENU).length)}))})()}})); //# sourceMappingURL=edit_penalty_form.min.js.mapamd/build/edit_penalty_form.min.js.map000064400000022470152266501530014016 0ustar00{"version":3,"file":"edit_penalty_form.min.js","sources":["../src/edit_penalty_form.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Handles edit penalty form.\n *\n * @module gradepenalty_duedate/edit_penalty_form\n * @copyright 2024 Catalyst IT\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as notification from 'core/notification';\nimport Fragment from 'core/fragment';\nimport Templates from 'core/templates';\n\n/**\n * Rule js class.\n */\nclass PenaltyRule {\n constructor(\n overdueby = 0,\n penalty = 0,\n ) {\n this.overdueby = overdueby;\n this.penalty = penalty;\n }\n}\n\n/**\n * Selectors\n */\nconst SELECTORS = {\n FORM_CONTAINER: '#penalty_rule_form_container',\n ACTION_MENU: '.action-menu',\n ADD_BUTTON: '#addrulebutton',\n INSERT_BUTTON: '.insertbelow',\n DELETE_BUTTON: '.deleterulebuttons',\n DELETE_ALL_BUTTON_CONTAINER: '#deleteallrulesbuttoncontainer',\n};\n\n/**\n * Register click event for delete and insert buttons.\n */\nconst registerEventListeners = () => {\n // Find all action menus in penalty rule form.\n const container = document.querySelector(SELECTORS.FORM_CONTAINER);\n container.addEventListener('click', (e) => {\n if (e.target.closest(SELECTORS.DELETE_BUTTON)) {\n e.preventDefault();\n deleteRule(e.target);\n\n return;\n }\n\n if (e.target.closest(SELECTORS.INSERT_BUTTON)) {\n e.preventDefault();\n insertRule(e.target);\n\n return;\n }\n });\n\n document.querySelector(SELECTORS.ADD_BUTTON).addEventListener('click', (e) => {\n e.preventDefault();\n insertRuleAtIndex(container.querySelectorAll(SELECTORS.ACTION_MENU).length);\n\n return;\n });\n};\n\n/**\n * Delete a rule group represented by thenode.\n *\n * @param {NodeElement} target\n */\nconst deleteRule = (target) => {\n // Get all form data.\n const {contextid, penaltyRules, finalPenaltyRule} = buildFormParams();\n const ruleNumber = getRuleNumber(target);\n\n // Remove the penalty rule.\n const updatedPenaltyRules = penaltyRules.filter((rule, index) => index !== ruleNumber);\n\n loadPenaltyRuleForm(\n contextid,\n updatedPenaltyRules,\n finalPenaltyRule,\n );\n};\n\n/**\n * Insert a rule group below the clicked button.\n *\n * @param {NodeElement} target\n */\nconst insertRule = (target) => insertRuleAtIndex(getRuleNumber(target) + 1);\n\n/**\n * Add a new rule group at the specified index.\n *\n * @param {Number} ruleNumber\n */\nconst insertRuleAtIndex = (ruleNumber) => {\n // Get all form data.\n const {contextid, penaltyRules, finalPenaltyRule} = buildFormParams();\n\n // Insert a new penalty rule.\n penaltyRules.splice(ruleNumber, 0, new PenaltyRule());\n\n loadPenaltyRuleForm(\n contextid,\n penaltyRules,\n finalPenaltyRule,\n );\n};\n\n/**\n * Get the rule number from the target.\n *\n * @param {Object} target\n * @return {Number} rule number\n */\nconst getRuleNumber = (target) => {\n const allRules = target\n .closest(SELECTORS.FORM_CONTAINER)\n .querySelectorAll(SELECTORS.ACTION_MENU);\n\n const foundIndex = Array.prototype.findIndex.call(\n allRules,\n (element) => element.contains(target),\n );\n\n if (foundIndex === -1) {\n throw new Error('Rule number not found on target', target);\n }\n\n return foundIndex;\n};\n\n/**\n * Build form parameters for loading fragment.\n *\n * @return {Object} form params\n */\nconst buildFormParams = () => {\n // Get the penalty rule form in its container.\n const container = document.querySelector(SELECTORS.FORM_CONTAINER);\n const form = container.querySelector('form');\n\n // Get all form data\n const formData = new FormData(form);\n\n // Get context id.\n const contextid = formData.get('contextid');\n\n // Get group count.\n const groupCount = formData.get('rulegroupcount');\n\n // Create list of penalty rules.\n const penaltyRules = [];\n\n // Current penalty rules.\n\n for (let i = 0; i < groupCount; i++) {\n penaltyRules.push(new PenaltyRule(\n formData.get(`overdueby[${i}][number]`) * formData.get(`overdueby[${i}][timeunit]`),\n formData.get(`penalty[${i}]`)\n ));\n }\n\n return {\n contextid,\n penaltyRules,\n finalPenaltyRule: formData.get('finalpenaltyrule'),\n };\n};\n\n/**\n * Load the penalty rule form.\n *\n * @param {Number} contextId\n * @param {Array} penaltyRules\n * @param {Number} finalPenaltyRule\n */\nconst loadPenaltyRuleForm = (\n contextId,\n penaltyRules,\n finalPenaltyRule,\n) => {\n // Disable the form while loading to improve UX.\n const container = document.querySelector(SELECTORS.FORM_CONTAINER);\n const form = container.querySelector('form');\n form.querySelectorAll('input, select').forEach(input => {\n input.disabled = true;\n });\n\n // Disable the add rule button.\n const addButton = document.querySelector(SELECTORS.ADD_BUTTON);\n if (addButton) {\n addButton.disabled = true;\n }\n\n // Disable the delete all rules button.\n const deleteAllButton = document.querySelector(SELECTORS.DELETE_ALL_BUTTON_CONTAINER).querySelector('button');\n if (deleteAllButton) {\n deleteAllButton.disabled = true;\n }\n\n // Replace the form with the new form.\n Fragment.loadFragment(\n 'gradepenalty_duedate',\n 'penalty_rule_form',\n contextId,\n {\n penaltyrules: JSON.stringify(penaltyRules),\n finalpenaltyrule: finalPenaltyRule,\n },\n )\n .then((html, js) => {\n Templates.replaceNodeContents(document.querySelector(SELECTORS.FORM_CONTAINER), html, js);\n\n if (addButton) {\n addButton.disabled = false;\n }\n\n if (deleteAllButton) {\n deleteAllButton.disabled = false;\n }\n return;\n })\n .catch(notification.exception);\n\n\n};\n\n/**\n * Initialize the js.\n */\nexport const init = () => {\n registerEventListeners();\n};\n"],"names":["PenaltyRule","constructor","overdueby","penalty","SELECTORS","deleteRule","target","contextid","penaltyRules","finalPenaltyRule","buildFormParams","ruleNumber","getRuleNumber","updatedPenaltyRules","filter","rule","index","loadPenaltyRuleForm","insertRule","insertRuleAtIndex","splice","allRules","closest","querySelectorAll","foundIndex","Array","prototype","findIndex","call","element","contains","Error","form","document","querySelector","formData","FormData","get","groupCount","i","push","contextId","forEach","input","disabled","addButton","deleteAllButton","loadFragment","penaltyrules","JSON","stringify","finalpenaltyrule","then","html","js","replaceNodeContents","catch","notification","exception","container","addEventListener","e","preventDefault","length","registerEventListeners"],"mappings":";;;;;;;oHA8BMA,YACFC,kBACIC,iEAAY,EACZC,+DAAU,OAELD,UAAYA,eACZC,QAAUA,eAOjBC,yBACc,+BADdA,sBAEW,eAFXA,qBAGU,iBAHVA,wBAIa,eAJbA,wBAKa,qBALbA,sCAM2B,iCAsC3BC,WAAcC,eAEVC,UAACA,UAADC,aAAYA,aAAZC,iBAA0BA,kBAAoBC,kBAC9CC,WAAaC,cAAcN,QAG3BO,oBAAsBL,aAAaM,QAAO,CAACC,KAAMC,QAAUA,QAAUL,aAE3EM,oBACIV,UACAM,oBACAJ,mBASFS,WAAcZ,QAAWa,kBAAkBP,cAAcN,QAAU,GAOnEa,kBAAqBR,mBAEjBJ,UAACA,UAADC,aAAYA,aAAZC,iBAA0BA,kBAAoBC,kBAGpDF,aAAaY,OAAOT,WAAY,EAAG,IAAIX,aAEvCiB,oBACIV,UACAC,aACAC,mBAUFG,cAAiBN,eACbe,SAAWf,OACZgB,QAAQlB,0BACRmB,iBAAiBnB,uBAEhBoB,WAAaC,MAAMC,UAAUC,UAAUC,KACzCP,UACCQ,SAAYA,QAAQC,SAASxB,cAGd,IAAhBkB,iBACM,IAAIO,MAAM,kCAAmCzB,eAGhDkB,YAQLd,gBAAkB,WAGdsB,KADYC,SAASC,cAAc9B,0BAClB8B,cAAc,QAG/BC,SAAW,IAAIC,SAASJ,MAGxBzB,UAAY4B,SAASE,IAAI,aAGzBC,WAAaH,SAASE,IAAI,kBAG1B7B,aAAe,OAIhB,IAAI+B,EAAI,EAAGA,EAAID,WAAYC,IAC5B/B,aAAagC,KAAK,IAAIxC,YAClBmC,SAASE,wBAAiBE,gBAAgBJ,SAASE,wBAAiBE,kBACpEJ,SAASE,sBAAeE,gBAIzB,CACHhC,UAAAA,UACAC,aAAAA,aACAC,iBAAkB0B,SAASE,IAAI,sBAWjCpB,oBAAsB,CACxBwB,UACAjC,aACAC,oBAGkBwB,SAASC,cAAc9B,0BAClB8B,cAAc,QAChCX,iBAAiB,iBAAiBmB,SAAQC,QAC3CA,MAAMC,UAAW,WAIfC,UAAYZ,SAASC,cAAc9B,sBACrCyC,YACAA,UAAUD,UAAW,SAInBE,gBAAkBb,SAASC,cAAc9B,uCAAuC8B,cAAc,UAChGY,kBACAA,gBAAgBF,UAAW,qBAItBG,aACL,uBACA,oBACAN,UACA,CACIO,aAAcC,KAAKC,UAAU1C,cAC7B2C,iBAAkB1C,mBAGrB2C,MAAK,CAACC,KAAMC,yBACCC,oBAAoBtB,SAASC,cAAc9B,0BAA2BiD,KAAMC,IAElFT,YACAA,UAAUD,UAAW,GAGrBE,kBACAA,gBAAgBF,UAAW,MAIlCY,MAAMC,aAAaC,0BAQR,KAnMW,YAErBC,UAAY1B,SAASC,cAAc9B,0BACzCuD,UAAUC,iBAAiB,SAAUC,GAC7BA,EAAEvD,OAAOgB,QAAQlB,0BACjByD,EAAEC,sBACFzD,WAAWwD,EAAEvD,SAKbuD,EAAEvD,OAAOgB,QAAQlB,0BACjByD,EAAEC,sBACF5C,WAAW2C,EAAEvD,kBAMrB2B,SAASC,cAAc9B,sBAAsBwD,iBAAiB,SAAUC,IACpEA,EAAEC,iBACF3C,kBAAkBwC,UAAUpC,iBAAiBnB,uBAAuB2D,YA+KxEC"}tests/penalty_rule_persistent_test.php000064400000011545152266501530014454 0ustar00. namespace gradepenalty_duedate; use context_course; use context_system; use gradepenalty_duedate\tests\penalty_testcase; /** * Test penalty rule persistent. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \gradepenalty_duedate\penalty_rule */ final class penalty_rule_persistent_test extends penalty_testcase { /** * Test get rules. */ public function test_get_rules(): void { $this->resetAfterTest(); $this->create_sample_rules(); $course = $this->getDataGenerator()->create_course(); $systemcontextid = context_system::instance()->id; $coursecontextid = context_course::instance($course->id)->id; // Check system context penalty rules. $rules = penalty_rule::get_rules($systemcontextid); $this->assertCount(5, $rules); $this->assertEquals(10, $rules[0]->get('penalty')); $this->assertEquals(20, $rules[1]->get('penalty')); $this->assertEquals(30, $rules[2]->get('penalty')); $this->assertEquals(40, $rules[3]->get('penalty')); $this->assertEquals(50, $rules[4]->get('penalty')); // Check course context penalty rules. $rules = penalty_rule::get_records(['contextid' => $coursecontextid]); $this->assertCount(0, $rules); // Verify the rules are inherited. $rules = penalty_rule::get_rules($coursecontextid); $this->assertCount(5, $rules); $this->assertEquals(10, $rules[0]->get('penalty')); $this->assertEquals(20, $rules[1]->get('penalty')); $this->assertEquals(30, $rules[2]->get('penalty')); $this->assertEquals(40, $rules[3]->get('penalty')); $this->assertEquals(50, $rules[4]->get('penalty')); } /** * Test reset rules. */ public function test_reset_rules(): void { $this->resetAfterTest(); $this->create_sample_rules(); $systemcontextid = context_system::instance()->id; penalty_rule::reset_rules($systemcontextid); $rules = penalty_rule::get_rules($systemcontextid); // Default 0% rule. $this->assertCount(1, $rules); $this->assertEquals(0, $rules[0]->get('penalty')); } /** * Test check if rules are overridden. */ public function test_is_overridden(): void { $this->resetAfterTest(); // System context penalty rules are never considered to be overridden. $systemcontextid = context_system::instance()->id; $this->create_sample_rules(); $this->assertFalse(penalty_rule::is_overridden($systemcontextid)); $course = $this->getDataGenerator()->create_course(); $coursecontextid = context_course::instance($course->id)->id; // Verify the course with no rules is not considered overridden. $this->assertFalse(penalty_rule::is_overridden($coursecontextid)); // Add penalty rules to the course context and verify they are considered overridden. $this->create_sample_rules($coursecontextid); $this->assertTrue(penalty_rule::is_overridden($coursecontextid)); } /** * Test check if rules are inherited. */ public function test_is_inherited(): void { $this->resetAfterTest(); // System context. $systemcontextid = context_system::instance()->id; $this->create_sample_rules(); $this->assertFalse(penalty_rule::is_inherited($systemcontextid)); // Create a course. $course = $this->getDataGenerator()->create_course(); $coursecontextid = context_course::instance($course->id)->id; // There is no rules created at course context, so they are inherited rules. $this->assertTrue(penalty_rule::is_inherited($coursecontextid)); // Create sample rules at course context, they are not considered inherited. $this->create_sample_rules($coursecontextid); $this->assertFalse(penalty_rule::is_inherited($coursecontextid)); // Remove the rules from the parent context. penalty_rule::reset_rules($systemcontextid); $this->assertFalse(penalty_rule::is_inherited($coursecontextid)); } } tests/classes/penalty_testcase.php000064400000004336152266501530013436 0ustar00. namespace gradepenalty_duedate\tests; use advanced_testcase; use context_system; /** * Base test. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class penalty_testcase extends advanced_testcase { /** * Reset after test. * * @return void */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * Create sample rules. * * @param int|null $contextid The context id. */ public function create_sample_rules(?int $contextid = null): void { global $DB; // Use system context by default. $contextid ??= context_system::instance()->id; // Remove initial rule. $DB->delete_records('gradepenalty_duedate_rule', ['contextid' => $contextid]); $rules = [ ['contextid' => $contextid, 'overdueby' => DAYSECS, 'penalty' => 10, 'sortorder' => 0], ['contextid' => $contextid, 'overdueby' => DAYSECS * 2, 'penalty' => 20, 'sortorder' => 1], ['contextid' => $contextid, 'overdueby' => DAYSECS * 3, 'penalty' => 30, 'sortorder' => 2], ['contextid' => $contextid, 'overdueby' => DAYSECS * 4, 'penalty' => 40, 'sortorder' => 3], ['contextid' => $contextid, 'overdueby' => DAYSECS * 5, 'penalty' => 50, 'sortorder' => 4], ]; foreach ($rules as $rule) { $DB->insert_record('gradepenalty_duedate_rule', (object)$rule); } } } tests/penalty_calculator_test.php000064400000013524152266501530013355 0ustar00. namespace gradepenalty_duedate; use cm_info; use context_course; use context_module; use context_system; use core\plugininfo\gradepenalty; use core_grades\penalty_manager; use grade_item; use gradepenalty_duedate\tests\penalty_testcase; /** * Test for penalty calculator. * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \gradepenalty_duedate\penalty_calculator */ final class penalty_calculator_test extends penalty_testcase { /** * Data provider for test_calculate_penalty. */ public static function calculate_penalty_provider(): array { return [ // Submission date, due date and expected grade. // No penalty. [0, 0, 100], // One day late. [1, 0, 90], [DAYSECS, 0, 90], // Two day late. [DAYSECS + 1, 0, 80], [DAYSECS * 2, 0, 80], // Three day late. [DAYSECS * 2 + 1, 0, 70], [DAYSECS * 3, 0, 70], // Four day late. [DAYSECS * 3 + 1, 0, 60], [DAYSECS * 4, 0, 60], // Five day late. [DAYSECS * 4 + 1, 0, 50], [DAYSECS * 5, 0, 50], // Six day late. Same penalty as five day late. [DAYSECS * 5 + 1, 0, 50], [DAYSECS * 6, 0, 50], ]; } /** * Test calculate penalty. * * @dataProvider calculate_penalty_provider * * @param int $submissiondate The submission date. * @param int $duedate The due date. * @param int $expectedgrade The expected grade. */ public function test_calculate_penalty(int $submissiondate, int $duedate, int $expectedgrade): void { $this->resetAfterTest(); // Create a course and an assignment. $user = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $assignment = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); // Create sample rules. $this->create_sample_rules(); // Enable grade penalty. penalty_manager::enable_modules(['quiz', 'assign']); gradepenalty::enable_plugin('duedate', true); // Add a grade. grade_update( 'mod/assign', $course->id, 'mod', 'assign', $assignment->id, 0, ['userid' => $user->id, 'rawgrade' => 100], ); // Get grade item. $gradeitemparams = [ 'courseid' => $course->id, 'itemtype' => 'mod', 'itemmodule' => 'assign', 'iteminstance' => $assignment->id, 'itemnumber' => 0, ]; $gradeitem = grade_item::fetch($gradeitemparams); // Apply penalty. penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, $submissiondate, $duedate); // Check the grade. $this->assertEquals($expectedgrade, $gradeitem->get_final($user->id)->finalgrade); } /** * Rules set at different contexts. */ public function test_find_effective_penalty_rules(): void { global $DB; $this->resetAfterTest(); // Create a course and an assignment. $course = $this->getDataGenerator()->create_course(); $assignment = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); $cm = get_coursemodule_from_instance('assign', $assignment->id, $course->id); $cm = cm_info::create($cm); // Create a penalty rule at the system context. $systemcontext = context_system::instance(); $systemrule = [ 'contextid' => $systemcontext->id, 'overdueby' => 1, 'penalty' => 10, 'sortorder' => 1, ]; $DB->insert_record('gradepenalty_duedate_rule', (object)$systemrule); // The penalty should be 10%. $this->assertEquals(10, penalty_calculator::get_penalty_from_rules($cm, DAYSECS, 0)); // Create a penalty rule at the course context. $coursecontext = context_course::instance($course->id); $courserule = [ 'contextid' => $coursecontext->id, 'overdueby' => 1, 'penalty' => 20, 'sortorder' => 1, ]; $DB->insert_record('gradepenalty_duedate_rule', (object)$courserule); // The penalty should be 20%. $this->assertEquals(20, penalty_calculator::get_penalty_from_rules($cm, DAYSECS, 0)); // Create a penalty rule at the module context. $cm = get_coursemodule_from_instance('assign', $assignment->id, $course->id); $cm = cm_info::create($cm); $modulecontext = context_module::instance($cm->id); $modulerule = [ 'contextid' => $modulecontext->id, 'overdueby' => 1, 'penalty' => 30, 'sortorder' => 1, ]; $DB->insert_record('gradepenalty_duedate_rule', (object)$modulerule); // The penalty should be 30%. $this->assertEquals(30, penalty_calculator::get_penalty_from_rules($cm, DAYSECS, 0)); } } tests/behat/penalty_rules.feature000064400000012651152266501530013246 0ustar00@gradepenalty @gradepenalty_duedate @penalty_rule Feature: As an administrator I need to add new penalty rule I need to edit penalty rule I need to delete penalty rule Background: Given I log in as "admin" And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And I navigate to "Grades > Grade penalties > Manage penalty plugins" in site administration And I click on "Enable Late submission penalties" "checkbox" And I reload the page @javascript Scenario: Edit, add, and delete and insert penalty rules When I navigate to "Grades > Grade penalties > Late submission penalties > Penalty rules" in site administration # Add 5 penalty rules. And I click on "Edit" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I set the following fields to these values: | overdueby[0][number] | 1 | | penalty[0] | 10 | | overdueby[1][number] | 1 | | penalty[1] | 10 | And I click on "Save changes" "button" Then I should see "The overdue amount must be greater than the amount for the rule above (1 day). The penalty must be greater than the penalty for the rule above (10.0%)." And I set the following fields to these values: | overdueby[1][number] | 2 | | penalty[1] | 20 | | overdueby[2][number] | 3 | | penalty[2] | 101 | And I click on "Save changes" "button" Then I should see "The penalty cannot be greater than 100.0%." And I set the following fields to these values: | overdueby[2][number] | 3 | | penalty[2] | 30 | | overdueby[3][number] | 4 | | penalty[3] | 40 | | overdueby[4][number] | 5 | | penalty[4] | 50 | | Final penalty rule | 100 | And I click on "Save changes" "button" Then I should see "Changes saved" Then I should see "10%" in the "≤ 1 day" "table_row" Then I should see "20%" in the "≤ 2 days" "table_row" Then I should see "30%" in the "≤ 3 days" "table_row" Then I should see "40%" in the "≤ 4 days" "table_row" Then I should see "50%" in the "≤ 5 days" "table_row" Then I should see "100%" in the "> 5 days" "table_row" @javascript Scenario: Override penalty rules at a course context When I navigate to "Grades > Grade penalties > Late submission penalties > Penalty rules" in site administration And I click on "Edit" "button" # Add 5 penalty rules. And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I click on "Add rule" "button" And I set the following fields to these values: | overdueby[0][number] | 1 | | penalty[0] | 10 | | overdueby[1][number] | 2 | | penalty[1] | 20 | | overdueby[2][number] | 3 | | penalty[2] | 30 | | overdueby[3][number] | 4 | | penalty[3] | 40 | | overdueby[4][number] | 5 | | penalty[4] | 50 | | Final penalty rule | 100 | And I click on "Save changes" "button" # Override penalty rules at a course context. When I am on "Course 1" course homepage And I navigate to "Grade penalties > Penalty rules" in current page administration Then I should see "10%" in the "≤ 1 day" "table_row" Then I should see "20%" in the "≤ 2 days" "table_row" Then I should see "30%" in the "≤ 3 days" "table_row" Then I should see "40%" in the "≤ 4 days" "table_row" Then I should see "50%" in the "≤ 5 days" "table_row" Then I should see "100%" in the "> 5 days" "table_row" And I click on "Edit" "button" And I set the following fields to these values: | overdueby[0][number] | 7 | | penalty[0] | 20 | | overdueby[1][number] | 8 | | penalty[1] | 30 | | overdueby[2][number] | 9 | | penalty[2] | 40 | | overdueby[3][number] | 10 | | penalty[3] | 50 | | overdueby[4][number] | 11 | | penalty[4] | 60 | | Final penalty rule | 100 | And I click on "Save changes" "button" Then I should see "Changes saved" Then I should see "20%" in the "≤ 7 days" "table_row" Then I should see "30%" in the "≤ 8 days" "table_row" Then I should see "40%" in the "≤ 9 days" "table_row" Then I should see "50%" in the "≤ 10 days" "table_row" Then I should see "60%" in the "≤ 11 days" "table_row" Then I should see "100%" in the "> 11 days" "table_row" # Reset. When I click on "Reset" "button" Then I click on "Continue" "button" Then I should see "10%" in the "≤ 1 day" "table_row" Then I should see "20%" in the "≤ 2 days" "table_row" Then I should see "30%" in the "≤ 3 days" "table_row" Then I should see "40%" in the "≤ 4 days" "table_row" Then I should see "50%" in the "≤ 5 days" "table_row" Then I should see "100%" in the "> 5 days" "table_row" settings.php000064400000003262152266501530007125 0ustar00. /** * Site configuration settings for the gradepenalty_duedate plugin * * @package gradepenalty_duedate * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\url; defined('MOODLE_INTERNAL') || die(); // New category for the plugin. $ADMIN->add('gradepenalty', new admin_category('gradepenalty_duedate', new lang_string('pluginname', 'gradepenalty_duedate'))); $capabilities = ['gradepenalty/duedate:manage']; if ($hassiteconfig || has_any_capability($capabilities, core\context\system::instance())) { // External page to manage the duedate rules. $temp = new admin_externalpage( 'duedaterule', get_string('duedaterule', 'gradepenalty_duedate'), new url('/grade/penalty/duedate/manage_penalty_rule.php', ['contextid' => context_system::instance()->id]), 'gradepenalty/duedate:manage' ); // Add the external page to the plugin category. $ADMIN->add('gradepenalty_duedate', $temp); }