From b09ae54a1ddbf9328744d2791a0746d1a8dbc33a Mon Sep 17 00:00:00 2001 From: Alexander Van der Bellen Date: Wed, 26 Nov 2025 21:10:39 +0800 Subject: [PATCH 1/3] MDL-86378 mod_quiz: Add override reason field --- .../backup/moodle2/backup_quiz_stepslib.php | 2 +- public/mod/quiz/classes/cache/overrides.php | 4 +- .../quiz/classes/external/get_overrides.php | 3 + .../quiz/classes/external/save_overrides.php | 12 +- .../quiz/classes/form/edit_override_form.php | 10 + .../quiz/classes/local/override_manager.php | 17 +- public/mod/quiz/classes/privacy/provider.php | 7 + public/mod/quiz/db/install.xml | 4 +- public/mod/quiz/db/upgrade.php | 22 ++ public/mod/quiz/lang/en/quiz.php | 3 + public/mod/quiz/overrideedit.php | 15 + public/mod/quiz/overrides.php | 14 + .../tests/backup/restore_override_test.php | 64 +++++ .../tests/behat/quiz_override_reason.feature | 64 +++++ .../mod/quiz/tests/external/override_test.php | 261 ++++++++++++++++++ .../mod/quiz/tests/privacy/provider_test.php | 3 + public/mod/quiz/version.php | 2 +- 17 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 public/mod/quiz/tests/backup/restore_override_test.php create mode 100644 public/mod/quiz/tests/behat/quiz_override_reason.feature diff --git a/public/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/public/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index 3a81746087e..4869678e400 100644 --- a/public/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/public/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -73,7 +73,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $override = new backup_nested_element('override', ['id'], [ 'userid', 'groupid', 'timeopen', 'timeclose', - 'timelimit', 'attempts', 'password']); + 'timelimit', 'attempts', 'password', 'reason', 'reasonformat']); $grades = new backup_nested_element('grades'); diff --git a/public/mod/quiz/classes/cache/overrides.php b/public/mod/quiz/classes/cache/overrides.php index 4a6ff371e45..223ed202d2c 100644 --- a/public/mod/quiz/classes/cache/overrides.php +++ b/public/mod/quiz/classes/cache/overrides.php @@ -78,7 +78,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'quiz_overrides', ['quiz' => $quizid, 'userid' => $userid], - 'timeopen, timeclose, timelimit, attempts, password' + 'timeopen, timeclose, timelimit, attempts, password, reason, reasonformat' ); break; case 'g': @@ -86,7 +86,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'quiz_overrides', ['quiz' => $quizid, 'groupid' => $groupid], - 'timeopen, timeclose, timelimit, attempts, password' + 'timeopen, timeclose, timelimit, attempts, password, reason, reasonformat' ); break; default: diff --git a/public/mod/quiz/classes/external/get_overrides.php b/public/mod/quiz/classes/external/get_overrides.php index 8a228bdc188..c0ae930c713 100644 --- a/public/mod/quiz/classes/external/get_overrides.php +++ b/public/mod/quiz/classes/external/get_overrides.php @@ -17,6 +17,7 @@ namespace mod_quiz\external; use core_external\external_api; +use core_external\external_format_value; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; @@ -85,6 +86,8 @@ class get_overrides extends external_api { 'timelimit' => new external_value(PARAM_INT, 'Override time limit value', VALUE_DEFAULT, null), 'attempts' => new external_value(PARAM_INT, 'Override attempts value', VALUE_DEFAULT, null), 'password' => new external_value(PARAM_TEXT, 'Override password', VALUE_DEFAULT, null), + 'reason' => new external_value(PARAM_RAW, 'Override reason', VALUE_DEFAULT, null), + 'reasonformat' => new external_format_value('reason', VALUE_DEFAULT, null), ]); return new external_single_structure([ diff --git a/public/mod/quiz/classes/external/save_overrides.php b/public/mod/quiz/classes/external/save_overrides.php index 545e818d1e4..4bd1fb19cee 100644 --- a/public/mod/quiz/classes/external/save_overrides.php +++ b/public/mod/quiz/classes/external/save_overrides.php @@ -17,6 +17,7 @@ namespace mod_quiz\external; use core_external\external_api; +use core_external\external_format_value; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; @@ -46,6 +47,8 @@ class save_overrides extends external_api { 'timelimit' => new external_value(PARAM_INT, 'Quiz override time limit', VALUE_DEFAULT, null), 'attempts' => new external_value(PARAM_INT, 'Quiz override attempt count', VALUE_DEFAULT, null), 'password' => new external_value(PARAM_TEXT, 'Quiz override password', VALUE_DEFAULT, null), + 'reason' => new external_value(PARAM_RAW, 'Quiz override reason', VALUE_OPTIONAL), + 'reasonformat' => new external_format_value('reason', VALUE_OPTIONAL), ]); return new external_function_parameters([ @@ -82,7 +85,14 @@ class save_overrides extends external_api { ); // Iterate over and save all overrides. - $ids = array_map(fn($override) => $manager->save_override($override), $overrides); + $ids = array_map(function (array $override) use ($manager): int { + // Ensure reasonformat is set on create when a reason is supplied but no format given. + if (empty($override['id']) && isset($override['reason']) && !isset($override['reasonformat'])) { + $override['reasonformat'] = FORMAT_MOODLE; + } + + return $manager->save_override($override); + }, $overrides); return ['ids' => $ids]; } diff --git a/public/mod/quiz/classes/form/edit_override_form.php b/public/mod/quiz/classes/form/edit_override_form.php index 4f5c7c67860..0a894e133e9 100644 --- a/public/mod/quiz/classes/form/edit_override_form.php +++ b/public/mod/quiz/classes/form/edit_override_form.php @@ -224,6 +224,16 @@ class edit_override_form extends moodleform { $mform->addHelpButton('attempts', 'attempts', 'quiz'); $mform->setDefault('attempts', $this->quiz->attempts); + // Reason for override. + $editoroptions = [ + 'maxfiles' => 0, + 'noclean' => false, + 'context' => $this->context, + ]; + $mform->addElement('editor', 'reason_editor', get_string('overridereason', 'quiz'), null, $editoroptions); + $mform->setType('reason_editor', PARAM_RAW); + $mform->addHelpButton('reason_editor', 'overridereason', 'quiz'); + // Submit buttons. $mform->addElement('submit', 'resetbutton', get_string('reverttodefaults', 'quiz')); diff --git a/public/mod/quiz/classes/local/override_manager.php b/public/mod/quiz/classes/local/override_manager.php index 827cdeac5e1..2ba36e6a4a9 100644 --- a/public/mod/quiz/classes/local/override_manager.php +++ b/public/mod/quiz/classes/local/override_manager.php @@ -91,7 +91,14 @@ class override_manager { return isset($formdata->$key) && !is_null($formdata->$key); }, self::OVERRIDEABLE_QUIZ_SETTINGS); - if (!in_array(true, $keysthatareset)) { + $hasoverridevalues = in_array(true, $keysthatareset, true); + + // If updating, we can also just update the reason. + if (!empty($formdata->id) && (property_exists($formdata, 'reason') || property_exists($formdata, 'reasonformat'))) { + $hasoverridevalues = true; + } + + if (!$hasoverridevalues) { $errors['general'][] = new \lang_string('nooverridedata', 'quiz'); } @@ -221,6 +228,14 @@ class override_manager { // Remove values that are the same as currently in the quiz. $settings = $this->clear_unused_values($settings); + // Pass through the optional reason fields unchanged. + if (array_key_exists('reason', $formdata)) { + $settings['reason'] = $formdata['reason']; + } + if (array_key_exists('reasonformat', $formdata) && $formdata['reasonformat'] !== null) { + $settings['reasonformat'] = $formdata['reasonformat']; + } + // Add the user / group back as applicable. $userorgroupdata = array_intersect_key($formdata, array_flip(['userid', 'groupid', 'quiz', 'id'])); diff --git a/public/mod/quiz/classes/privacy/provider.php b/public/mod/quiz/classes/privacy/provider.php index 6b397fabaec..4d6b1aafd2b 100644 --- a/public/mod/quiz/classes/privacy/provider.php +++ b/public/mod/quiz/classes/privacy/provider.php @@ -104,6 +104,7 @@ class provider implements 'timeopen' => 'privacy:metadata:quiz_overrides:timeopen', 'timeclose' => 'privacy:metadata:quiz_overrides:timeclose', 'timelimit' => 'privacy:metadata:quiz_overrides:timelimit', + 'reason' => 'privacy:metadata:quiz_overrides:reason', ], 'privacy:metadata:quiz_overrides'); // These define the structure of the quiz. @@ -255,6 +256,8 @@ class provider implements qo.timeopen AS override_timeopen, qo.timeclose AS override_timeclose, qo.timelimit AS override_timelimit, + qo.reason AS override_reason, + qo.reasonformat AS override_reasonformat, c.id AS contextid, cm.id AS cmid FROM {context} c @@ -305,6 +308,10 @@ class provider implements if (!empty($quizdata->override_timelimit)) { $quizdata->override->timelimit = $quiz->override_timelimit; } + if (!empty($quiz->override_reason)) { + $format = $quiz->override_reasonformat ?? FORMAT_MOODLE; + $quizdata->override->reason = format_text($quiz->override_reason, $format, ['context' => $context]); + } } $quizdata->accessdata = (object) []; diff --git a/public/mod/quiz/db/install.xml b/public/mod/quiz/db/install.xml index 229e381f27f..6fb2b080671 100644 --- a/public/mod/quiz/db/install.xml +++ b/public/mod/quiz/db/install.xml @@ -1,5 +1,5 @@ - @@ -133,6 +133,8 @@ + + diff --git a/public/mod/quiz/db/upgrade.php b/public/mod/quiz/db/upgrade.php index 57970c45fac..33eb95b25cc 100644 --- a/public/mod/quiz/db/upgrade.php +++ b/public/mod/quiz/db/upgrade.php @@ -84,5 +84,27 @@ function xmldb_quiz_upgrade($oldversion) { upgrade_mod_savepoint(true, 2026022400, 'quiz'); } + if ($oldversion < 2026030600) { + // Define field reason to be added to quiz_overrides. + $table = new xmldb_table('quiz_overrides'); + $field = new xmldb_field('reason', XMLDB_TYPE_TEXT, null, null, null, null, null, 'password'); + + // Conditionally launch add field reason. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field reasonformat to be added to quiz_overrides. + $formatfield = new xmldb_field('reasonformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'reason'); + + // Conditionally launch add field reasonformat. + if (!$dbman->field_exists($table, $formatfield)) { + $dbman->add_field($table, $formatfield); + } + + // Quiz savepoint reached. + upgrade_mod_savepoint(true, 2026030600, 'quiz'); + } + return true; } diff --git a/public/mod/quiz/lang/en/quiz.php b/public/mod/quiz/lang/en/quiz.php index 81de4c729b5..4a3d06217b0 100644 --- a/public/mod/quiz/lang/en/quiz.php +++ b/public/mod/quiz/lang/en/quiz.php @@ -684,6 +684,8 @@ $string['overridedeletegroupsure'] = 'Are you sure you want to delete the overri $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->quiz} - {$a->group}'; +$string['overridereason'] = 'Reason for override'; +$string['overridereason_help'] = 'Optionally record the reason for this override.'; $string['overrideinvalidattempts'] = 'Attempts value must be greater than zero.'; $string['overrideinvalidexistingid'] = 'Existing override doesn\'t exist.'; $string['overrideinvalidgroup'] = 'Group given doesn\'t exist.'; @@ -764,6 +766,7 @@ $string['privacy:metadata:quiz_grades:timemodified'] = 'The time that the grade $string['privacy:metadata:quiz_grades:userid'] = 'The user who was graded.'; $string['privacy:metadata:quiz_overrides'] = 'Details about overrides for this quiz'; $string['privacy:metadata:quiz_overrides:quiz'] = 'The quiz with override information'; +$string['privacy:metadata:quiz_overrides:reason'] = 'Optional notes documenting the reason for a quiz override.'; $string['privacy:metadata:quiz_overrides:timeclose'] = 'The new close time for the quiz.'; $string['privacy:metadata:quiz_overrides:timelimit'] = 'The new time limit for the quiz.'; $string['privacy:metadata:quiz_overrides:timeopen'] = 'The new open time for the quiz.'; diff --git a/public/mod/quiz/overrideedit.php b/public/mod/quiz/overrideedit.php index 492fb290189..5b80fca26be 100644 --- a/public/mod/quiz/overrideedit.php +++ b/public/mod/quiz/overrideedit.php @@ -88,6 +88,14 @@ foreach ($keys as $key) { } } +// Prepare reason editor data. +if (isset($override->reason)) { + $data->reason_editor = [ + 'text' => $override->reason, + 'format' => $override->reasonformat ?? FORMAT_MOODLE, + ]; +} + // If we are duplicating an override, then clear the user/group and override id // since they will change. if ($action === 'duplicate') { @@ -121,6 +129,13 @@ if ($mform->is_cancelled()) { $fromform->id = $overrideid; } + // Extract reason and reasonformat from editor field. + if (isset($fromform->reason_editor)) { + $fromform->reason = $fromform->reason_editor['text'] ?? ''; + $fromform->reasonformat = $fromform->reason_editor['format'] ?? FORMAT_MOODLE; + unset($fromform->reason_editor); + } + // Process the data. $id = $manager->save_override((array) $fromform); diff --git a/public/mod/quiz/overrides.php b/public/mod/quiz/overrides.php index be467be6243..c38b467ac5a 100644 --- a/public/mod/quiz/overrides.php +++ b/public/mod/quiz/overrides.php @@ -229,6 +229,20 @@ foreach ($overrides as $override) { get_string('enabled', 'quiz') : get_string('none', 'quiz'); } + // Format reason. + if (isset($override->reason) && $override->reason !== '') { + $formattedreason = format_text( + $override->reason, + $override->reasonformat ?? FORMAT_MOODLE, + ['context' => $context], + ); + + if ($formattedreason !== '') { + $fields[] = get_string('overridereason', 'quiz'); + $values[] = $formattedreason; + } + } + // Prepare the information about who this override applies to. $extranamebit = $active ? '' : '*'; $usercells = []; diff --git a/public/mod/quiz/tests/backup/restore_override_test.php b/public/mod/quiz/tests/backup/restore_override_test.php new file mode 100644 index 00000000000..d1724358aa6 --- /dev/null +++ b/public/mod/quiz/tests/backup/restore_override_test.php @@ -0,0 +1,64 @@ +. + +namespace mod_quiz\backup; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); + +/** + * Restore override tests. + * + * @package mod_quiz + * @author Alexander Van der Bellen + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(\restore_quiz_activity_structure_step::class)] +final class restore_override_test extends \restore_date_testcase { + /** + * Test restore overrides with reason. + */ + public function test_restore_overrides_with_reason(): void { + global $DB, $USER; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + + $useroverride = (object) [ + 'quiz' => $quiz->id, + 'userid' => $USER->id, + 'timeopen' => 100, + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_HTML, + ]; + $DB->insert_record('quiz_overrides', $useroverride); + + // Back up and restore. + $newcourseid = $this->backup_and_restore($course); + $newquiz = $DB->get_record('quiz', ['course' => $newcourseid]); + $overrides = $DB->get_records('quiz_overrides', ['quiz' => $newquiz->id]); + + $this->assertEquals(1, count($overrides)); + $restoredoverride = reset($overrides); + $this->assertEquals($useroverride->reason, $restoredoverride->reason); + $this->assertEquals($useroverride->reasonformat, $restoredoverride->reasonformat); + } +} diff --git a/public/mod/quiz/tests/behat/quiz_override_reason.feature b/public/mod/quiz/tests/behat/quiz_override_reason.feature new file mode 100644 index 00000000000..676e755a933 --- /dev/null +++ b/public/mod/quiz/tests/behat/quiz_override_reason.feature @@ -0,0 +1,64 @@ +@mod @mod_quiz +Feature: Quiz override reason + In order to explain why an override was granted + As a teacher + I need to be able to add a reason to an override + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | Group 1 | C1 | G1 | + And the following "group members" exist: + | user | group | + | student1 | G1 | + And the following "activities" exist: + | activity | name | intro | course | + | quiz | Test quiz | Test quiz intro | C1 | + + @javascript + Scenario: Add a user override with a reason + Given I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher1" + When I navigate to "Overrides" in current page administration + And I press "Add user override" + And I set the following fields to these values: + | Override user | Student 1 | + | Close the quiz | ##1 Jan 2030 08:00## | + | Reason | Extension granted for medical reasons | + And I press "Save" + And I should see "Extension granted for medical reasons" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Student 1" "table_row" + And the field "Reason for override" matches value "Extension granted for medical reasons" + And I set the following fields to these values: + | Reason | Updated reason after review | + And I press "Save" + Then I should see "Updated reason after review" in the "Reason for override" "table_row" + + @javascript + Scenario: Add a group override with a reason + Given I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher1" + When I navigate to "Overrides" in current page administration + And I select "Group overrides" from the "jump" singleselect + And I press "Add group override" + And I set the following fields to these values: + | Override group | Group 1 | + | Close the quiz | ##1 Jan 2030 08:00## | + | Reason | Additional time approved for this group | + And I press "Save" + And I should see "Additional time approved for this group" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Group 1" "table_row" + And the field "Reason for override" matches value "Additional time approved for this group" + And I set the following fields to these values: + | Reason | Updated group reason after review | + And I press "Save" + Then I should see "Updated group reason after review" in the "Reason for override" "table_row" diff --git a/public/mod/quiz/tests/external/override_test.php b/public/mod/quiz/tests/external/override_test.php index 7b87796b73e..d49dfc2a998 100644 --- a/public/mod/quiz/tests/external/override_test.php +++ b/public/mod/quiz/tests/external/override_test.php @@ -216,4 +216,265 @@ final class override_test extends \core_external\tests\externallib_testcase { $this->assertNotEmpty($result['ids']); $this->assertContains($id, $result['ids']); } + + /** + * Provides values to test_save_reason_overrides + * + * @return array + */ + public static function save_reason_overrides_provider(): array { + return [ + 'create with reason' => [ + 'data' => [ + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_HTML, + 'timeopen' => 999, + ], + 'expectedreason' => 'This is a reason', + 'expectedformat' => FORMAT_HTML, + ], + 'create with reason no format' => [ + 'data' => [ + 'reason' => 'This is a reason', + 'timeopen' => 999, + ], + 'expectedreason' => 'This is a reason', + 'expectedformat' => FORMAT_MOODLE, + ], + 'create with reason and different format' => [ + 'data' => [ + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_MOODLE, + 'timeopen' => 999, + ], + 'expectedreason' => 'This is a reason', + 'expectedformat' => FORMAT_MOODLE, + ], + 'create with reason and null format' => [ + 'data' => [ + 'reason' => 'This is a reason', + 'reasonformat' => null, + 'timeopen' => 999, + ], + 'expectedreason' => 'This is a reason', + 'expectedformat' => FORMAT_MOODLE, + ], + 'create with reason only (fail)' => [ + 'data' => [ + 'reason' => 'This is a reason', + ], + 'expectedreason' => null, + 'expectedformat' => FORMAT_HTML, + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'create with format only' => [ + 'data' => [ + 'reasonformat' => FORMAT_MOODLE, + ], + 'expectedreason' => null, + 'expectedformat' => FORMAT_MOODLE, + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'create with null reason' => [ + 'data' => [ + 'reason' => null, + ], + 'expectedreason' => null, + 'expectedformat' => FORMAT_HTML, + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'create with no reason or format' => [ + 'data' => [], + 'expectedreason' => null, + 'expectedformat' => FORMAT_HTML, + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests save_overrides with reason + * + * @dataProvider save_reason_overrides_provider + * @param array $data + * @param string|null $expectedreason + * @param int|string|null $expectedformat + * @param string|null $expectedexception + */ + public function test_save_reason_overrides( + array $data, + ?string $expectedreason, + int|string|null $expectedformat, + ?string $expectedexception = null + ): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + $data = array_merge($data, ['userid' => $user->id]); + + $payload = [ + 'quizid' => $quiz->id, + 'overrides' => [ + $data, + ], + ]; + + if ($expectedexception) { + $this->expectException($expectedexception); + } + + $result = save_overrides::execute($payload); + + if ($expectedexception) { + return; + } + + $this->assertNotEmpty($result['ids']); + $this->assertCount(1, $result['ids']); + $overrideid = reset($result['ids']); + + $override = $DB->get_record('quiz_overrides', ['id' => $overrideid]); + $this->assertEquals($expectedreason, $override->reason); + $this->assertEquals($expectedformat, $override->reasonformat); + } + + /** + * Provides values to test_update_reason_overrides + * + * @return array + */ + public static function update_reason_overrides_provider(): array { + return [ + 'update reason' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reason' => 'Updated reason', + 'reasonformat' => FORMAT_HTML, + ], + 'expectedreason' => 'Updated reason', + 'expectedformat' => FORMAT_HTML, + ], + 'update reason format' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reason' => 'Initial reason', + 'reasonformat' => FORMAT_MOODLE, + ], + 'expectedreason' => 'Initial reason', + 'expectedformat' => FORMAT_MOODLE, + ], + 'update reason only' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reason' => 'Updated reason', + ], + 'expectedreason' => 'Updated reason', + 'expectedformat' => FORMAT_HTML, + ], + 'update format only' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reasonformat' => FORMAT_MOODLE, + ], + 'expectedreason' => 'Initial reason', + 'expectedformat' => FORMAT_MOODLE, + ], + 'update reason to null' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reason' => null, + ], + 'expectedreason' => null, + 'expectedformat' => FORMAT_HTML, + ], + 'update format to null' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [ + 'reasonformat' => null, + ], + 'expectedreason' => null, + 'expectedformat' => null, + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'update no changes' => [ + 'initialreason' => 'Initial reason', + 'initialformat' => FORMAT_HTML, + 'data' => [], + 'expectedreason' => null, + 'expectedformat' => null, + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests update_overrides with reason + * + * @dataProvider update_reason_overrides_provider + * @param string|null $initialreason + * @param int|string|null $initialformat + * @param array $data + * @param string|null $expectedreason + * @param int|string|null $expectedformat + * @param string|null $expectedexception + */ + public function test_update_reason_overrides( + ?string $initialreason, + int|string|null $initialformat, + array $data, + ?string $expectedreason, + int|string|null $expectedformat, + ?string $expectedexception = null + ): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + // Create initial override. + $overrideid = $DB->insert_record('quiz_overrides', [ + 'quiz' => $quiz->id, + 'userid' => $user->id, + 'reason' => $initialreason, + 'reasonformat' => $initialformat, + ]); + + $data['id'] = $overrideid; + $data['userid'] = $user->id; + + $payload = [ + 'quizid' => $quiz->id, + 'overrides' => [ + $data, + ], + ]; + + if ($expectedexception) { + $this->expectException($expectedexception); + } + + save_overrides::execute($payload); + + if ($expectedexception) { + return; + } + + $override = $DB->get_record('quiz_overrides', ['id' => $overrideid]); + $this->assertEquals($expectedreason, $override->reason); + $this->assertEquals($expectedformat, $override->reasonformat); + } } diff --git a/public/mod/quiz/tests/privacy/provider_test.php b/public/mod/quiz/tests/privacy/provider_test.php index 2fa4a9c61a7..3e827cf13f5 100644 --- a/public/mod/quiz/tests/privacy/provider_test.php +++ b/public/mod/quiz/tests/privacy/provider_test.php @@ -148,6 +148,8 @@ final class provider_test extends \core_privacy\tests\provider_testcase { 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_MOODLE, ]); // Run as the user and make an attempt on the quiz. @@ -177,6 +179,7 @@ final class provider_test extends \core_privacy\tests\provider_testcase { $quizdata = $writer->get_data([]); $this->assertEquals($quizobj->get_quiz_name(), $quizdata->name); + $this->assertEquals(format_text('This is a reason', FORMAT_MOODLE, ['context' => $context]), $quizdata->override->reason); // Every module has an intro. $this->assertTrue(isset($quizdata->intro)); diff --git a/public/mod/quiz/version.php b/public/mod/quiz/version.php index 084fbbb18e3..4042e7b41d9 100644 --- a/public/mod/quiz/version.php +++ b/public/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026022400; +$plugin->version = 2026030600; $plugin->requires = 2025092600; $plugin->component = 'mod_quiz'; From 7bd4aa2ee700047a8e44503e67e0182ab5f94b8c Mon Sep 17 00:00:00 2001 From: Alexander Van der Bellen Date: Wed, 26 Nov 2025 21:11:11 +0800 Subject: [PATCH 2/3] MDL-86378 mod_assign: Add override reason field --- .../backup/moodle2/backup_assign_stepslib.php | 13 +++- public/mod/assign/classes/cache/overrides.php | 4 +- .../mod/assign/classes/privacy/provider.php | 7 +- public/mod/assign/db/install.xml | 4 +- public/mod/assign/db/upgrade.php | 22 +++++++ public/mod/assign/lang/en/assign.php | 3 + public/mod/assign/override_form.php | 10 +++ public/mod/assign/overrideedit.php | 15 +++++ public/mod/assign/overrides.php | 14 ++++ .../tests/backup/restore_override_test.php | 65 +++++++++++++++++++ .../behat/assign_override_reason.feature | 64 ++++++++++++++++++ public/mod/assign/tests/locallib_test.php | 6 ++ .../assign/tests/privacy/provider_test.php | 6 ++ public/mod/assign/version.php | 2 +- 14 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 public/mod/assign/tests/backup/restore_override_test.php create mode 100644 public/mod/assign/tests/behat/assign_override_reason.feature diff --git a/public/mod/assign/backup/moodle2/backup_assign_stepslib.php b/public/mod/assign/backup/moodle2/backup_assign_stepslib.php index 62a53c8957f..f764caa80d8 100644 --- a/public/mod/assign/backup/moodle2/backup_assign_stepslib.php +++ b/public/mod/assign/backup/moodle2/backup_assign_stepslib.php @@ -142,8 +142,17 @@ class backup_assign_activity_structure_step extends backup_activity_structure_st 'value')); $overrides = new backup_nested_element('overrides'); - $override = new backup_nested_element('override', array('id'), array( - 'groupid', 'userid', 'sortorder', 'allowsubmissionsfromdate', 'duedate', 'cutoffdate', 'timelimit')); + $override = new backup_nested_element('override', ['id'], [ + 'groupid', + 'userid', + 'sortorder', + 'allowsubmissionsfromdate', + 'duedate', + 'cutoffdate', + 'timelimit', + 'reason', + 'reasonformat', + ]); // Build the tree. $assign->add_child($userflags); diff --git a/public/mod/assign/classes/cache/overrides.php b/public/mod/assign/classes/cache/overrides.php index 0c9d8912baf..bee511309d4 100644 --- a/public/mod/assign/classes/cache/overrides.php +++ b/public/mod/assign/classes/cache/overrides.php @@ -74,7 +74,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'assign_overrides', ['assignid' => $assignid, 'userid' => $userid], - 'duedate, cutoffdate, allowsubmissionsfromdate' + 'duedate, cutoffdate, allowsubmissionsfromdate, reason, reasonformat' ); break; case 'g': @@ -82,7 +82,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'assign_overrides', ['assignid' => $assignid, 'groupid' => $groupid], - 'sortorder, duedate, cutoffdate, allowsubmissionsfromdate' + 'sortorder, duedate, cutoffdate, allowsubmissionsfromdate, reason, reasonformat' ); break; default: diff --git a/public/mod/assign/classes/privacy/provider.php b/public/mod/assign/classes/privacy/provider.php index 8918fe1078b..bcfefb1e3f7 100644 --- a/public/mod/assign/classes/privacy/provider.php +++ b/public/mod/assign/classes/privacy/provider.php @@ -83,7 +83,8 @@ class provider implements 'userid' => 'privacy:metadata:userid', 'allowsubmissionsfromdate' => 'allowsubmissionsfromdate', 'duedate' => 'duedate', - 'cutoffdate' => 'cutoffdate' + 'cutoffdate' => 'cutoffdate', + 'reason' => 'privacy:metadata:assignoverrides:reason', ]; $assignsubmission = [ 'userid' => 'privacy:metadata:userid', @@ -627,6 +628,10 @@ class provider implements if (!empty($overrides->allowsubmissionsfromdate)) { $data->allowsubmissionsfromdate = transform::datetime($overrides->allowsubmissionsfromdate); } + if (!empty($overrides->reason)) { + $format = $overrides->reasonformat ?? FORMAT_MOODLE; + $data->reason = format_text($overrides->reason, $format, ['context' => $context]); + } if (!empty($data)) { writer::with_context($context)->export_data([get_string('overrides', 'mod_assign')], $data); } diff --git a/public/mod/assign/db/install.xml b/public/mod/assign/db/install.xml index da3b2765b7e..063d42377fc 100644 --- a/public/mod/assign/db/install.xml +++ b/public/mod/assign/db/install.xml @@ -1,5 +1,5 @@ - @@ -160,6 +160,8 @@ + + diff --git a/public/mod/assign/db/upgrade.php b/public/mod/assign/db/upgrade.php index 6a79a0c5a0c..81f4e5134e3 100644 --- a/public/mod/assign/db/upgrade.php +++ b/public/mod/assign/db/upgrade.php @@ -131,5 +131,27 @@ function xmldb_assign_upgrade($oldversion) { upgrade_mod_savepoint(true, 2026022300, 'assign'); } + if ($oldversion < 2026030600) { + // Define field reason to be added to assign_overrides. + $table = new xmldb_table('assign_overrides'); + $field = new xmldb_field('reason', XMLDB_TYPE_TEXT, null, null, null, null, null, 'timelimit'); + + // Conditionally launch add field reason. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field reasonformat to be added to assign_overrides. + $formatfield = new xmldb_field('reasonformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'reason'); + + // Conditionally launch add field reasonformat. + if (!$dbman->field_exists($table, $formatfield)) { + $dbman->add_field($table, $formatfield); + } + + // Assign savepoint reached. + upgrade_mod_savepoint(true, 2026030600, 'assign'); + } + return true; } diff --git a/public/mod/assign/lang/en/assign.php b/public/mod/assign/lang/en/assign.php index 0a4554a385f..d1caa07409b 100644 --- a/public/mod/assign/lang/en/assign.php +++ b/public/mod/assign/lang/en/assign.php @@ -479,6 +479,8 @@ $string['overridedeletegroupsure'] = 'Are you sure you want to delete the overri $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->assign} - {$a->group}'; +$string['overridereason'] = 'Reason for override'; +$string['overridereason_help'] = 'Optionally record the reason for this override.'; $string['overriderecalculatepenalty'] = 'Recalculate penalty for user(s) in the override'; $string['overrides'] = 'Overrides'; $string['overrideuser'] = 'Override user'; @@ -508,6 +510,7 @@ $string['privacy:metadata:assignmarkerfilter'] = 'Filter the assign summary by t $string['privacy:metadata:assignmentid'] = 'Assignment ID'; $string['privacy:metadata:assignmessageexplanation'] = 'Messages are sent to students through the messaging system.'; $string['privacy:metadata:assignoverrides'] = 'Stores override information for the assignment'; +$string['privacy:metadata:assignoverrides:reason'] = 'Optional notes documenting the reason for an assignment override.'; $string['privacy:metadata:assignperpage'] = 'Number of assignments shown per page.'; $string['privacy:metadata:assignquickgrading'] = 'A preference as to whether quick grading is used or not.'; $string['privacy:metadata:assignsubmissiondetail'] = 'Stores user submission information'; diff --git a/public/mod/assign/override_form.php b/public/mod/assign/override_form.php index ab732b5e74d..78059150174 100644 --- a/public/mod/assign/override_form.php +++ b/public/mod/assign/override_form.php @@ -286,6 +286,16 @@ class assign_override_form extends moodleform { $mform->setDefault('timelimit', $assigninstance->timelimit); } + // Reason for override. + $editoroptions = [ + 'maxfiles' => 0, + 'noclean' => false, + 'context' => $this->context, + ]; + $mform->addElement('editor', 'reason_editor', get_string('overridereason', 'assign'), null, $editoroptions); + $mform->setType('reason_editor', PARAM_RAW); + $mform->addHelpButton('reason_editor', 'overridereason', 'assign'); + // Submit buttons. $mform->addElement('submit', 'resetbutton', get_string('reverttodefaults', 'assign')); diff --git a/public/mod/assign/overrideedit.php b/public/mod/assign/overrideedit.php index 17fa1a321c9..ceb3bfdb988 100644 --- a/public/mod/assign/overrideedit.php +++ b/public/mod/assign/overrideedit.php @@ -103,6 +103,14 @@ foreach ($keys as $key) { } } +// Prepare reason editor data for existing overrides. +if (!empty($override) && isset($override->reason)) { + $data->reason_editor = [ + 'text' => $override->reason, + 'format' => $override->reasonformat ?? FORMAT_MOODLE, + ]; +} + // True if group-based override. $groupmode = !empty($data->groupid) || ($action === 'addgroup' && empty($overrideid)); @@ -142,6 +150,13 @@ if ($mform->is_cancelled()) { // Process the data. $fromform->assignid = $assigninstance->id; + // Extract reason and reasonformat from editor field. + if (isset($fromform->reason_editor)) { + $fromform->reason = $fromform->reason_editor['text'] ?? ''; + $fromform->reasonformat = $fromform->reason_editor['format'] ?? FORMAT_MOODLE; + unset($fromform->reason_editor); + } + // Replace unchanged values with null. foreach ($keys as $key) { if (!isset($fromform->{$key}) || $fromform->{$key} == $assigninstance->{$key}) { diff --git a/public/mod/assign/overrides.php b/public/mod/assign/overrides.php index 10e40e4febe..016d3325646 100644 --- a/public/mod/assign/overrides.php +++ b/public/mod/assign/overrides.php @@ -219,6 +219,20 @@ foreach ($overrides as $override) { $values[] = $override->timelimit > 0 ? format_time($override->timelimit) : get_string('none', 'assign'); } + // Format reason. + if (isset($override->reason) && $override->reason !== '') { + $formattedreason = format_text( + $override->reason, + $override->reasonformat ?? FORMAT_MOODLE, + ['context' => $context], + ); + + if ($formattedreason !== '') { + $fields[] = get_string('overridereason', 'assign'); + $values[] = $formattedreason; + } + } + // Icons. $iconstr = ''; diff --git a/public/mod/assign/tests/backup/restore_override_test.php b/public/mod/assign/tests/backup/restore_override_test.php new file mode 100644 index 00000000000..c52011f9bae --- /dev/null +++ b/public/mod/assign/tests/backup/restore_override_test.php @@ -0,0 +1,65 @@ +. + +namespace mod_assign\backup; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); + +/** + * Restore override tests. + * + * @package mod_assign + * @author Alexander Van der Bellen + * @copyright 2025 Catalyst IT Australia Pty Ltd + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(\restore_assign_activity_structure_step::class)] +final class restore_override_test extends \restore_date_testcase { + /** + * Test restore overrides with reason. + */ + public function test_restore_overrides_with_reason(): void { + global $DB, $USER; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + + $useroverride = (object) [ + 'assignid' => $assign->id, + 'userid' => $USER->id, + 'sortorder' => 1, + 'allowsubmissionsfromdate' => 100, + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_HTML, + ]; + $DB->insert_record('assign_overrides', $useroverride); + + // Back up and restore. + $newcourseid = $this->backup_and_restore($course); + $newassign = $DB->get_record('assign', ['course' => $newcourseid]); + $overrides = $DB->get_records('assign_overrides', ['assignid' => $newassign->id]); + + $this->assertEquals(1, count($overrides)); + $restoredoverride = reset($overrides); + $this->assertEquals($useroverride->reason, $restoredoverride->reason); + $this->assertEquals($useroverride->reasonformat, $restoredoverride->reasonformat); + } +} diff --git a/public/mod/assign/tests/behat/assign_override_reason.feature b/public/mod/assign/tests/behat/assign_override_reason.feature new file mode 100644 index 00000000000..3014280f486 --- /dev/null +++ b/public/mod/assign/tests/behat/assign_override_reason.feature @@ -0,0 +1,64 @@ +@mod @mod_assign +Feature: Assign override reason + In order to explain why an override was granted + As a teacher + I need to be able to add a reason to an override + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | Group 1 | C1 | G1 | + And the following "group members" exist: + | user | group | + | student1 | G1 | + And the following "activities" exist: + | activity | name | intro | course | assignsubmission_onlinetext_enabled | + | assign | Test assignment name | Submit your online text | C1 | 1 | + + @javascript + Scenario: Add a user override with a reason + Given I am on the "Test assignment name" Activity page logged in as teacher1 + When I navigate to "Overrides" in current page administration + And I press "Add user override" + And I set the following fields to these values: + | Override user | Student 1 | + | Due date | ##1 Jan 2030 08:00## | + | Reason | Extension granted for medical reasons | + And I press "Save" + And I should see "Extension granted for medical reasons" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Student 1" "table_row" + And the field "Reason for override" matches value "Extension granted for medical reasons" + And I set the following fields to these values: + | Reason | Updated reason after review | + And I press "Save" + Then I should see "Updated reason after review" in the "Reason for override" "table_row" + + @javascript + Scenario: Add a group override with a reason + Given I am on the "Test assignment name" Activity page logged in as teacher1 + When I navigate to "Overrides" in current page administration + And I select "Group overrides" from the "jump" singleselect + And I press "Add group override" + And I set the following fields to these values: + | Override group | Group 1 | + | Due date | ##1 Jan 2030 08:00## | + | Reason | Additional time approved for this group | + And I press "Save" + And I should see "Additional time approved for this group" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Group 1" "table_row" + And the field "Reason for override" matches value "Additional time approved for this group" + And I set the following fields to these values: + | Reason | Updated group reason after review | + And I press "Save" + Then I should see "Updated group reason after review" in the "Reason for override" "table_row" diff --git a/public/mod/assign/tests/locallib_test.php b/public/mod/assign/tests/locallib_test.php index 731f68d6b96..827916f2a34 100644 --- a/public/mod/assign/tests/locallib_test.php +++ b/public/mod/assign/tests/locallib_test.php @@ -4245,6 +4245,8 @@ Anchor link 2:Link text 'duedate' => 2, 'cutoffdate' => 3, 'timelimit' => null, + 'reason' => null, + 'reasonformat' => 0, ], (object) [ // Override for group 2, lower priority (numerically higher sortorder). @@ -4256,6 +4258,8 @@ Anchor link 2:Link text 'duedate' => 6, 'cutoffdate' => 6, 'timelimit' => null, + 'reason' => null, + 'reasonformat' => 0, ], (object) [ // User override. @@ -4267,6 +4271,8 @@ Anchor link 2:Link text 'duedate' => 8, 'cutoffdate' => 9, 'timelimit' => null, + 'reason' => null, + 'reasonformat' => 0, ], ]; diff --git a/public/mod/assign/tests/privacy/provider_test.php b/public/mod/assign/tests/privacy/provider_test.php index 201e5bcbd9a..61f9d57753f 100644 --- a/public/mod/assign/tests/privacy/provider_test.php +++ b/public/mod/assign/tests/privacy/provider_test.php @@ -226,6 +226,8 @@ final class provider_test extends provider_testcase { $overridedata->duedate = time(); $overridedata->allowsubmissionsfromdate = time(); $overridedata->cutoffdate = time(); + $overridedata->reason = 'This is a reason'; + $overridedata->reasonformat = FORMAT_MOODLE; $DB->insert_record('assign_overrides', $overridedata); $grade1 = '67.00'; @@ -284,6 +286,10 @@ final class provider_test extends provider_testcase { $overrideexport->cutoffdate); $this->assertEquals(\core_privacy\local\request\transform::datetime($overridedata->allowsubmissionsfromdate), $overrideexport->allowsubmissionsfromdate); + $this->assertEquals( + format_text($overridedata->reason, $overridedata->reasonformat, ['context' => $context]), + $overrideexport->reason + ); } /** diff --git a/public/mod/assign/version.php b/public/mod/assign/version.php index 0dc6b54f3ca..e120530c779 100644 --- a/public/mod/assign/version.php +++ b/public/mod/assign/version.php @@ -25,5 +25,5 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics). -$plugin->version = 2026022300; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2026030600; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2025092600; // Requires this Moodle version. From 94b2f36168682f124ee9afc61c89f38b65e8034c Mon Sep 17 00:00:00 2001 From: Alexander Van der Bellen Date: Wed, 26 Nov 2025 21:11:32 +0800 Subject: [PATCH 3/3] MDL-86378 mod_lesson: Add override reason field --- .../backup/moodle2/backup_lesson_stepslib.php | 2 +- public/mod/lesson/classes/cache/overrides.php | 4 +- .../mod/lesson/classes/privacy/provider.php | 14 +++- public/mod/lesson/db/install.xml | 4 +- public/mod/lesson/db/upgrade.php | 22 ++++++ public/mod/lesson/lang/en/lesson.php | 3 + public/mod/lesson/override_form.php | 10 +++ public/mod/lesson/overrideedit.php | 15 ++++ public/mod/lesson/overrides.php | 14 ++++ .../tests/backup/restore_override_test.php | 34 +++++++++ .../behat/lesson_override_reason.feature | 69 +++++++++++++++++++ .../lesson/tests/privacy/provider_test.php | 5 +- public/mod/lesson/version.php | 2 +- 13 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 public/mod/lesson/tests/behat/lesson_override_reason.feature diff --git a/public/mod/lesson/backup/moodle2/backup_lesson_stepslib.php b/public/mod/lesson/backup/moodle2/backup_lesson_stepslib.php index 0edd0bd8560..201cf5afe91 100644 --- a/public/mod/lesson/backup/moodle2/backup_lesson_stepslib.php +++ b/public/mod/lesson/backup/moodle2/backup_lesson_stepslib.php @@ -137,7 +137,7 @@ class backup_lesson_activity_structure_step extends backup_activity_structure_st $overrides = new backup_nested_element('overrides'); $override = new backup_nested_element('override', array('id'), array( 'groupid', 'userid', 'available', 'deadline', 'timelimit', - 'review', 'maxattempts', 'retake', 'password')); + 'review', 'maxattempts', 'retake', 'password', 'reason', 'reasonformat')); // Now that we have all of the elements created we've got to put them // together correctly. diff --git a/public/mod/lesson/classes/cache/overrides.php b/public/mod/lesson/classes/cache/overrides.php index aec3b4f2f05..0395a180a09 100644 --- a/public/mod/lesson/classes/cache/overrides.php +++ b/public/mod/lesson/classes/cache/overrides.php @@ -66,7 +66,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'lesson_overrides', ['lessonid' => $lessonid, 'userid' => $userid], - 'available, deadline, timelimit, review, maxattempts, retake, password' + 'available, deadline, timelimit, review, maxattempts, retake, password, reason, reasonformat' ); break; case 'g': @@ -74,7 +74,7 @@ class overrides implements data_source_interface { $override = $DB->get_record( 'lesson_overrides', ['lessonid' => $lessonid, 'groupid' => $groupid], - 'available, deadline, timelimit, review, maxattempts, retake, password' + 'available, deadline, timelimit, review, maxattempts, retake, password, reason, reasonformat' ); break; default: diff --git a/public/mod/lesson/classes/privacy/provider.php b/public/mod/lesson/classes/privacy/provider.php index 8621d75d6ec..54498cc36f7 100644 --- a/public/mod/lesson/classes/privacy/provider.php +++ b/public/mod/lesson/classes/privacy/provider.php @@ -107,6 +107,7 @@ class provider implements 'maxattempts' => 'privacy:metadata:overrides:maxattempts', 'retake' => 'privacy:metadata:overrides:retake', 'password' => 'privacy:metadata:overrides:password', + 'reason' => 'privacy:metadata:overrides:reason', ], 'privacy:metadata:overrides'); $collection->add_user_preference('lesson_view', 'privacy:metadata:userpref:lessonview'); @@ -257,7 +258,7 @@ class provider implements $recordset = $DB->get_recordset_select('lesson_overrides', $sqluserlesson, $paramsuserlesson); static::recordset_loop_and_export($recordset, 'lessonid', null, function($carry, $record) { // We know that there is only one row per lesson, so no need to use $carry. - return (object) [ + $data = (object) [ 'available' => $record->available !== null ? transform::datetime($record->available) : null, 'deadline' => $record->deadline !== null ? transform::datetime($record->deadline) : null, 'timelimit' => $record->timelimit !== null ? format_time($record->timelimit) : null, @@ -266,8 +267,19 @@ class provider implements 'retake' => $record->retake !== null ? transform::yesno($record->retake) : null, 'password' => $record->password, ]; + + if (!empty($record->reason)) { + $data->reason = $record->reason; + $data->reasonformat = $record->reasonformat ?? FORMAT_MOODLE; + } + + return $data; }, function($lessonid, $data) use ($lessonidstocmids) { $context = context_module::instance($lessonidstocmids[$lessonid]); + if (isset($data->reason)) { + $data->reason = format_text($data->reason, $data->reasonformat, ['context' => $context]); + unset($data->reasonformat); + } writer::with_context($context)->export_related_data([], 'overrides', $data); }); diff --git a/public/mod/lesson/db/install.xml b/public/mod/lesson/db/install.xml index 88080ff4782..d2f2b0d0430 100644 --- a/public/mod/lesson/db/install.xml +++ b/public/mod/lesson/db/install.xml @@ -1,5 +1,5 @@ - @@ -189,6 +189,8 @@ + + diff --git a/public/mod/lesson/db/upgrade.php b/public/mod/lesson/db/upgrade.php index 03d0e114f96..1277e13e235 100644 --- a/public/mod/lesson/db/upgrade.php +++ b/public/mod/lesson/db/upgrade.php @@ -75,5 +75,27 @@ function xmldb_lesson_upgrade($oldversion) { upgrade_mod_savepoint(true, 2026022300, 'lesson'); } + if ($oldversion < 2026030600) { + // Define field reason to be added to lesson_overrides. + $table = new xmldb_table('lesson_overrides'); + $field = new xmldb_field('reason', XMLDB_TYPE_TEXT, null, null, null, null, null, 'password'); + + // Conditionally launch add field reason. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field reasonformat to be added to lesson_overrides. + $formatfield = new xmldb_field('reasonformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'reason'); + + // Conditionally launch add field reasonformat. + if (!$dbman->field_exists($table, $formatfield)) { + $dbman->add_field($table, $formatfield); + } + + // Lesson savepoint reached. + upgrade_mod_savepoint(true, 2026030600, 'lesson'); + } + return true; } diff --git a/public/mod/lesson/lang/en/lesson.php b/public/mod/lesson/lang/en/lesson.php index cc957c8a042..629197ab2af 100644 --- a/public/mod/lesson/lang/en/lesson.php +++ b/public/mod/lesson/lang/en/lesson.php @@ -434,6 +434,8 @@ $string['overridedeletegroupsure'] = 'Are you sure you want to delete the overri $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->lesson} - {$a->group}'; +$string['overridereason'] = 'Reason for override'; +$string['overridereason_help'] = 'Optionally record the reason for this override.'; $string['overrides'] = 'Overrides'; $string['overrideuser'] = 'Override user'; $string['overrideusereventname'] = '{$a->lesson} - Override'; @@ -500,6 +502,7 @@ $string['privacy:metadata:overrides:maxattempts'] = 'The maximium number of atte $string['privacy:metadata:overrides:retake'] = 'Whether re-takes are allowed'; $string['privacy:metadata:overrides:password'] = 'The password to access the lesson'; $string['privacy:metadata:overrides'] = 'A record of overrides per lesson'; +$string['privacy:metadata:overrides:reason'] = 'Optional notes documenting the reason for a lesson override.'; $string['privacy:metadata:userpref:lessonview'] = 'The preferred display mode when editing lessons'; $string['privacy:path:essayresponses'] = 'Essay responses'; $string['privacy:path:essayanswers'] = 'Essay answers'; diff --git a/public/mod/lesson/override_form.php b/public/mod/lesson/override_form.php index 2aeae2203b1..c7346d61abd 100644 --- a/public/mod/lesson/override_form.php +++ b/public/mod/lesson/override_form.php @@ -230,6 +230,16 @@ class lesson_override_form extends moodleform { $mform->addHelpButton('retake', 'retakesallowed', 'lesson'); $mform->setDefault('retake', $this->lesson->retake); + // Reason for override. + $editoroptions = [ + 'maxfiles' => 0, + 'noclean' => false, + 'context' => $this->context, + ]; + $mform->addElement('editor', 'reason_editor', get_string('overridereason', 'lesson'), null, $editoroptions); + $mform->setType('reason_editor', PARAM_RAW); + $mform->addHelpButton('reason_editor', 'overridereason', 'lesson'); + // Submit buttons. $mform->addElement('submit', 'resetbutton', get_string('reverttodefaults', 'lesson')); diff --git a/public/mod/lesson/overrideedit.php b/public/mod/lesson/overrideedit.php index fc8ae373c6e..744e794229a 100644 --- a/public/mod/lesson/overrideedit.php +++ b/public/mod/lesson/overrideedit.php @@ -99,6 +99,14 @@ foreach ($keys as $key) { } } +// Prepare reason editor data for existing overrides. +if (!empty($override) && isset($override->reason)) { + $data->reason_editor = [ + 'text' => $override->reason, + 'format' => $override->reasonformat ?? FORMAT_MOODLE, + ]; +} + // True if group-based override. $groupmode = !empty($data->groupid) || ($action === 'addgroup' && empty($overrideid)); @@ -130,6 +138,13 @@ if ($mform->is_cancelled()) { // Process the data. $fromform->lessonid = $lesson->id; + // Extract reason and reasonformat from editor field. + if (isset($fromform->reason_editor)) { + $fromform->reason = $fromform->reason_editor['text'] ?? ''; + $fromform->reasonformat = $fromform->reason_editor['format'] ?? FORMAT_MOODLE; + unset($fromform->reason_editor); + } + // Replace unchanged values with null. foreach ($keys as $key) { if ($fromform->{$key} == $lesson->{$key}) { diff --git a/public/mod/lesson/overrides.php b/public/mod/lesson/overrides.php index 2e81ea40491..3c515c0276a 100644 --- a/public/mod/lesson/overrides.php +++ b/public/mod/lesson/overrides.php @@ -265,6 +265,20 @@ foreach ($overrides as $override) { get_string('enabled', 'lesson') : get_string('none', 'lesson'); } + // Format reason. + if (isset($override->reason) && $override->reason !== '') { + $formattedreason = format_text( + $override->reason, + $override->reasonformat ?? FORMAT_MOODLE, + ['context' => $context], + ); + + if ($formattedreason !== '') { + $fields[] = get_string('overridereason', 'lesson'); + $values[] = $formattedreason; + } + } + // Icons. $iconstr = ''; diff --git a/public/mod/lesson/tests/backup/restore_override_test.php b/public/mod/lesson/tests/backup/restore_override_test.php index b62a40d7d91..94cd57c17b7 100644 --- a/public/mod/lesson/tests/backup/restore_override_test.php +++ b/public/mod/lesson/tests/backup/restore_override_test.php @@ -28,6 +28,7 @@ require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +#[\PHPUnit\Framework\Attributes\CoversClass(\restore_lesson_activity_structure_step::class)] final class restore_override_test extends \restore_date_testcase { /** @@ -95,4 +96,37 @@ final class restore_override_test extends \restore_date_testcase { // 1 user override. $this->assertEquals(1, count($overrides)); } + + /** + * Test restore overrides with reason. + */ + public function test_restore_overrides_with_reason(): void { + global $DB, $USER; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $lessongen = $this->getDataGenerator()->get_plugin_generator('mod_lesson'); + $lesson = $lessongen->create_instance(['course' => $course->id]); + + $useroverride = (object) [ + 'lessonid' => $lesson->id, + 'userid' => $USER->id, + 'sortorder' => 1, + 'available' => 100, + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_MOODLE, + ]; + $DB->insert_record('lesson_overrides', $useroverride); + + // Back up and restore. + $newcourseid = $this->backup_and_restore($course); + $newlesson = $DB->get_record('lesson', ['course' => $newcourseid]); + $overrides = $DB->get_records('lesson_overrides', ['lessonid' => $newlesson->id]); + + $this->assertEquals(1, count($overrides)); + $restoredoverride = reset($overrides); + $this->assertEquals($useroverride->reason, $restoredoverride->reason); + $this->assertEquals($useroverride->reasonformat, $restoredoverride->reasonformat); + } } diff --git a/public/mod/lesson/tests/behat/lesson_override_reason.feature b/public/mod/lesson/tests/behat/lesson_override_reason.feature new file mode 100644 index 00000000000..7bf13e032b7 --- /dev/null +++ b/public/mod/lesson/tests/behat/lesson_override_reason.feature @@ -0,0 +1,69 @@ +@mod @mod_lesson +Feature: Lesson override reason + In order to explain why an override was granted + As a teacher + I need to be able to add a reason to an override + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | Group 1 | C1 | G1 | + And the following "group members" exist: + | user | group | + | student1 | G1 | + And the following "activities" exist: + | activity | name | intro | course | + | lesson | Test lesson | Test lesson intro | C1 | + + @javascript + Scenario: Add a user override with a reason + Given I am on the "Test lesson" "lesson activity" page logged in as teacher1 + When I navigate to "Overrides" in current page administration + And I follow "Add user override" + And I set the following fields to these values: + | Override user | Student 1 | + | Deadline | ##1 Jan 2030 08:00## | + | Reason | Extension granted for medical reasons | + And I press "Save" + And I should see "Extension granted for medical reasons" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Student 1" "table_row" + And the field "Reason for override" matches value "Extension granted for medical reasons" + And I set the following fields to these values: + | Reason | Updated reason after review | + And I press "Save" + Then I should see "Updated reason after review" in the "Reason for override" "table_row" + + @javascript + Scenario: Add a group override with a reason + Given I am on the "Test lesson" "lesson activity" page logged in as teacher1 + When I navigate to "Overrides" in current page administration + And I select "Group overrides" from the "jump" singleselect + And I follow "Add group override" + And I set the following fields to these values: + | Override group | Group 1 | + | id_deadline_enabled | 1 | + | deadline[day] | 1 | + | deadline[month] | January | + | deadline[year] | 2030 | + | deadline[hour] | 08 | + | deadline[minute] | 00 | + | Reason | Additional time approved for this group | + And I press "Save" + And I should see "Additional time approved for this group" in the "Reason for override" "table_row" + And I click on "Edit" "link" in the "Group 1" "table_row" + And the field "Reason for override" matches value "Additional time approved for this group" + And I set the following fields to these values: + | Reason | Updated group reason after review | + And I press "Save" + Then I should see "Updated group reason after review" in the "Reason for override" "table_row" diff --git a/public/mod/lesson/tests/privacy/provider_test.php b/public/mod/lesson/tests/privacy/provider_test.php index 30f70813c38..03d8ef48671 100644 --- a/public/mod/lesson/tests/privacy/provider_test.php +++ b/public/mod/lesson/tests/privacy/provider_test.php @@ -449,7 +449,9 @@ final class provider_test extends provider_testcase { 'review' => 1, 'maxattempts' => 1, 'retake' => 0, - 'password' => '1337 5p34k' + 'password' => '1337 5p34k', + 'reason' => 'This is a reason', + 'reasonformat' => FORMAT_MOODLE, ]); $this->create_override($cm1, $u2, [ 'available' => $now - 1230, @@ -480,6 +482,7 @@ final class provider_test extends provider_testcase { $this->assertEquals(1, $data->maxattempts); $this->assertEquals(transform::yesno(false), $data->retake); $this->assertEquals('1337 5p34k', $data->password); + $this->assertEquals(format_text('This is a reason', FORMAT_MOODLE, ['context' => $cm2ctx]), $data->reason); writer::reset(); provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id])); diff --git a/public/mod/lesson/version.php b/public/mod/lesson/version.php index 7329a5181d0..af76c2c618a 100644 --- a/public/mod/lesson/version.php +++ b/public/mod/lesson/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026022300; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2026030600; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2025092600; // Requires this Moodle version. $plugin->component = 'mod_lesson'; // Full name of the plugin (used for diagnostics) $plugin->cron = 0;