MDL-82639 mod_quiz: Improve events for random questions

This commit is contained in:
Cameron Ball
2025-12-13 11:58:48 +08:00
parent d4c1bf260a
commit 1c293fec24
8 changed files with 283 additions and 35 deletions
+68 -10
View File
@@ -24,6 +24,9 @@
namespace mod_quiz\event;
use core\exception\coding_exception;
use core\url;
/**
* The mod_quiz slot created event class.
*
@@ -51,14 +54,30 @@ class slot_created extends \core\event\base {
}
public function get_description() {
return "The user with id '$this->userid' created a new slot with id '{$this->objectid}' " .
"and slot number '{$this->other['slotnumber']}' " .
"on page '{$this->other['page']}' " .
"of the quiz with course module id '$this->contextinstanceid'.";
if (isset($this->other['questionbankentryid'])) {
$version = $this->other['version'] ?? 'Always latest';
return "The user with id '$this->userid' created a new slot with " .
"id '{$this->objectid}', " .
"slot number '{$this->other['slotnumber']}', and " .
"question bank entry id '{$this->other['questionbankentryid']}' (version '$version') " .
"on page '{$this->other['page']}' " .
"of the quiz with course module id '$this->contextinstanceid'.";
}
if (isset($this->other['questionscontextid'])) {
return "The user with id '$this->userid' created a new slot using question references with " .
"id '{$this->objectid}', " .
"slot number '{$this->other['slotnumber']}', " .
"question context '{$this->other['questionscontextid']}', and " .
"filter condition '{$this->other['filtercondition']}' " .
"on page '{$this->other['page']}' " .
"of the quiz with course module id '$this->contextinstanceid'.";
}
}
public function get_url() {
return new \moodle_url('/mod/quiz/edit.php', [
return new url('/mod/quiz/edit.php', [
'cmid' => $this->contextinstanceid
]);
}
@@ -66,24 +85,63 @@ class slot_created extends \core\event\base {
protected function validate_data() {
parent::validate_data();
$errors = [];
if (!isset($this->objectid)) {
throw new \coding_exception('The \'objectid\' value must be set.');
$errors[] = "The 'objectid' value must be set.";
}
if (!isset($this->contextinstanceid)) {
throw new \coding_exception('The \'contextinstanceid\' value must be set.');
$errors[] = "The 'contextinstanceid' value must be set.";
}
if (!isset($this->other['quizid'])) {
throw new \coding_exception('The \'quizid\' value must be set in other.');
$errors[] = "The 'quizid' value must be set in other.";
}
if (!isset($this->other['slotnumber'])) {
throw new \coding_exception('The \'slotnumber\' value must be set in other.');
$errors[] = "The 'slotnumber' value must be set in other.";
}
if (!isset($this->other['page'])) {
throw new \coding_exception('The \'page\' value must be set in other.');
$errors[] = "The 'page' value must be set in other.";
}
$questionbankfields = ['questionbankentryid', 'version'];
$referencefields = ['questionscontextid', 'filtercondition'];
$questionbankset = isset($this->other['questionbankentryid']) || array_key_exists('version', $this->other);
$referenceset = isset($this->other['questionscontextid']) || isset($this->other['filtercondition']);
if ($questionbankset && $referenceset) {
$errors[] = "Values for exactly one of these field sets must be set in 'other': " .
'(' . implode(', ', $questionbankfields) . '), ' .
'(' . implode(', ', $referencefields) . ')';
} else if (!$questionbankset && !$referenceset) {
$errors[] = "Values for exactly one of these field sets must be set in 'other': " .
'(' . implode(', ', $questionbankfields) . '), ' .
'(' . implode(', ', $referencefields) . ')';
}
if ($questionbankset) {
if (!isset($this->other['questionbankentryid'])) {
$errors[] = "The 'questionbankentryid' value must be set in other.";
}
if (!array_key_exists('version', $this->other)) {
$errors[] = "The 'version' value must be set in other.";
}
}
if ($referenceset) {
if (!isset($this->other['questionscontextid'])) {
$errors[] = "The 'questionscontextid' value must be set in other.";
}
if (!isset($this->other['filtercondition'])) {
$errors[] = "The 'filtercondition' value must be set in other.";
}
}
if ($errors) {
throw new coding_exception("Errors in event data:\n\n" . implode("\n", $errors));
}
}
@@ -0,0 +1,92 @@
<?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/>.
declare(strict_types=1);
namespace mod_quiz\event;
use core\event\base;
use core\exception\coding_exception;
use core\url;
/**
* This event is fired when the filter condition of a slot
* using the question set references table is updated.
*
* @package mod_quiz
* @copyright 2024 Catalyst IT Australia Pty Ltd
* @author Cameron Ball <[email protected]>
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class slot_filtercondition_updated extends base {
#[\Override]
protected function init() {
$this->data['objecttable'] = 'quiz_slots';
$this->data['crud'] = 'c';
$this->data['edulevel'] = self::LEVEL_TEACHING;
}
#[\Override]
public static function get_name() {
return get_string('eventslotfilterconditionupdated', 'mod_quiz');
}
#[\Override]
public function get_description() {
return "The user with id '$this->userid' updated a slot using question references with " .
"id '{$this->objectid}' and " .
"slot number '{$this->other['slotnumber']}' " .
"on page '{$this->other['page']}' " .
"of the quiz with course module id '$this->contextinstanceid' " .
"to use question context '{$this->other['questionscontextid']}' and " .
"filter condition '{$this->other['filtercondition']}'.";
}
#[\Override]
public function get_url() {
return new url('/mod/quiz/edit.php', ['cmid' => $this->contextinstanceid]);
}
#[\Override]
protected function validate_data() {
$missing = fn(string $field) => !isset($this->other[$field]);
$missingmembers = array_filter(['objectid'], fn(string $member): bool => !isset($this->$member));
$missingfields = array_filter(['quizid', 'slotnumber', 'page', 'questionscontextid', 'filtercondition'], $missing);
parent::validate_data();
$errors = [
...array_map(fn(string $member): string => "The '$member' value must be set.", $missingmembers),
...array_map(fn(string $field): string => "The '$field' value must be set in other.", $missingfields),
];
if ($errors) {
throw new coding_exception("Errors in event data:\n\n" . implode("\n", $errors));
}
}
#[\Override]
public static function get_objectid_mapping() {
return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance'];
}
#[\Override]
public static function get_other_mapping() {
$othermapped = [];
$othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz'];
return $othermapped;
}
}
@@ -16,7 +16,10 @@
namespace mod_quiz\local\structure;
use context_module;
use core\context\module;
use core\exception\coding_exception;
use mod_quiz\event\slot_created;
use mod_quiz\event\slot_filtercondition_updated;
/**
* Class slot_random, represents a random question slot type.
@@ -196,20 +199,21 @@ class slot_random {
$this->referencerecord->filtercondition = $this->filtercondition;
$DB->insert_record('question_set_references', $this->referencerecord);
$trans->allow_commit();
// Log slot created event.
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
$event = \mod_quiz\event\slot_created::create([
'context' => context_module::instance($cm->id),
slot_created::create([
'context' => module::instance($cm->id),
'objectid' => $this->record->id,
'other' => [
'quizid' => $quiz->id,
'slotnumber' => $this->record->slot,
'page' => $this->record->page
]
]);
$event->trigger();
'page' => $this->record->page,
'questionscontextid' => $this->referencerecord->questionscontextid,
'filtercondition' => $this->referencerecord->filtercondition,
],
])->trigger();
$trans->allow_commit();
}
/**
@@ -221,20 +225,35 @@ class slot_random {
global $DB;
if (!isset($this->record->id)) {
throw new \coding_exception('Cannot update filtercondition without slot record ID.');
throw new coding_exception('Cannot update filtercondition without slot record ID.');
}
$setreferenceid = $DB->get_field('question_set_references', 'id', [
$cm = get_coursemodule_from_instance('quiz', $this->get_quiz()->id);
$transaction = $DB->start_delegated_transaction();
$filterconditionjson = json_encode($filtercondition);
$params = [
'component' => 'mod_quiz',
'questionarea' => 'slot',
'itemid' => $this->record->id,
], MUST_EXIST);
$update = (object)[
'id' => $setreferenceid,
'filtercondition' => json_encode($filtercondition),
'questionscontextid' => $this->referencerecord->questionscontextid,
'usingcontextid' => $this->referencerecord->usingcontextid ?? module::instance($cm->id)->id,
];
$DB->update_record('question_set_references', $update);
$setreferenceid = $DB->get_field('question_set_references', 'id', $params, MUST_EXIST);
$DB->update_record('question_set_references', (object)[
'id' => $setreferenceid,
'filtercondition' => $filterconditionjson,
'questionscontextid' => $this->referencerecord->questionscontextid,
]);
slot_filtercondition_updated::create([
'context' => module::instance($cm->id),
'objectid' => $this->record->id,
'other' => [
'quizid' => $this->get_quiz()->id,
'slotnumber' => $this->record->slot,
'page' => $this->record->page,
'questionscontextid' => $this->referencerecord->questionscontextid,
'filtercondition' => $filterconditionjson,
],
])->trigger();
$transaction->allow_commit();
}
}
+1
View File
@@ -395,6 +395,7 @@ $string['eventsectionshuffleupdated'] = 'Section shuffle updated';
$string['eventslotcreated'] = 'Slot created';
$string['eventslotdeleted'] = 'Slot deleted';
$string['eventslotdisplayedquestionnumberupdated'] = 'Slot displayed question number updated';
$string['eventslotfilterconditionupdated'] = 'Slot filter condition updated';
$string['eventslotgradeitemupdated'] = 'Slot grade item updated';
$string['eventslotmarkupdated'] = 'Slot mark updated';
$string['eventslotversionupdated'] = 'Slot version updated';
+5 -3
View File
@@ -1885,8 +1885,6 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
$DB->insert_record('question_references', $questionreferences);
}
$trans->allow_commit();
// Log slot created event.
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
$event = \mod_quiz\event\slot_created::create([
@@ -1895,10 +1893,14 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
'other' => [
'quizid' => $quiz->id,
'slotnumber' => $slot->slot,
'page' => $slot->page
'page' => $slot->page,
'questionbankentryid' => $questionreferences->questionbankentryid,
'version' => $questionreferences->version,
]
]);
$event->trigger();
$trans->allow_commit();
}
/**
+60 -2
View File
@@ -28,6 +28,7 @@ namespace mod_quiz\event;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
use context_module;
use core_question\local\bank\condition;
use mod_quiz\external\submit_question_version;
/**
@@ -1158,8 +1159,10 @@ final class events_test extends \advanced_testcase {
'other' => [
'quizid' => $quizobj->get_quizid(),
'slotnumber' => 1,
'page' => 1
]
'page' => 1,
'questionbankentryid' => 1,
'version' => null,
],
];
$event = \mod_quiz\event\slot_created::create($params);
@@ -1175,6 +1178,61 @@ final class events_test extends \advanced_testcase {
$this->assertEventContextNotUsed($event);
}
/**
* Verify slot_created carries question reference information when a specific question is added.
*/
public function test_slot_created_for_question_reference(): void {
$quizobj = $this->prepare_quiz();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('shortanswer', null, ['category' => $category->id]);
$sink = $this->redirectEvents();
quiz_add_quiz_question($question->id, $quizobj->get_quiz());
$events = array_filter($sink->get_events(), fn(\core\event\base $event): bool => $event instanceof slot_created);
$this->assertNotEmpty($events);
$event = reset($events);
$this->assertArrayHasKey('questionbankentryid', $event->other);
$this->assertArrayHasKey('version', $event->other);
$this->assertArrayNotHasKey('questionscontextid', $event->other);
$this->assertArrayNotHasKey('filtercondition', $event->other);
}
/**
* Verify slot_created carries question set reference information for random slots.
*/
public function test_slot_created_for_random_question(): void {
$quizobj = $this->prepare_quiz();
$this->setAdminUser();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $questiongenerator->create_question_category();
$questiongenerator->create_question('shortanswer', null, ['category' => $category->id]);
$questiongenerator->create_question('shortanswer', null, ['category' => $category->id]);
$filtercondition = [
'filter' => [
'category' => [
'jointype' => condition::JOINTYPE_DEFAULT,
'values' => [$category->id],
'filteroptions' => ['includesubcategories' => false],
],
],
];
$sink = $this->redirectEvents();
$quizobj->get_structure()->add_random_questions(1, 1, $filtercondition);
$events = array_filter($sink->get_events(), fn(\core\event\base $event): bool => $event instanceof slot_created);
$this->assertNotEmpty($events);
$event = reset($events);
$this->assertArrayHasKey('questionscontextid', $event->other);
$this->assertArrayHasKey('filtercondition', $event->other);
$this->assertArrayNotHasKey('questionbankentryid', $event->other);
$this->assertArrayNotHasKey('version', $event->other);
$this->assertEquals(json_encode($filtercondition), $event->other['filtercondition']);
}
/**
* Test the slot deleted event.
*
@@ -18,6 +18,7 @@ namespace mod_quiz\external;
use advanced_testcase;
use core_question\local\bank\condition;
use mod_quiz\event\slot_filtercondition_updated;
use mod_quiz\quiz_settings;
/**
@@ -115,7 +116,24 @@ final class update_filter_condition_test extends advanced_testcase {
// Call the webservice execute method.
$filtercondition['filter']['category']['values'] = [$questioncategory2->id];
update_filter_condition::execute($cm->id, $qsetref->itemid, json_encode($filtercondition));
$slot = $DB->get_record('quiz_slots', ['id' => $qsetref->itemid], '*', MUST_EXIST);
$filterjson = json_encode($filtercondition);
$sink = $this->redirectEvents();
update_filter_condition::execute($cm->id, $qsetref->itemid, $filterjson);
$events = array_filter(
$sink->get_events(),
fn(\core\event\base $event): bool => $event instanceof slot_filtercondition_updated
);
$this->assertCount(1, $events);
$event = reset($events);
$this->assertEquals($slot->id, $event->objectid);
$this->assertEquals($quizcontext, $event->get_context());
$this->assertEquals($slot->slot, $event->other['slotnumber']);
$this->assertEquals($slot->page, $event->other['page']);
$this->assertEquals($questioncategory2->contextid, $event->other['questionscontextid']);
$this->assertEquals($filterjson, $event->other['filtercondition']);
// Check that the questionscontextid value has been updated.
$qsetref = $DB->get_record('question_set_references', [
+1 -1
View File
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2025100600;
$plugin->version = 2025121300;
$plugin->requires = 2025092600;
$plugin->component = 'mod_quiz';