diff --git a/mod/quiz/classes/event/slot_created.php b/mod/quiz/classes/event/slot_created.php index 5fb52b0f9cfdb..350950ef06155 100644 --- a/mod/quiz/classes/event/slot_created.php +++ b/mod/quiz/classes/event/slot_created.php @@ -24,6 +24,9 @@ namespace mod_quiz\event; +use core\exception\coding_exception; +use core\url; + /** * The mod_quiz slot created event class. * @@ -51,39 +54,62 @@ public static function get_name() { } 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 ]); } protected function validate_data() { - parent::validate_data(); + $missing = fn(string $field) => match($field) { + 'version' => !array_key_exists('version', $this->other), + default => !isset($this->other[$field]) + }; - if (!isset($this->objectid)) { - throw new \coding_exception('The \'objectid\' value must be set.'); - } - - if (!isset($this->contextinstanceid)) { - throw new \coding_exception('The \'contextinstanceid\' value must be set.'); - } + $any = fn(array $fields): bool => in_array(false, array_map($missing, $fields), true); + $all = fn(array $fields): bool => !in_array(true, array_map($missing, $fields), true); - if (!isset($this->other['quizid'])) { - throw new \coding_exception('The \'quizid\' value must be set in other.'); - } + $missingmembers = array_filter(['objectid'], fn(string $member): bool => !isset($this->$member)); + $missingfields = array_filter(['quizid', 'slotnumber', 'page'], $missing); + $mutexfields = [['questionbankentryid', 'version'], ['questionscontextid', 'filtercondition']]; + $providedmutex = array_values(array_filter($mutexfields, $any)); + $fieldsets = implode(", ", array_map(fn(array $fields): string => '(' . implode(', ', $fields) . ')', $mutexfields)); + parent::validate_data(); - if (!isset($this->other['slotnumber'])) { - throw new \coding_exception('The \'slotnumber\' value must be set in other.'); - } + $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), + ...match(true) { + count($providedmutex) !== 1 => ["Values for exactly one of these field sets must be set in 'other': $fieldsets"], + !$all($providedmutex[0]) => ['The values: ' . implode(', ', $providedmutex[0]) . ' must all be set in \'other\'.'], + default => [] + }, + ]; - if (!isset($this->other['page'])) { - throw new \coding_exception('The \'page\' value must be set in other.'); + if ($errors) { + throw new coding_exception("Errors in event data:\n\n" . implode("\n", $errors)); } } diff --git a/mod/quiz/classes/event/slot_filtercondition_updated.php b/mod/quiz/classes/event/slot_filtercondition_updated.php new file mode 100644 index 0000000000000..981908598f312 --- /dev/null +++ b/mod/quiz/classes/event/slot_filtercondition_updated.php @@ -0,0 +1,93 @@ +. + +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 + * @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; + } +} diff --git a/mod/quiz/classes/external/update_filter_condition.php b/mod/quiz/classes/external/update_filter_condition.php index 70d2c5c94bfa4..5e02bf5271721 100644 --- a/mod/quiz/classes/external/update_filter_condition.php +++ b/mod/quiz/classes/external/update_filter_condition.php @@ -22,10 +22,12 @@ require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); -use external_function_parameters; -use external_single_structure; -use external_value; -use external_api; +use core\context\module; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use core_external\external_api; +use mod_quiz\quiz_settings; /** * Update the filter condition for a random question. @@ -58,35 +60,19 @@ public static function execute_parameters(): external_function_parameters { * @param string $filtercondition * @return array result */ - public static function execute( - int $cmid, - int $slotid, - string $filtercondition, - ): array { - global $DB; + public static function execute(int $cmid, int $slotid, string $filtercondition): array { + ['cmid' => $cmid, 'slotid' => $slotid, 'filtercondition' => $filtercondition] = self::validate_parameters( + self::execute_parameters(), + ['cmid' => $cmid, 'slotid' => $slotid, 'filtercondition' => $filtercondition] + ); - [ - 'cmid' => $cmid, - 'slotid' => $slotid, - 'filtercondition' => $filtercondition, - ] = self::validate_parameters(self::execute_parameters(), [ - 'cmid' => $cmid, - 'slotid' => $slotid, - 'filtercondition' => $filtercondition, - ]); - - // Validate context. - $thiscontext = \context_module::instance($cmid); - self::validate_context($thiscontext); - require_capability('mod/quiz:manage', $thiscontext); + $context = module::instance($cmid); + self::validate_context($context); + require_capability('mod/quiz:manage', $context); - // Update filter condition. - $setparams = [ - 'itemid' => $slotid, - 'questionarea' => 'slot', - 'component' => 'mod_quiz', - ]; - $DB->set_field('question_set_references', 'filtercondition', $filtercondition, $setparams); + $filtercondition = json_decode($filtercondition, true); + $structure = quiz_settings::create_for_cmid($cmid)->get_structure(); + $structure->update_random_question($slotid, $filtercondition); return ['message' => get_string('updatefilterconditon_success', 'mod_quiz')]; } diff --git a/mod/quiz/classes/local/structure/slot_random.php b/mod/quiz/classes/local/structure/slot_random.php index a743f6eaaff4c..875f666179bb1 100644 --- a/mod/quiz/classes/local/structure/slot_random.php +++ b/mod/quiz/classes/local/structure/slot_random.php @@ -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. @@ -214,19 +217,50 @@ public function insert($page) { $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 + 'page' => $this->record->page, + 'questionscontextid' => $this->referencerecord->questionscontextid, + 'filtercondition' => $this->referencerecord->filtercondition, + ], + ])->trigger(); + + $trans->allow_commit(); + } + + /** + * Update the filter condition for an existing random slot. + * + * @param array $filtercondition + */ + public function update_filtercondition(array $filtercondition): void { + global $DB; + + if (!isset($this->record->id)) { + throw new coding_exception('Cannot update filtercondition without slot record ID.'); + } + + $cm = get_coursemodule_from_instance('quiz', $this->get_quiz()->id); + $transaction = $DB->start_delegated_transaction(); + $params = ['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $this->record->id]; + $DB->set_field('question_set_references', 'filtercondition', json_encode($filtercondition), $params); + 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' => json_encode($filtercondition), ] - ]); - $event->trigger(); + ])->trigger(); + $transaction->allow_commit(); } } diff --git a/mod/quiz/classes/structure.php b/mod/quiz/classes/structure.php index 15a72d372d145..62f577acbd94b 100644 --- a/mod/quiz/classes/structure.php +++ b/mod/quiz/classes/structure.php @@ -18,12 +18,17 @@ use coding_exception; use context_module; +use core\context; +use core\context\module; +use core\exception\invalid_parameter_exception; +use core\exception\moodle_exception; use core\output\inplace_editable; use mod_quiz\event\quiz_grade_item_created; use mod_quiz\event\quiz_grade_item_deleted; use mod_quiz\event\quiz_grade_item_updated; use mod_quiz\event\slot_grade_item_updated; use mod_quiz\event\slot_mark_updated; +use mod_quiz\local\structure\slot_random; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\qubaids_for_quiz; use stdClass; @@ -1616,34 +1621,78 @@ public function get_slot_tags_for_slot_id() { * @param array $filtercondition the filter condition. Must contain at least a category filter. */ public function add_random_questions(int $addonpage, int $number, array $filtercondition): void { + $category = $this->validate_filtercondition($filtercondition); + for ($i = 0; $i < $number; $i++) { + $randomslot = $this->get_randomslot($category->contextid); + $randomslot->set_filter_condition(json_encode($filtercondition)); + $randomslot->insert($addonpage); + } + } + + /** + * Update an existing random question. + * + * @param int $slotid + * @param array $filtercondition + */ + public function update_random_question(int $slotid, array $filtercondition): void { + $category = $this->validate_filtercondition($filtercondition); + $randomslot = $this->get_randomslot($category->contextid, $slotid); + $randomslot->update_filtercondition($filtercondition); + } + + /** + * Validate a given filter condition array. Specifically check for + * the prescence of a category and the relevant permission to use + * the category. Returns the category. + * + * @param array $filtercondition + * @return stdClass The row from the question_categories table. + * @throws invalid_parameter_exception When the filter condition is + * missing a category. + * @throws moodle_exception When the specified category doesn't exist. + */ + private function validate_filtercondition(array $filtercondition): stdClass { global $DB; if (!isset($filtercondition['filter']['category'])) { - throw new \invalid_parameter_exception('$filtercondition must contain at least a category filter.'); + throw new invalid_parameter_exception('$filtercondition must contain at least a category filter.'); } $categoryid = $filtercondition['filter']['category']['values'][0]; $category = $DB->get_record('question_categories', ['id' => $categoryid]); if (!$category) { - new \moodle_exception('invalidcategoryid'); + new moodle_exception('invalidcategoryid'); } - $catcontext = \context::instance_by_id($category->contextid); + $catcontext = context::instance_by_id($category->contextid); require_capability('moodle/question:useall', $catcontext); - // Create the selected number of random questions. - for ($i = 0; $i < $number; $i++) { - // Slot data. - $randomslotdata = new stdClass(); - $randomslotdata->quizid = $this->get_quizid(); - $randomslotdata->usingcontextid = context_module::instance($this->get_cmid())->id; - $randomslotdata->questionscontextid = $category->contextid; - $randomslotdata->maxmark = 1; - - $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); - $randomslot->set_quiz($this->get_quiz()); - $randomslot->set_filter_condition(json_encode($filtercondition)); - $randomslot->insert($addonpage); - } + return $category; + } + + /** + * Get a random slot. If the slot ID parameter is not provided + * a new random slot will be created. + * + * @param int $categorycontextid + * @param int|null $slotid + * @return slot_random + */ + private function get_randomslot(int $categorycontextid, ?int $slotid = null): slot_random { + $randomslotdata = match($slotid) { + null => (object)[ + 'quizid' => $this->get_quizid(), + 'usingcontextid' => module::instance($this->get_cmid())->id, + 'maxmark' => 1, + ], + default => $this->get_slot_by_id($slotid) + }; + + $randomslotdata->questionscontextid = $categorycontextid; + $randomslot = new slot_random($randomslotdata); + $randomslot->set_quiz($this->get_quiz()); + + return $randomslot; } } diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 25d56edff470e..eb10cd28d895a 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -388,6 +388,7 @@ $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['eventslotmoved'] = 'Slot moved'; diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 41ed9e9ddf041..7da1222a11376 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -1846,8 +1846,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([ @@ -1856,10 +1854,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(); } /** diff --git a/mod/quiz/tests/event/events_test.php b/mod/quiz/tests/event/events_test.php index a13a48dbc3919..41a42a3fc27fc 100644 --- a/mod/quiz/tests/event/events_test.php +++ b/mod/quiz/tests/event/events_test.php @@ -1122,8 +1122,10 @@ public function test_slot_created(): void { 'other' => [ 'quizid' => $quizobj->get_quizid(), 'slotnumber' => 1, - 'page' => 1 - ] + 'page' => 1, + 'questionbankentryid' => 1, + 'version' => null, + ], ]; $event = \mod_quiz\event\slot_created::create($params); diff --git a/mod/quiz/version.php b/mod/quiz/version.php index fa22a93ba4087..b28da34b96368 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024051700; +$plugin->version = 2024080700; $plugin->requires = 2024041600; $plugin->component = 'mod_quiz';