Merge branch 'MDL-86378-502' of https://github.com/catalyst/moodle
This commit is contained in:
@@ -176,8 +176,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);
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
@@ -701,6 +702,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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<XMLDB PATH="mod/assign/db" VERSION="20250414" COMMENT="XMLDB file for Moodle mod/assign"
|
||||
<XMLDB PATH="mod/assign/db" VERSION="20251126" COMMENT="XMLDB file for Moodle mod/assign"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
|
||||
>
|
||||
@@ -162,6 +162,8 @@
|
||||
<FIELD NAME="duedate" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Time by which students must have completed their attempt. Can be null, in which case the assign default is used."/>
|
||||
<FIELD NAME="cutoffdate" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Time by which students must have completed their attempt. Can be null, in which case the assign default is used."/>
|
||||
<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="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"/>
|
||||
|
||||
@@ -217,5 +217,27 @@ function xmldb_assign_upgrade($oldversion) {
|
||||
upgrade_mod_savepoint(true, 2026030900, 'assign');
|
||||
}
|
||||
|
||||
if ($oldversion < 2026031300) {
|
||||
// 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, 2026031300, 'assign');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -507,6 +507,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';
|
||||
@@ -538,6 +540,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';
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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_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 <[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_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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -4205,6 +4205,8 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
|
||||
'duedate' => 2,
|
||||
'cutoffdate' => 3,
|
||||
'timelimit' => null,
|
||||
'reason' => null,
|
||||
'reasonformat' => 0,
|
||||
],
|
||||
(object) [
|
||||
// Override for group 2, lower priority (numerically higher sortorder).
|
||||
@@ -4216,6 +4218,8 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
|
||||
'duedate' => 6,
|
||||
'cutoffdate' => 6,
|
||||
'timelimit' => null,
|
||||
'reason' => null,
|
||||
'reasonformat' => 0,
|
||||
],
|
||||
(object) [
|
||||
// User override.
|
||||
@@ -4227,6 +4231,8 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
|
||||
'duedate' => 8,
|
||||
'cutoffdate' => 9,
|
||||
'timelimit' => null,
|
||||
'reason' => null,
|
||||
'reasonformat' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -229,6 +229,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';
|
||||
@@ -301,6 +303,10 @@ final class provider_test extends provider_testcase {
|
||||
$this->assertEquals($user->id, $writer->get_data(['Marker allocations'])->data[0]['student']);
|
||||
$this->assertEquals($teacher->id, $writer->get_data(['Marks'])->data[0]['marker']);
|
||||
$this->assertEquals(99.9, $writer->get_data(['Marks'])->data[0]['mark']);
|
||||
$this->assertEquals(
|
||||
format_text($overridedata->reason, $overridedata->reasonformat, ['context' => $context]),
|
||||
$overrideexport->reason
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics).
|
||||
$plugin->version = 2026030900; // The current module version (Date: YYYYMMDDXX).
|
||||
$plugin->version = 2026031300; // The current module version (Date: YYYYMMDDXX).
|
||||
$plugin->requires = 2025092600; // Requires this Moodle version.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<XMLDB PATH="mod/lesson/db" VERSION="20250414" COMMENT="XMLDB file for Moodle mod/lesson"
|
||||
<XMLDB PATH="mod/lesson/db" VERSION="20251126" COMMENT="XMLDB file for Moodle mod/lesson"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
|
||||
>
|
||||
@@ -189,6 +189,8 @@
|
||||
<FIELD NAME="maxattempts" TYPE="int" LENGTH="3" NOTNULL="false" SEQUENCE="false"/>
|
||||
<FIELD NAME="retake" TYPE="int" LENGTH="3" NOTNULL="false" SEQUENCE="false"/>
|
||||
<FIELD NAME="password" TYPE="char" LENGTH="32" NOTNULL="false" SEQUENCE="false" COMMENT="Lesson password. Can be null, in which case the lesson 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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Cache data source for the quiz overrides.
|
||||
*
|
||||
* @package mod_quiz
|
||||
* @copyright 2021 Shamim Rezaie <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace mod_quiz\cache;
|
||||
|
||||
use core_cache\data_source_interface;
|
||||
use core_cache\definition;
|
||||
|
||||
/**
|
||||
* Class quiz_overrides
|
||||
*
|
||||
* @package mod_quiz
|
||||
* @copyright 2021 Shamim Rezaie <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class overrides implements data_source_interface {
|
||||
/** @var overrides the singleton instance of this class. */
|
||||
protected static $instance = null;
|
||||
|
||||
/**
|
||||
* Returns an instance of the data source class that the cache can use for loading data using the other methods
|
||||
* specified by this interface.
|
||||
*
|
||||
* @param definition $definition
|
||||
* @return overrides
|
||||
*/
|
||||
public static function get_instance_for_cache(definition $definition): overrides {
|
||||
if (is_null(self::$instance)) {
|
||||
self::$instance = new overrides();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the data for the key provided ready formatted for caching.
|
||||
*
|
||||
* @param string|int $key The key to load.
|
||||
* @return mixed What ever data should be returned, or false if it can't be loaded.
|
||||
* @throws \coding_exception
|
||||
*/
|
||||
public function load_for_cache($key) {
|
||||
global $DB;
|
||||
|
||||
// Ignore getting data if this is a cache invalidation - {@see \core_cache\helper::purge_by_event()}.
|
||||
if ($key == 'lastinvalidation') {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$quizid, $ug, $ugid] = explode('_', $key);
|
||||
$quizid = (int) $quizid;
|
||||
|
||||
switch ($ug) {
|
||||
case 'u':
|
||||
$userid = (int) $ugid;
|
||||
$override = $DB->get_record(
|
||||
'quiz_overrides',
|
||||
['quiz' => $quizid, 'userid' => $userid],
|
||||
'timeopen, timeclose, timelimit, attempts, password, reason, reasonformat'
|
||||
);
|
||||
break;
|
||||
case 'g':
|
||||
$groupid = (int) $ugid;
|
||||
$override = $DB->get_record(
|
||||
'quiz_overrides',
|
||||
['quiz' => $quizid, 'groupid' => $groupid],
|
||||
'timeopen, timeclose, timelimit, attempts, password, reason, reasonformat'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new \coding_exception('Invalid cache key');
|
||||
}
|
||||
|
||||
// Return null instead of false, because false will not be cached.
|
||||
return $override ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads several keys for the cache.
|
||||
*
|
||||
* @param array $keys An array of keys each of which will be string|int.
|
||||
* @return array An array of matching data items.
|
||||
*/
|
||||
public function load_many_for_cache(array $keys) {
|
||||
$results = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$results[] = $this->load_for_cache($key);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -94,7 +94,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');
|
||||
}
|
||||
|
||||
@@ -224,6 +231,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) [];
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -685,6 +685,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.';
|
||||
@@ -765,6 +767,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.';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2026022400;
|
||||
$plugin->version = 2026030600;
|
||||
$plugin->requires = 2025092600;
|
||||
$plugin->component = 'mod_quiz';
|
||||
|
||||
Reference in New Issue
Block a user