MDL-86378 mod_quiz: Add override reason field

This commit is contained in:
Alexander Van der Bellen
2026-03-12 09:47:14 +08:00
parent 6ebf41a6c2
commit b09ae54a1d
17 changed files with 500 additions and 7 deletions
@@ -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');
+2 -2
View File
@@ -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:
+3
View File
@@ -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([
+11 -1
View File
@@ -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];
}
@@ -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'));
@@ -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']));
@@ -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) [];
+3 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/quiz/db" VERSION="20250414" COMMENT="XMLDB file for Moodle mod/quiz"
<XMLDB PATH="mod/quiz/db" VERSION="20251126" COMMENT="XMLDB file for Moodle mod/quiz"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
@@ -133,6 +133,8 @@
<FIELD NAME="timelimit" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Time limit in seconds. Can be null, in which case the quiz default is used."/>
<FIELD NAME="attempts" TYPE="int" LENGTH="6" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="password" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Quiz password. Can be null, in which case the quiz default is used."/>
<FIELD NAME="reason" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="An optional reason explaining why this override was granted."/>
<FIELD NAME="reasonformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The internal format for the override reason."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+22
View File
@@ -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;
}
+3
View File
@@ -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.';
+15
View File
@@ -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);
+14
View File
@@ -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 = [];
@@ -0,0 +1,64 @@
<?php
// This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>.
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 <[email protected]>
* @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);
}
}
@@ -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"
+261
View File
@@ -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);
}
}
@@ -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));
+1 -1
View File
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2026022400;
$plugin->version = 2026030600;
$plugin->requires = 2025092600;
$plugin->component = 'mod_quiz';