ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç PK8B\yȍdb/install.phpnu[. /** * mod_qbank install operations to transfer question categories to new contexts. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ function xmldb_qbank_install(): void { $task = new \mod_qbank\task\transfer_question_categories(); \core\task\manager::queue_adhoc_task($task); } PK8B\c[ db/access.phpnu[. /** * Capability definitions for the question bank module. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Ability to see that the question bank exists, and the basic information about it. 'mod/qbank:view' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW, ], ], // Ability to add a new question bank to the course. 'mod/qbank:addinstance' => [ 'riskbitmask' => RISK_XSS, 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW, ], 'clonepermissionsfrom' => 'moodle/course:manageactivities', ], ]; PK8B\ܑf<<db/upgrade.phpnu[. /** * Plugin upgrade steps are defined here. * * @package mod_qbank * @category upgrade * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Execute mod_qbank upgrade from the given old version. * * @param int $oldversion * @return bool */ function xmldb_qbank_upgrade($oldversion) { global $DB; $dbman = $DB->get_manager(); // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK8B\jdb/install.xmlnu[
PK8B\a lang/en/qbank.phpnu[. /** * Plugin strings are defined here. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['addanotherbank'] = 'Add question bank'; $string['coursecategory'] = 'Shared teaching resources for category: {$a}'; $string['courserestore'] = 'Course restore'; $string['createqbank'] = 'Create question bank {$a}'; $string['modulename'] = 'Question bank'; $string['modulename_help'] = 'This activity allows a teacher to create, preview, and edit questions in a database of question categories. Questions are then used in a quiz or other activity. After a question is used, version control and statistics are available.'; $string['modulenameplural'] = 'Question banks'; $string['noqbankinstances'] = 'There are no question banks in this course.'; $string['pluginadministration'] = 'Question bank administration'; $string['pluginname'] = 'Question bank'; $string['privacy:metadata'] = 'The Question bank plugin does not store any personal data, core_question automatically tracks all sorts of data for questions.'; $string['progress_createqbank'] = 'Creating question banks'; $string['qbank:addinstance'] = 'Add a new question bank'; $string['qbank:view'] = 'View question bank'; $string['qbankname'] = 'Question bank name'; $string['qbankname_help'] = 'Enter the question bank name.'; $string['saveanddisplay'] = 'Save and display'; $string['saveandreturn'] = 'Save and return to question bank list'; $string['sharedbank'] = '{$a} shared question bank'; $string['showdescription'] = 'Display description on manage question banks page'; $string['showdescription_help'] = 'If enabled, the description above will be displayed on the manage question bank page just below the link to the bank.'; $string['transfernotfinished'] = 'The adhoc tasks \\mod_qbank\\task\\transfer_question_categories and \\mod_qbank\\task\\transfer_questions are not yet complete or have failed. Questions previously created in different contexts may not be transferred to course shared question banks yet. Questions can\'t be managed or shared until these tasks are complete.'; $string['unknownbanktype'] = 'Unknown question bank type {$a}'; PK8B\C%Z Z 4backup/moodle2/restore_qbank_activity_task.class.phpnu[. defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/qbank/backup/moodle2/restore_qbank_stepslib.php'); /** * The task that provides a complete restore of mod_qbank. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qbank_activity_task extends restore_activity_task { /** * Defines particular settings that this activity can have. */ protected function define_my_settings(): void { // No particular settings for this activity. } /** * Defines particular steps that this activity can have. * * @return void. */ protected function define_my_steps(): void { // Qbank only has one structure step. $this->add_step(new restore_qbank_activity_structure_step('qbank_structure', 'qbank.xml')); } /** * Defines the contents in the activity that must be processed by the link decoder. * * @return array. */ public static function define_decode_contents(): array { return []; } /** * Defines the decoding rules for links belonging to the activity to be executed by the link decoder. * * @return restore_decode_rule[]. */ public static function define_decode_rules(): array { $rules = []; $rules[] = new restore_decode_rule('QBANKVIEWBYID', '/mod/qbank/view.php?id=$1', 'course_module'); return $rules; } /** * Defines the restore log rules that will be applied by the * {@see restore_logs_processor} when restoring mod_qbank logs. It * must return one array of {@see restore_log_rule} objects. * * @return restore_log_rule[]. */ public static function define_restore_log_rules(): array { return []; } } PK8B\LVV(backup/moodle2/backup_qbank_stepslib.phpnu[. /** * Define the complete qbank structure for backup, with file and id annotations. * * @package mod_qbank * @category backup * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qbank_activity_structure_step extends backup_activity_structure_step { /** * Define the mod_qbank activity structure. * * @return backup_nested_element */ protected function define_structure(): backup_nested_element { // Define each element separated. $qbank = new backup_nested_element( 'qbank', ['id'], [ 'name', 'timecreated', 'timemodified', 'intro', 'introformat', 'type', ], ); // Build the tree. // (No tree). // Define sources. $qbank->set_source_table('qbank', ['id' => backup::VAR_ACTIVITYID]); // Define id annotations. // (none). // Define file annotations. $qbank->annotate_files('mod_qbank', 'intro', null); // This file area does not have an itemid. // Return the root element (qbank), wrapped into standard activity structure. return $this->prepare_activity_structure($qbank); } } PK8B\Q!)backup/moodle2/restore_qbank_stepslib.phpnu[. /** * Defines the structure step to restore one mod_qbank activity. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qbank_activity_structure_step extends restore_activity_structure_step { /** * Defines the structure to be restored. * * @return restore_path_element[]. */ protected function define_structure(): array { $paths = []; $paths[] = new restore_path_element('qbank', '/activity/qbank'); // Return the paths wrapped into standard activity structure. return $this->prepare_activity_structure($paths); } /** * Processes the qbank restore data. * * @param array $data Parsed element data. */ protected function process_qbank(array $data): void { global $DB; $data['course'] = $this->get_courseid(); // Insert the record. $newitemid = $DB->insert_record('qbank', $data); // Immediately after inserting "activity" record, call this. $this->apply_activity_instance($newitemid); } /** * Defines post-execution actions. */ protected function after_execute(): void { // Add qbank related files, no need to match by itemname (just internally handled context). $this->add_related_files('mod_qbank', 'intro', null); } } PK8B\X r 3backup/moodle2/backup_qbank_activity_task.class.phpnu[. defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/qbank/backup/moodle2/backup_qbank_stepslib.php'); /** * Qbank module backup task that provides all the settings and steps to perform one complete backup of the activity. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qbank_activity_task extends backup_activity_task { /** * Define (add) particular settings this activity can have */ protected function define_my_settings() { // No particular settings for this activity. } /** * Define (add) particular steps this activity can have */ protected function define_my_steps() { $this->add_step(new backup_qbank_activity_structure_step('qbank_structure', 'qbank.xml')); $this->add_step(new backup_calculate_question_categories('qbank_activity_question_categories')); $this->add_step(new backup_delete_temp_questions('clean_temp_questions')); } /** * Code the transformations to perform in the activity in * order to get transportable (encoded) links * * @param string $content some HTML text that eventually contains URLs to the activity instance scripts * @return string encoded content */ public static function encode_content_links($content) { global $CFG; $base = preg_quote($CFG->wwwroot, '/'); // Link to qbank view by moduleid. $search = "/(".$base."\/mod\/qbank\/view.php\?id\=)([0-9]+)/"; return preg_replace($search, '$@QBANKVIEWBYID*$2@$', $content); } } PK8B\view.phpnu[. /** * This page should show the bank itself, but we can't move that code here as other modules rely on /question/edit.php. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../config.php'); require_once($CFG->libdir . '/questionlib.php'); require_login(); $context = context_module::instance(required_param('id', PARAM_INT)); redirect(question_edit_url($context)); PK8B\)npix/monologo.svgnu[ PK8B\ lib.phpnu[. /** * Library of interface functions and constants. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Return if the plugin supports $feature. * * @param string $feature Constant representing the feature. * @return bool|string|null True if module supports feature, false if not, null if it doesn't know or string for the module purpose. */ function qbank_supports(string $feature) { switch ($feature) { case FEATURE_BACKUP_MOODLE2: case FEATURE_PUBLISHES_QUESTIONS: case FEATURE_SHOW_DESCRIPTION: case FEATURE_USES_QUESTIONS: return true; case FEATURE_CAN_DISPLAY: case FEATURE_CAN_UNINSTALL: case FEATURE_COMMENT: case FEATURE_COMPLETION: case FEATURE_COMPLETION_HAS_RULES: case FEATURE_COMPLETION_TRACKS_VIEWS: case FEATURE_CONTROLS_GRADE_VISIBILITY: case FEATURE_GRADE_OUTCOMES: case FEATURE_MODEDIT_DEFAULT_COMPLETION: return false; case FEATURE_MOD_PURPOSE: return MOD_PURPOSE_CONTENT; default: return null; } } /** * Saves a new instance of the mod_qbank into the database. * * Given an object containing all the necessary data, * this function will create a new instance and return the id number of the instance. * * @param stdClass $moduleinstance An object from the form. * @param mod_qbank_mod_form|null $mform The form. Not used in this function. * @return int The id of the newly inserted record. */ function qbank_add_instance(stdClass $moduleinstance, ?mod_qbank_mod_form $mform): int { global $DB; $moduleinstance->timecreated = time(); return $DB->insert_record('qbank', $moduleinstance); } /** * Updates an instance of the mod_qbank in the database. * Given an object containing all the necessary data, * this function will update an existing instance with new data. * * @param stdClass $moduleinstance An object from the form in mod_form.php. * @param mod_qbank_mod_form|null $mform The form. Not used in this function. * @return bool True if successful, false otherwise. */ function qbank_update_instance(stdClass $moduleinstance, ?mod_qbank_mod_form $mform): bool { global $DB; $moduleinstance->timemodified = time(); $moduleinstance->id = $moduleinstance->instance; return $DB->update_record('qbank', $moduleinstance); } /** * Removes an instance of the mod_qbank from the database. * We don't need to do anything for questions, question_categories, or user records here * as the module deletion API cleans that up for us. * * @param int $id id of the module instance. * @return bool True if successful, false on failure. */ function qbank_delete_instance(int $id): bool { global $DB; if (!$DB->record_exists('qbank', ['id' => $id])) { return false; } $DB->delete_records('qbank', ['id' => $id]); return true; } /** * Callback for tool_generator so we can add qbanks to generated courses. * * @param tool_generator_course_backend $backend * @param testing_data_generator $generator * @param int $courseid * @param int $number * @return void * @throws coding_exception */ function qbank_course_backend_generator_create_activity( tool_generator_course_backend $backend, testing_data_generator $generator, int $courseid, int $number, ) { // Set up generator. $generator = $generator->get_plugin_generator('mod_qbank'); // Create assignments. $backend->log('createqbank', $number, true, 'mod_qbank'); for ($i = 1; $i <= $number; $i++) { $qbank = $generator->create_instance( [ 'course' => $courseid, 'name' => "Question bank course {$courseid} bank {$i}", ], [ 'section' => 0, ], ); question_get_default_category(\core\context\module::instance($qbank->cmid)->id, true); $backend->dot($i, $number); } $backend->end_log(); } PK8B\eEzii version.phpnu[. /** * Plugin version and other meta-data are defined here. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_qbank'; $plugin->version = 2025041400; $plugin->requires = 2025040800; PK8B\;classes/privacy/provider.phpnu[. namespace mod_qbank\privacy; /** * Privacy Subsystem implementation for mod_qbank. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Return lang string identifier. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK8B\94classes/event/course_module_instance_list_viewed.phpnu[. namespace mod_qbank\event; /** * The mod_qbank instance list viewed event class. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed { // No code required here as the parent class handles it all. } PK8B\p #classes/task/transfer_questions.phpnu[. namespace mod_qbank\task; use core\context; use core\task\adhoc_task; /** * Move all the question files and tags under a given question category to a new context. * * An instance of this task will be created for each category moved to a new context in * {@see transfer_question_categories}, allowing the heavy lifting of moving the data for each * question to be parallelised. * * @package mod_qbank * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class transfer_questions extends adhoc_task { /** * Find the questions in the category, move their files and tags to the new context. * * @return void */ public function execute() { global $DB, $CFG; require_once($CFG->dirroot . '/question/engine/lib.php'); $data = $this->get_custom_data(); $newcontextid = $DB->get_field('question_categories', 'contextid', ['id' => $data->categoryid]); if (!$newcontextid) { mtrace("Could not find a category record for id {$data->categoryid}. Terminating task."); return; } $newcontext = context::instance_by_id($newcontextid); $sql = "SELECT q.id, q.qtype FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $questions = $DB->get_records_sql($sql, [$data->categoryid]); $questioncount = count($questions); mtrace("Moving files and tags for {$questioncount} questions in category {$data->categoryid}."); $transaction = $DB->start_delegated_transaction(); foreach ($questions as $question) { \question_bank::get_qtype($question->qtype, false)->move_files($question->id, $data->contextid, $newcontext->id); // Purge this question from the cache. \question_bank::notify_question_edited($question->id); } question_move_question_tags_to_new_context($questions, $newcontext); $transaction->allow_commit(); } } PK8B\BB-classes/task/transfer_question_categories.phpnu[. namespace mod_qbank\task; use context_system; use core\context; use core\task\adhoc_task; use core\task\manager; use core_course_category; use core_question\local\bank\question_bank_helper; use stdClass; /** * This script transfers question categories at CONTEXT_SITE, CONTEXT_COURSE, & CONTEXT_COURSECAT to a new qbank instance * context. * * Firstly, it finds any question categories where questions are not being used and deletes them, including questions. * * Then for any remaining, if it is at course level context, it creates a mod_qbank instance taking the course name * and moves the category there including subcategories, files and tags. * * If the original question category context was at system context, then it creates a mod_qbank instance on the site course i.e. * front page and moves the category & sub categories there, along with its files and tags. * * If the original question category context was a course category context, then it creates a course in that category, * taking the category name. Then it creates a mod_qbank instance in that course and moves the category & sub categories * there, along with files and tags belonging to those categories. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class transfer_question_categories extends adhoc_task { /** * @var array a cache [ context id => question category ] of the top category in each context. * Used by get_top_category_id_for_context() to avoid repeated DB queries. * 0 is cached if this context id has no corresponding top category. */ private array $topcategorycache = []; #[\Override] public function execute(): void { global $DB, $CFG; require_once($CFG->dirroot . '/course/modlib.php'); require_once($CFG->libdir . '/questionlib.php'); $this->fix_wrong_parents(); $recordset = $DB->get_recordset('question_categories', ['parent' => 0]); foreach ($recordset as $oldtopcategory) { if (!$oldcontext = context::instance_by_id($oldtopcategory->contextid, IGNORE_MISSING)) { // That context does not exist anymore, we will treat these as if they were at site context level. $oldcontext = context_system::instance(); } $trans = $DB->start_delegated_transaction(); // Remove any unused questions if they are marked as deleted. // Also, if a category contained questions which were all unusable then delete it as well. $subcategories = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $oldtopcategory->contextid] ); // This gives us categories in parent -> child order so array_reverse it, // because we should process stale categories from the bottom up. $subcategories = array_reverse(sort_categories_by_tree($subcategories, $oldtopcategory->id)); foreach ($subcategories as $subcategory) { \qbank_managecategories\helper::question_remove_stale_questions_from_category($subcategory->id); if ($this->question_category_is_empty($subcategory->id)) { question_category_delete_safe($subcategory); } } // If the top category no longer has any subcategories, because they only contained stale questions, // delete the top category and stop here without creating a new qbank. if (!$DB->record_exists('question_categories', ['parent' => $oldtopcategory->id])) { $DB->delete_records('question_categories', ['id' => $oldtopcategory->id]); $trans->allow_commit(); continue; } // We don't want to transfer any categories at valid contexts i.e. quiz modules. if ($oldcontext->contextlevel === CONTEXT_MODULE) { $trans->allow_commit(); continue; } // Category is in use so let's process it. Firstly, a course and mod instance is needed. switch ($oldcontext->contextlevel) { case CONTEXT_SYSTEM: $course = get_site(); $bankname = question_bank_helper::get_bank_name_string('systembank', 'question'); break; case CONTEXT_COURSECAT: $coursecategory = core_course_category::get($oldcontext->instanceid); $courseshortname = "$coursecategory->name-$coursecategory->id"; $course = $this->create_course($coursecategory, $courseshortname); $bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $coursecategory->name); break; case CONTEXT_COURSE: $course = get_course($oldcontext->instanceid); $bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $course->shortname); break; default: // This shouldn't be possible, so we can't really transfer it. // We should commit any pre-transfer category cleanup though. $trans->allow_commit(); continue 2; } if (!$newmod = question_bank_helper::get_default_open_instance_system_type($course)) { $newmod = question_bank_helper::create_default_open_instance($course, $bankname, question_bank_helper::TYPE_SYSTEM); } // We have our new mod instance, now move all the subcategories of the old 'top' category to this new context. $movedcategories = $this->move_question_category($oldtopcategory, $newmod->context); // Create a set of new tasks to update the questions in each category to the new contexts. // The category itself is already in the new context. We record the old context // so we know where to move files and tags from. foreach ($movedcategories as $categoryid) { $task = new transfer_questions(); $task->set_custom_data(['categoryid' => $categoryid, 'contextid' => $oldtopcategory->contextid]); manager::queue_adhoc_task($task); } // Job done, lets delete the old 'top' category. $DB->delete_records('question_categories', ['id' => $oldtopcategory->id]); $trans->allow_commit(); } $recordset->close(); } /** * Wrapper for \create_course. * * @param core_course_category $coursecategory * @param string $shortname * @return stdClass */ protected function create_course(core_course_category $coursecategory, string $shortname): stdClass { $data = (object) [ 'enablecompletion' => 0, 'fullname' => get_string('coursecategory', 'mod_qbank', $coursecategory->name), 'shortname' => $shortname, 'category' => $coursecategory->id, ]; return create_course($data); } /** * Create a new 'Top' category in our new context and move the old categories descendents beneath it. * * @param stdClass $oldtopcategory The old 'Top' category that we are moving. * @param context\module $newcontext The context we are moving our category to. * @return int[] The IDs of all categories moved to the new context. */ protected function move_question_category(stdClass $oldtopcategory, context\module $newcontext): array { global $DB; $newtopcategory = question_get_top_category($newcontext->id, true); move_question_set_references($oldtopcategory->id, $newtopcategory->id, $oldtopcategory->contextid, $newcontext->id, true); // This function moves subcategories, so we have to start at the top. $movedcategories = $this->move_subcategories_to_context($oldtopcategory->id, $newcontext); // Move the parent from the old top category to the new one. $DB->set_field('question_categories', 'parent', $newtopcategory->id, ['parent' => $oldtopcategory->id]); return $movedcategories; } /** * Recursively update the contextid for all subcategories of the given category. * * @param int $categoryid The ID of the category to update subcategories for. When calling directly, * this should be a top category. * @param context\module $newcontext The new context for the subcategories. * @return int[] The IDs of all categories moved to the new context. */ protected function move_subcategories_to_context(int $categoryid, context\module $newcontext): array { global $DB; $movedcategories = []; $subcatids = $DB->get_records('question_categories', ['parent' => $categoryid]); foreach ($subcatids as $subcatid => $data) { // Because of the fallback above, where categories pointing to a // missing contextid are all moved to the new shared system-level // question bank, some categories are moved from previously // separate contextids to the same context. This can violate // unique indexes, so we fix this by ensuring uniqueness. // For the stamp, we just generate a new stamp if required. if ($DB->record_exists('question_categories', ['stamp' => $data->stamp, 'contextid' => $newcontext->id])) { $data->stamp = make_unique_id_code(); } // The idnumber we just reset duplicates to null, as is done in other places. if ( $data->idnumber !== null && $DB->record_exists('question_categories', ['idnumber' => $data->idnumber, 'contextid' => $newcontext->id]) ) { $data->idnumber = null; } // Update the contextid and save the category. $data->contextid = $newcontext->id; $DB->update_record('question_categories', $data); $movedcategories[] = $subcatid; $movedcategories = array_merge( $this->move_subcategories_to_context($subcatid, $newcontext), $movedcategories, ); } return $movedcategories; } /** * Find the Top category for a context, if there is one. * * @param int $contextid the id of a context (which might not exist). * @return int a Top category id, or 0 if none is found. */ protected function get_top_category_id_for_context(int $contextid): int { global $DB; // Use the cache if we have already loaded this. if (array_key_exists($contextid, $this->topcategorycache)) { return $this->topcategorycache[$contextid]; } $topcategoryid = (int) $DB->get_field('question_categories', 'id', ['contextid' => $contextid, 'parent' => 0]); $this->topcategorycache[$contextid] = $topcategoryid; return $topcategoryid; } /** * Fix the context of child categories whose contextid does not match that of their parents. * * Fix here means: * * - if the child category's context exists, and has a 'Top' category, we move the child * category to be just under that Top category. That is where they would have appeared * before, e.g. in the return from question_categorylist(). * * - if the child category points to a context that does not exist at all, then we * instead change its context to be the same as it's parent's context. This may * break things like images in the question text of questions there, but there is * no real alternative. * * This is necessary because, due to old bugs, for example in backup and restoree code, * we know there can be question categories in the databases of old Moodle sites with * the wrong context id. */ public function fix_wrong_parents(): void { global $DB; $categoriestofix = $this->get_categories_in_a_different_context_to_their_parent(); foreach ($categoriestofix as $childcategoryid => $childcontextid) { $topcategoryid = $this->get_top_category_id_for_context($childcontextid); if ($topcategoryid) { // Suitable Top category in the child's current context, so move to be a parent of that. $DB->set_field('question_categories', 'parent', $topcategoryid, ['id' => $childcategoryid]); } else { // Top not found. Change the child to have the same context as its parent. // This is not efficient in DB queries, but we expect this to be a rare case, and this is simple and right. $childcategory = $DB->get_record('question_categories', ['id' => $childcategoryid]); $parentcontextid = $DB->get_field('question_categories', 'contextid', ['id' => $childcategory->parent]); $this->move_category_and_its_children($childcategoryid, $parentcontextid); } } } /** * Get question categories that are in a different context to their parent. * * @return int[] child category id => context id of the child category. */ public function get_categories_in_a_different_context_to_their_parent(): array { global $DB; return $DB->get_records_sql_menu(' SELECT c.id, c.contextid FROM {question_categories} c JOIN {question_categories} p ON p.id = c.parent WHERE p.contextid <> c.contextid ORDER BY c.id '); } /** * Set the contextid of category $categoryid and all its children to $newcontextid. * * We may need to modify the category before moving it to avoid unique key violations {@see move_subcategories_to_context()}. * * @param int $categoryid a question_category id. * @param int $newcontextid the place to move to. */ public function move_category_and_its_children(int $categoryid, int $newcontextid): void { global $DB; $category = $DB->get_record('question_categories', ['id' => $categoryid]); // Check for fields that are part of a unique key for conflicts with existing records. if ($DB->record_exists('question_categories', ['contextid' => $newcontextid, 'stamp' => $category->stamp])) { $category->stamp = make_unique_id_code(); } if ($DB->record_exists('question_categories', ['contextid' => $newcontextid, 'idnumber' => $category->idnumber])) { $category->idnumber = null; } $category->contextid = $newcontextid; $DB->update_record('question_categories', $category); $children = $DB->get_records('question_categories', ['parent' => $categoryid], '', 'id, contextid'); foreach ($children as $child) { if ($child->contextid != $newcontextid) { $this->move_category_and_its_children($child->id, $newcontextid); } } } /** * Recursively check if a question category or its children contain any questions. * * @param int $categoryid The parent category to check from. * @return bool True if neither the category nor its children contain any questions. */ protected function question_category_is_empty(int $categoryid): bool { global $DB; if ($DB->record_exists('question_bank_entries', ['questioncategoryid' => $categoryid])) { return false; } // If this category is empty, recursively check child categories. $childcategoryids = $DB->get_fieldset('question_categories', 'id', ['parent' => $categoryid]); foreach ($childcategoryids as $childcategoryid) { if (!$this->question_category_is_empty($childcategoryid)) { // If we found questions in a child, we don't want to check any other children. return false; } } return true; } } PK8B\1J88 index.phpnu[. /** * This redirect to /question/banks.php to list all the instances of mod_qbank in a particular course. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once("../../config.php"); $id = required_param('id', PARAM_INT); $course = get_course($id); require_login($course); // Redirect to /question/banks.php as that page shows all the available banks on the course. redirect(new moodle_url('/question/banks.php', ['courseid' => $course->id])); PK8B\S 8tests/fixtures/testable_transfer_question_categories.phpnu[. namespace mod_qbank\task; use core\context\module; use core\exception\moodle_exception; /** * Testable version of the transfer_question_categories class. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_transfer_question_categories extends transfer_question_categories { /** @var int tracks the number of test transfers. */ private int $testcounter = 0; /** * Summary of move_question_category * @param \stdClass $oldtopcategory * @param module $newcontext * @return void */ #[\Override] protected function move_question_category(\stdClass $oldtopcategory, module $newcontext): array { if ($this->testcounter >= 1) { // We simulate a failure after successfully transferring two question categories // and creating two corresponding transfer_questions tasks. throw new moodle_exception('This is a mocked exception for testing purposes.'); } $this->testcounter++; return parent::move_question_category($oldtopcategory, $newcontext); } } PK8B\єK)K)tests/backup/restore_test.phpnu[. namespace mod_qbank\backup; use core\context\module; /** * Tests to cover restoring or import a question bank and its questions multiple times to the same course. * * When we restore/import/duplicate an activity that publishes questions, all of its questions should * be restored into the restored activity, even if they already exist in another activity on the same course. * * @package mod_qbank * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \restore_dbops::load_questionbanks_to_tempids * @covers \restore_questionbanks_parser_processor */ final class restore_test extends \advanced_testcase { /** * Given a context, find the default question category, return the question IDs and records. * * @param int $contextid * @return array[int[], \stdClass[]] An array of the question ID, and an numerically-indexed array of question records. */ protected function get_questions_in_default_category(int $contextid): array { global $DB; $records = $DB->get_records_sql( " SELECT q.* FROM {question} q JOIN {question_versions} qv ON q.id = qv.questionid JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = :contextid AND qc.name != :top ", [ 'contextid' => $contextid, 'top' => 'top', ], ); return [array_keys($records), array_values($records)]; } /** * Create a qbank containing questions and verify the correct records exist. * * @param int $courseid * @return array */ protected function create_qbank_with_questions(int $courseid): array { // Create a quiz with questions in the first course. $qbank = $this->getDataGenerator()->get_plugin_generator('mod_qbank')->create_instance(['course' => $courseid]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = question_get_default_category($context->id); // Create a short answer question. $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Create a multi-answer question with children. $maq = $questiongenerator->create_question('multianswer', 'twosubq', ['category' => $cat->id]); // Verify that we have 1 qbank, with a default category containing 4 questions (2, plus 2 children). $qbanks = get_fast_modinfo($courseid)->get_instances_of('qbank'); $this->assertCount(1, $qbanks); $qbank1 = reset($qbanks); $qbankcontext = module::instance($qbank1->id); [, $qbankquestions] = $this->get_questions_in_default_category($qbankcontext->id); $this->assertCount(4, $qbankquestions); return [$qbank, $saq, $maq]; } /** * Importing (duplicating) a bank in the same course should give you a second bank with a copy of all the questions. */ public function test_import_qbank_into_same_course(): void { global $CFG, $USER; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); [$qbank, $originalsaq, $originalmaq] = $this->create_qbank_with_questions($course1->id); // Backup qbank. $bc = new \backup_controller( \backup::TYPE_1ACTIVITY, $qbank->cmid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new \restore_controller( $backupid, $course1->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $teacher->id, \backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that we now have 2 qbanks. $qbanks = get_fast_modinfo($course1->id)->get_instances_of('qbank'); $this->assertCount(2, $qbanks); // The first qbank should be the same as before. $qbank1 = reset($qbanks); $qbank1context = module::instance($qbank1->id); [$qbank1questionids, $qbank1questions] = $this->get_questions_in_default_category($qbank1context->id); $this->assertCount(4, $qbank1questions); $this->assertContains((int) $originalsaq->id, $qbank1questionids); $this->assertContains((int) $originalmaq->id, $qbank1questionids); // The second qbank should have its own categories and a copy of each question. $qbank2 = end($qbanks); $qbank2context = module::instance($qbank2->id); [$qbank2questionids, $qbank2questions] = $this->get_questions_in_default_category($qbank2context->id); $this->assertCount(4, $qbank2questions); foreach ($qbank2questions as $key => $qbank2question) { $this->assertNotContains((int) $qbank2question->id, $qbank1questionids); $this->assertEquals($qbank2question->questiontext, $qbank1questions[$key]->questiontext); if ($qbank2question->parent != 0) { // Check that child questions are linked to the parent in the same qbank. $this->assertNotContains((int) $qbank2question->parent, $qbank1questionids); $this->assertContains((int) $qbank2question->parent, $qbank2questionids); } } } /** * Importing a bank into a different course multiple times should copy all the questions each time. */ public function test_import_qbank_into_different_course_twice(): void { global $CFG, $USER; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $generator->enrol_user($teacher->id, $course2->id, 'editingteacher'); [$qbank, $originalsaq, $originalmaq] = $this->create_qbank_with_questions($course1->id); for ($i = 0, $j = 2; $i < $j; $i++) { // Backup qbank. $bc = new \backup_controller( \backup::TYPE_1ACTIVITY, $qbank->cmid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into another course twice. $rc = new \restore_controller( $backupid, $course2->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $teacher->id, \backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); } $qbankcontext = module::instance($qbank->cmid); [$seenquestionids] = $this->get_questions_in_default_category($qbankcontext->id); // Verify that we have 2 qbanks on the destination course, each with its own categories and questions. $qbanks = get_fast_modinfo($course2->id)->get_instances_of('qbank'); $this->assertCount(2, $qbanks); foreach ($qbanks as $qbank) { $qbankcontext = module::instance($qbank->id); [$qbankquestionids, $qbankquestions] = $this->get_questions_in_default_category($qbankcontext->id); $this->assertCount(4, $qbankquestions); // The new question bank doesn't contain the original questions. $this->assertNotContains((int) $originalsaq->id, $qbankquestionids); $this->assertNotContains((int) $originalmaq->id, $qbankquestionids); // The new question bank doesn't contain questions from any other question bank. $this->assertEmpty(array_intersect($seenquestionids, $qbankquestionids)); $seenquestionids = array_merge($seenquestionids, $qbankquestionids); // The new question bank does contain its own copy of the questions. $this->assertNotEmpty( array_filter($qbankquestions, fn($question) => $question->questiontext == $originalsaq->questiontext) ); $qbankmaq = array_filter($qbankquestions, fn($question) => $question->questiontext == $originalmaq->questiontext); $qbankmaq = reset($qbankmaq); $this->assertNotFalse($qbankmaq); // It also contains 2 children of the multianswer question. $this->assertCount(2, array_filter($qbankquestions, fn($question) => $question->parent == $qbankmaq->id)); } } } PK8B\"""tests/backup/restore_date_test.phpnu[. namespace mod_qbank\backup; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); /** * Restore test for mod_qbank. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class restore_date_test extends \restore_date_testcase { /** * When restoring a course, you can change the start date, which shifts other dates. * This test checks that certain dates are correctly modified. * * @covers \restore_dbops::create_new_course() * @return void */ public function test_restore_dates(): void { global $DB; [$course, $module] = $this->create_course_and_module('qbank', ['timemodified' => time()]); $newcourseid = $this->backup_and_restore($course); $newmodule = $DB->get_record('qbank', ['course' => $newcourseid]); $this->assertFieldsNotRolledForward($module, $newmodule, ['timemodified']); } } PK8B\=-Ftests/generator/lib.phpnu[. /** * Generator for mod_qbank. * Required by the module generator but intentionally blank until we need to extend * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_qbank_generator extends testing_module_generator { #[\Override] public function create_instance($record = null, ?array $options = null) { $record = (object)(array)$record; if (empty($record->type)) { $record->type = core_question\local\bank\question_bank_helper::TYPE_STANDARD; } if (isset($options['section']) && (int) $options['section'] !== 0) { throw new \core\exception\coding_exception( "Attempted to create a mod_qbank instance in section {$options['section']}. " . "Question banks can only be created in section 0.", ); } return parent::create_instance($record, $options); } } PK8B\=Dn~~0tests/task/transfer_question_categories_test.phpnu[. namespace mod_qbank\task; use context; use context_course; use context_coursecat; use context_module; use context_system; use core\context\module; use core\context\system; use core\exception\moodle_exception; use core\task\manager; use core_question\local\bank\random_question_loader; use core_question\local\bank\question_bank_helper; use mod_quiz\quiz_settings; use stdClass; use core_question\local\bank\question_version_status; /** * Before testing, we firstly need to create some data to emulate what sites can have pre-upgrade. * Namely, we are adding question categories and questions to deprecated contexts i.e. anything not CONTEXT_MODULE, * and to quiz local banks too as we need to test these don't get touched. * It also adds questions to some categories that are not used by quizzes anywhere. * * The tests cover a few areas. * 1: We validate the data setup is correct before we run the installation script testing. * 2: The installation test validates that any question categories not in CONTEXT_MODULE get transferred to relevant mod_qbank * instances including their questions. It also validates that any stale questions that are not in use by quizzes are removed * along with empty categories. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_qbank\task\transfer_question_categories */ final class transfer_question_categories_test extends \advanced_testcase { /** @var \core\context\coursecat test course category context */ private \core\context\coursecat $coursecatcontext; /** @var \core\context\course test course context */ private \core\context\course $coursecontext; /** @var \core\context\course test stale course context*/ private \core\context\course $stalecoursecontext; /** @var \core\context\module test quiz mod context */ private \core\context\module $quizcontext; /** @var \core\context\course Course context with used and unused questions. */ private \core\context\course $usedunusedcontext; /** @var stdClass[] test stale questions */ private array $stalequestions; /** * Get question data from question category ids provided in the argument. * * @param array $categoryids * @return array */ protected function get_question_data(array $categoryids): array { global $DB; [$insql, $inparams] = $DB->get_in_or_equal($categoryids); $sql = "SELECT q.id, qbe.questioncategoryid AS categoryid, qv.status FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid {$insql}"; return $DB->get_records_sql($sql, $inparams); } /** * This is hacky, but we can't use the API to create these as non module contexts are deprecated for holding question * categories. * * @param string $name of the new category * @param int $contextid of the module holding the category * @param int $parentid of the new category * @return stdClass category object */ protected function create_question_category(string $name, int $contextid, int $parentid = 0): stdClass { global $DB; if (!$parentid) { if (!$parent = $DB->get_record('question_categories', ['contextid' => $contextid, 'parent' => 0, 'name' => 'top'])) { $parent = new stdClass(); $parent->name = 'top'; $parent->info = ''; $parent->contextid = $contextid; $parent->parent = 0; $parent->sortorder = 0; $parent->stamp = make_unique_id_code(); $parent->id = $DB->insert_record('question_categories', $parent); } $parentid = $parent->id; } $record = (object) [ 'name' => $name, 'parent' => $parentid, 'contextid' => $contextid, 'info' => '', 'infoformat' => FORMAT_HTML, 'stamp' => make_unique_id_code(), 'sortorder' => 999, 'idnumber' => null, ]; $record->id = $DB->insert_record('question_categories', $record); return $record; } /** * Sets up the installation test with data. * * @return void */ protected function setup_pre_install_data(): void { global $DB; self::setAdminUser(); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); $quizgenerator = self::getDataGenerator()->get_plugin_generator('mod_quiz'); // Setup 2 categories at site level context, with a question in each. $sitecontext = context_system::instance(); $site = get_site(); $siteparentcat = $this->create_question_category('Site Parent Cat', $sitecontext->id); $sitechildcat = $this->create_question_category('Site Child Cat', $sitecontext->id, $siteparentcat->id); $question1 = $questiongenerator->create_question( 'shortanswer', null, ['category' => $siteparentcat->id, 'status' => question_version_status::QUESTION_STATUS_READY] ); $question2 = $questiongenerator->create_question( 'shortanswer', null, ['category' => $sitechildcat->id, 'status' => question_version_status::QUESTION_STATUS_READY] ); // Add a quiz to the site course and put those questions into it. $quiz = $quizgenerator->create_instance(['course' => $site->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); quiz_add_quiz_question($question1->id, $quiz, 1); quiz_add_quiz_question($question2->id, $quiz, 1); \core_tag_tag::add_item_tag('core_question', 'question', $question1->id, $sitecontext, 'tag1'); \core_tag_tag::add_item_tag('core_question', 'question', $question1->id, $sitecontext, 'tag2'); // Create a course with a quiz containing a random question from the system context. $randomcourse = self::getDataGenerator()->create_course(['shortname' => 'Random']); $randomquiz = $quizgenerator->create_instance( [ 'course' => $randomcourse->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0', ], ); $randomquizsettings = quiz_settings::create($randomquiz->id); $structure = $randomquizsettings->get_structure(); $topcategory = $DB->get_record('question_categories', ['contextid' => $sitecontext->id, 'parent' => 0]); $filtercondition = [ 'filter' => [ 'category' => [ 'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT, 'values' => [$topcategory->id], 'filteroptions' => ['includesubcategories' => true], ], ], ]; $structure->add_random_questions(1, 1, $filtercondition); // Create a course category and then a question category attached to that context. $coursecategory = self::getDataGenerator()->create_category(); $this->coursecatcontext = context_coursecat::instance($coursecategory->id); $coursecatcat = $this->create_question_category('Course Cat Parent Cat', $this->coursecatcontext->id); // Add a question to the category just made. $question3 = $questiongenerator->create_question('essay', 'files', ['category' => $coursecatcat->id]); // Add a quiz to the course category and put those questions into it. $course = self::getDataGenerator()->create_course(['category' => $coursecategory->id]); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); quiz_add_quiz_question($question3->id, $quiz, 1); // Create an additional question with a missing type, to catch edge cases. $question4 = $questiongenerator->create_question('missingtype', 'invalid', ['category' => $coursecatcat->id]); $DB->set_field('question', 'qtype', 'invalid', ['id' => $question4->id]); // Create 2 nested categories with questions in them at course context level. $course = self::getDataGenerator()->create_course(); $this->coursecontext = context_course::instance($course->id); $coursegrandparentcat = $this->create_question_category('Course Grandparent Cat', $this->coursecontext->id); $courseparentcat1 = $this->create_question_category( 'Course Parent Cat', $this->coursecontext->id, $coursegrandparentcat->id, ); $coursechildcat1 = $this->create_question_category( 'Course Child Cat', $this->coursecontext->id, $courseparentcat1->id, ); $question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $courseparentcat1->id]); $question5 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]); // Make the questions 'in use'. $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); quiz_add_quiz_question($question4->id, $quiz, 1); quiz_add_quiz_question($question5->id, $quiz, 1); \core_tag_tag::add_item_tag('core_question', 'question', $question4->id, $this->coursecontext, 'tag1'); \core_tag_tag::add_item_tag('core_question', 'question', $question4->id, $this->coursecontext, 'tag3'); // Include a stale question, which should not be migrated with the others. $question6 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]); $DB->set_field( 'question_versions', 'status', question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question6->id], ); \core_tag_tag::add_item_tag('core_question', 'question', $question6->id, $this->coursecontext, 'staletag'); // Create some nested categories with no questions in use. $course = self::getDataGenerator()->create_course(); $context = context_course::instance($course->id); $courseparentcat1 = $this->create_question_category('Stale Course Parent Cat1', $context->id); $coursechildcat1 = $this->create_question_category('Stale Course Child Cat1', $context->id, $courseparentcat1->id); $courseparentcat2 = $this->create_question_category('Stale Course Parent Cat2', $context->id); $coursechildcat2 = $this->create_question_category('Stale Course Child Cat2', $context->id, $courseparentcat2->id); $coursegrandchildcat1 = $this->create_question_category('Stale Course Grandchild Cat1', $context->id, $coursechildcat2->id); $this->stalecoursecontext = context_course::instance($course->id); // Make all the questions hidden. $this->stalequestions[] = $questiongenerator->create_question('shortanswer', null, ['category' => $courseparentcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] ); $this->stalequestions[] = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] ); $this->stalequestions[] = $questiongenerator->create_question('shortanswer', null, ['category' => $courseparentcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] ); $this->stalequestions[] = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] ); $this->stalequestions[] = $questiongenerator->create_question('shortanswer', null, ['category' => $coursegrandchildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] ); foreach ($this->stalequestions as $question) { $DB->set_field('question_versions', 'status', question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question->id] ); } // Create additional versions of a stale question, all hidden. $staleversionquestion = reset($this->stalequestions); $questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion); $questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion); // Set up a quiz with some categories and questions attached to it. $course = self::getDataGenerator()->create_course(); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); $this->quizcontext = context_module::instance($quiz->cmid); $quizparentcat1 = $this->create_question_category('Quiz Mod Parent Cat1', $this->quizcontext->id); $quizchildcat1 = $this->create_question_category('Quiz Mod Child Cat1', $this->quizcontext->id, $quizparentcat1->id); $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizparentcat1->id]); $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizchildcat1->id]); quiz_add_quiz_question($question1->id, $quiz, 1); quiz_add_quiz_question($question2->id, $quiz, 1); // Set up a course with three categories // - One contains questions including 1 that is used in a quiz. // - One contains questions that are not used anywhere, but are in "ready" state. // - One contains no questions. $course = self::getDataGenerator()->create_course(['shortname' => 'Used-Unused-Empty']); $this->usedunusedcontext = context_course::instance($course->id); $usedcategory = $this->create_question_category(name: 'Used Question Cat', contextid: $this->usedunusedcontext->id); $unusedcategory = $this->create_question_category('Unused Question Cat', $this->usedunusedcontext->id); $emptycategory = $this->create_question_category('Empty Cat', $this->usedunusedcontext->id); $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]); $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]); $question3 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]); $question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); quiz_add_quiz_question($question1->id, $quiz, 1); // The quiz also contains a random question from the used category. $quizsettings = quiz_settings::create($quiz->id); $structure = $quizsettings->get_structure(); $filtercondition = [ 'filter' => [ 'category' => [ 'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT, 'values' => [$usedcategory->id], 'filteroptions' => ['includesubcategories' => false], ], ], ]; $structure->add_random_questions(1, 1, $filtercondition); } /** * Asserts that the pre-installation setup is correct. * * @return void */ public function test_setup_pre_install_data(): void { global $DB; $this->resetAfterTest(); $this->setup_pre_install_data(); $sitecontext = context_system::instance(); $allsitecats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id], 'id ASC'); // Make sure we have 2 site level question categories below 'top' and that the child is below the parent. $this->assertCount(3, $allsitecats); $parentcat = next($allsitecats); $childcat = end($allsitecats); $this->assertEquals($parentcat->id, $childcat->parent); // Make sure we have 1 question per the above site level question categories. $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allsitecats)); usort($questions, static fn($a, $b) => $a->categoryid <=> $b->categoryid); $this->assertCount(2, $questions); $parentcatq = reset($questions); $childcatq = end($questions); $this->assertEquals($parentcat->id, $parentcatq->categoryid); $this->assertEquals($childcat->id, $childcatq->categoryid); // Make sure the "Random" course has 1 quiz with 1 random question that returns the questions from the system top category. $randomcourse = $DB->get_record('course', ['shortname' => 'Random']); $coursemods = get_course_mods($randomcourse->id); $randomquiz = reset($coursemods); $randomquizsettings = quiz_settings::create($randomquiz->instance); $structure = $randomquizsettings->get_structure(); $randomquestionslot = $structure->get_question_in_slot(1); $this->assertEquals($randomquestionslot->contextid, $sitecontext->id); $loader = new random_question_loader(new \qubaid_list([])); $randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']); $this->assertCount(2, $randomquestions); $randomq1 = reset($randomquestions); $randomq2 = end($randomquestions); $this->assertEquals($parentcatq->id, $randomq1->id); $this->assertEquals($parentcat->id, $randomq1->category); $this->assertEquals($childcatq->id, $randomq2->id); $this->assertEquals($childcat->id, $randomq2->category); // Make sure that the course category has a question category below 'top'. $allcoursecatcats = $DB->get_records('question_categories', ['contextid' => $this->coursecatcontext->id], 'id ASC'); $this->assertCount(2, $allcoursecatcats); $topcat = reset($allcoursecatcats); $parentcat = end($allcoursecatcats); $this->assertEquals($topcat->id, $parentcat->parent); // Make sure we have 2 questions in the above course category level question category. $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecatcats)); $this->assertCount(2, $questions); $question = reset($questions); $this->assertEquals($parentcat->id, $question->categoryid); // Make sure there are files in the expected fileareas for this question. $fs = get_file_storage(); $this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'question', 'questiontext', $question->id, '/', '1.png')); $this->assertTrue( $fs->file_exists($this->coursecatcontext->id, 'question', 'generalfeedback', $question->id, '/', '2.png'), ); $this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png')); // Make sure we have 4 question categories at course level (including 'top') with some questions in them. $allcoursecats = $DB->get_records('question_categories', ['contextid' => $this->coursecontext->id], 'id ASC'); $this->assertCount(4, $allcoursecats); $grandparentcat = next($allcoursecats); $parentcat = next($allcoursecats); $this->assertEquals($grandparentcat->id, $parentcat->parent); $childcat = end($allcoursecats); $this->assertEquals($parentcat->id, $childcat->parent); $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecats)); // 2 active questions and 1 stale question, for a total of 3. $this->assertCount(3, $questions); // Make sure we have 6 stale question categories at course level (including 'top') with some questions in them. $questioncats = $DB->get_records('question_categories', ['contextid' => $this->stalecoursecontext->id], 'id ASC'); $this->assertCount(6, $questioncats); $topcat = reset($questioncats); $parentcat1 = next($questioncats); $childcat1 = next($questioncats); $parentcat2 = next($questioncats); $childcat2 = next($questioncats); $grandchildcat1 = next($questioncats); $this->assertEquals($topcat->id, $parentcat1->parent); $this->assertEquals($topcat->id, $parentcat2->parent); $this->assertEquals($parentcat1->id, $childcat1->parent); $this->assertEquals($parentcat2->id, $childcat2->parent); $this->assertEquals($childcat2->id, $grandchildcat1->parent); // There should be 4 question bank entries with 1 version each, and 1 with 3 versions, for a total of 7. $questionids = $this->get_question_data(array_map(static fn($cat) => $cat->id, $questioncats)); $this->assertCount(7, $questionids); // Make sure the "Used-Unused-Empty" course has 4 question categories (including 'top') with 0, 2, 2, and 0 // questions respectively. $questioncats = $DB->get_records('question_categories', ['contextid' => $this->usedunusedcontext->id], 'id ASC'); $this->assertCount(4, $questioncats); $topcat = reset($questioncats); $this->assertEmpty($this->get_question_data([$topcat->id])); $usedcat = next($questioncats); $this->assertCount(2, $this->get_question_data([$usedcat->id])); $unusedcat = next($questioncats); $this->assertCount(2, $this->get_question_data([$unusedcat->id])); $emptycat = next($questioncats); $this->assertCount(0, $this->get_question_data([$emptycat->id])); // The question reference for the random question is using the "used" category, and the site context. $coursemods = get_course_mods($this->usedunusedcontext->instanceid); $quiz = reset($coursemods); $quizsettings = quiz_settings::create($quiz->instance); $structure = $quizsettings->get_structure(); $randomquestionslot = $structure->get_question_in_slot(2); $this->assertEquals($this->usedunusedcontext->id, $randomquestionslot->contextid); $this->assertEquals($usedcat->id, $randomquestionslot->filtercondition['filter']['category']['values'][0]); } /** * Assert the installation task handles the deprecated contexts correctly. * * @return void */ public function test_qbank_install(): void { global $DB; $this->resetAfterTest(); $this->setup_pre_install_data(); $task = new transfer_question_categories(); $task->execute(); // Site context checks. $sitecontext = context_system::instance(); $sitecontextcats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id]); // Should be no site context question categories left, not even 'top'. $this->assertCount(0, $sitecontextcats); $sitemodinfo = get_fast_modinfo(get_site()); $siteqbanks = $sitemodinfo->get_instances_of('qbank'); // We should have 1 new module on the site course. $this->assertCount(1, $siteqbanks); $siteqbank = reset($siteqbanks); // Make doubly sure it got put into section 0 as these mod types are not rendered to the course page. $this->assertEquals(0, $siteqbank->sectionnum); // It should have our determined name. $this->assertEquals('System shared question bank', $siteqbank->name); $sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id); // The 3 question categories including 'top' should now be at the new module context with their order intact. $sitemodcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $sitemodcontext->id], 'id ASC' ); $this->assertCount(2, $sitemodcats); $topcat = question_get_top_category($sitemodcontext->id); $parentcat = reset($sitemodcats); $childcat = next($sitemodcats); $this->assertEquals($topcat->id, $parentcat->parent); $this->assertEquals($parentcat->id, $childcat->parent); // The random question should now point to the questions in the site course question bank. $randomcourse = $DB->get_record('course', ['shortname' => 'Random']); $coursemods = get_course_mods($randomcourse->id); $randomquiz = reset($coursemods); $randomquizsettings = quiz_settings::create($randomquiz->instance); $structure = $randomquizsettings->get_structure(); $randomquestionslot = $structure->get_question_in_slot(1); $this->assertEquals($randomquestionslot->contextid, $sitemodcontext->id); $loader = new random_question_loader(new \qubaid_list([])); $randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']); $this->assertCount(2, $randomquestions); $randomq1 = reset($randomquestions); $randomq2 = end($randomquestions); $this->assertEquals($parentcat->id, $randomq1->category); $this->assertEquals($childcat->id, $randomq2->category); // Course category context checks. // Make sure that the course category has no question categories, not even 'top'. $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecatcontext->id])); $courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC'); // We should have 2 courses in this category now, the original and the new one that holds our new mod instance. $this->assertCount(2, $courses); $newcourse = end($courses); $coursecat = $DB->get_record('course_categories', ['id' => $newcourse->category]); // Make sure the new course shortname is a unique name based on the category name and id. $this->assertEquals("$coursecat->name-$coursecat->id", $newcourse->shortname); // Make sure the new course fullname is based on the category name. $this->assertEquals("Shared teaching resources for category: $coursecat->name", $newcourse->fullname); $coursemodinfo = get_fast_modinfo($newcourse); $coursecatqbanks = $coursemodinfo->get_instances_of('qbank'); // We should have 1 new module on this course. $this->assertCount(1, $coursecatqbanks); $coursecatqbank = reset($coursecatqbanks); // Make sure the new module name is what we expect. $this->assertEquals("$coursecat->name shared question bank", $coursecatqbank->name); $coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC'); // The 2 question categories should be moved to the module context now. $this->assertCount(2, $coursecatqcats); $topcat = reset($coursecatqcats); $parentcat = end($coursecatqcats); // Make sure the parent orders are correct. $this->assertEquals($topcat->id, $parentcat->parent); // Course context checks. // Make sure that the course has no more question categories, not even 'top'. $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecontext->id])); $coursemodinfo = get_fast_modinfo($this->coursecontext->instanceid); $course = $coursemodinfo->get_course(); $courseqbanks = $coursemodinfo->get_instances_of('qbank'); // We should have only 1 new mod instance in this course. $this->assertCount(1, $coursecatqbanks); // The module name should be what we expect. $courseqbank = reset($courseqbanks); $this->assertEquals("$course->shortname shared question bank", $courseqbank->name); // Make sure the question categories still exist and that we have a new top one at the new module context. $topcat = question_get_top_category($courseqbank->context->id); $courseqcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $courseqbank->context->id], 'id ASC' ); $grandparentcat = reset($courseqcats); $parentcat = next($courseqcats); $childcat = next($courseqcats); $this->assertEquals($topcat->id, $grandparentcat->parent); $this->assertEquals($grandparentcat->id, $parentcat->parent); $this->assertEquals($parentcat->id, $childcat->parent); // Make sure the two active questions were migrated with their categories, but not the stale question. $migratedquestions = $this->get_question_data([$parentcat->id, $childcat->id]); $this->assertCount(2, $migratedquestions); foreach ($migratedquestions as $migratedquestion) { $this->assertTrue($migratedquestion->status === question_version_status::QUESTION_STATUS_READY); } // Stale course context checks. // Make sure the stale course has no categories attached to it anymore and the questions were removed. $this->assertFalse($DB->record_exists('question_categories', ['contextid' => $this->stalecoursecontext->id])); foreach ($this->stalequestions as $stalequestion) { $this->assertFalse($DB->record_exists('question', ['id' => $stalequestion->id])); } // Make sure the we did not create a qbank in the stale course. $this->assertEmpty(get_fast_modinfo($this->stalecoursecontext->instanceid)->get_instances_of('qbank')); // Quiz module checks. // Make sure the 3 categories at quiz context, including 'top' have not been touched. $quizcategories = $DB->get_records('question_categories', ['contextid' => $this->quizcontext->id]); $this->assertCount(3, $quizcategories); $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $quizcategories)); $this->assertCount(2, $questions); // Used-Unused-Empty checks. // The empty category should have been removed. The other categories should both have been migrated to a qbank module, // with all of their questions. $usedunusedmodinfo = get_fast_modinfo($this->usedunusedcontext->instanceid); $usedunusedcourse = $usedunusedmodinfo->get_course(); $usedunusedqbanks = $usedunusedmodinfo->get_instances_of('qbank'); $usedunusedqbank = reset($usedunusedqbanks); $this->assertEquals("$usedunusedcourse->shortname shared question bank", $usedunusedqbank->name); // We should now only have 3 categories. Top, used and unused. $usedunusedcats = $DB->get_records( 'question_categories', ['contextid' => $usedunusedqbank->context->id], fields: 'name, id', ); $this->assertCount(3, $usedunusedcats); $this->assertArrayHasKey('top', $usedunusedcats); $this->assertArrayHasKey('Used Question Cat', $usedunusedcats); $this->assertArrayHasKey('Unused Question Cat', $usedunusedcats); $this->assertArrayNotHasKey('Empty Question Cat', $usedunusedcats); $this->assertEmpty($this->get_question_data([$usedunusedcats['top']->id])); $this->assertCount(2, $this->get_question_data([$usedunusedcats['Used Question Cat']->id])); $this->assertCount(2, $this->get_question_data([$usedunusedcats['Unused Question Cat']->id])); // The question reference for the random question is using the same category, but the new context. $modinfo = get_fast_modinfo($this->usedunusedcontext->instanceid); $quizzes = $modinfo->get_instances_of('quiz'); $quiz = reset($quizzes); $quizsettings = quiz_settings::create($quiz->instance); $structure = $quizsettings->get_structure(); $randomquestionslot = $structure->get_question_in_slot(2); $this->assertEquals($usedunusedqbank->context->id, $randomquestionslot->contextid); $this->assertEquals( $usedunusedcats['Used Question Cat']->id, $randomquestionslot->filtercondition['filter']['category']['values'][0] ); } /** * Assert the installation task handles the missing contexts correctly. * * @return void */ public function test_qbank_install_with_missing_context(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); // The problem is that question categories that used to related to contextids // which no longer exist are now all moved to the new system-level shared // question bank. This moving categories together can cause unique key violations. // Create 2 orphaned categories where the contextid no longer exists, with the same stamp and idnumber. // We need to do this by creating in a real context, then deleting the context, // because create category logs, which needs a valid context id. $tamperedstamp = make_unique_id_code(); $context1 = context_course::instance(self::getDataGenerator()->create_course()->id); $oldcat1 = $this->create_question_category('Lost category 1', $context1->id); $oldcat1->stamp = $tamperedstamp; $oldcat1->idnumber = 'tamperedidnumber'; $DB->update_record('question_categories', $oldcat1); $DB->delete_records('context', ['id' => $context1->id]); $context2 = context_course::instance(self::getDataGenerator()->create_course()->id); $oldcat2 = $this->create_question_category('Lost category 2', $context2->id); $oldcat2->stamp = $tamperedstamp; $oldcat2->idnumber = 'tamperedidnumber'; $DB->update_record('question_categories', $oldcat2); $DB->delete_records('context', ['id' => $context2->id]); // Add a question to each category. $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat1->id]); $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat2->id]); // Make the questions 'in use'. $quizcourse = self::getDataGenerator()->create_course(); $quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance( ['course' => $quizcourse->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0'] ); quiz_add_quiz_question($question1->id, $quiz); quiz_add_quiz_question($question2->id, $quiz); // Make sure the caches are reset so that the contexts are not cached. \core\context_helper::reset_caches(); // Run the task. $task = new transfer_question_categories(); $task->execute(); // An important thing to verify is that the task completes without errors, // for example unique key violations. // Verify - there should be a single question bank in the site course with the expected name. $sitemodinfo = get_fast_modinfo(get_site()); $siteqbanks = $sitemodinfo->get_instances_of('qbank'); $this->assertCount(1, $siteqbanks); $siteqbank = reset($siteqbanks); $this->assertEquals('System shared question bank', $siteqbank->name); // The two previously orphaned categories should now be in this site questions bank, with a top category. $sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id); $sitemodcats = $DB->get_records_select( 'question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $sitemodcontext->id], 'id ASC', ); // Work out which category is which. $movedcat1 = null; $movedcat2 = null; foreach ($sitemodcats as $movedcat) { if ($movedcat->name === $oldcat1->name) { $movedcat1 = $movedcat; } if ($movedcat->name === $oldcat2->name) { $movedcat2 = $movedcat; } } $this->assertNotNull($movedcat1); $this->assertNotNull($movedcat2); // Verify the properties of the moved categories. $this->assertNotEquals($movedcat1->stamp, $movedcat2->stamp); $this->assertNotEquals($movedcat1->idnumber, $movedcat2->idnumber); $this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat1->parent); $this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat2->parent); } public function test_fix_wrong_parents(): void { $this->resetAfterTest(); $this->setup_pre_install_data(); // Create a second course. $course2 = self::getDataGenerator()->create_course(); $course2context = context_course::instance($course2->id); // In course2 we build this category structure: // - $course2parentcat -- context $course2context // - - $wrongchild1 -- context $this->coursecontext (wrong) // - - - $wronggrandchild1 -- context $this->coursecontext (same wrong) // - - - $doublywronggrandchild1 -- context $course2context (back right, but not matching its parent) // - - $wrongchild2 -- context non-existant A // - - - $wronggrandchild2 -- context non-existent A // - - - $doublywronggrandchild2 -- context non-existent B. $course2parentcat = $this->create_question_category( 'Course2 parent cat', $course2context->id); $wrongchild1 = $this->create_question_category( 'Child cat with wrong context', $this->coursecontext->id, $course2parentcat->id); $wronggrandchild1 = $this->create_question_category( 'Grandchild of child1 in same wrong context', $this->coursecontext->id, $wrongchild1->id); $doublywronggrandchild1 = $this->create_question_category( 'Grandchild of child1 back in the right context', $course2context->id, $wrongchild1->id); $wrongchild2 = $this->create_question_category( 'Child cat with non-existent context', $course2context->id + 1000, $course2parentcat->id); $wronggrandchild2 = $this->create_question_category( 'Grandchild of child2 with same non-existent context', $course2context->id + 1000, $wrongchild2->id); $doublywronggrandchild2 = $this->create_question_category( 'Grandchild of child2 with different non-existent context', $course2context->id + 2000, $wrongchild2->id); // Before we clean up, check that the expected categories are picked up. // $wronggrandchild1 & $wronggrandchild2 are not seen, because their contexts match // their parent's even though both are wrong. They should still get fixed. $task = new transfer_question_categories(); $this->assertEquals( [ $wrongchild1->id => $wrongchild1->contextid, $doublywronggrandchild1->id => $course2context->id, $wrongchild2->id => $wrongchild2->contextid, $doublywronggrandchild2->id => $doublywronggrandchild2->contextid, ], $task->get_categories_in_a_different_context_to_their_parent(), ); // Call the cleanup method. $task->fix_wrong_parents(); // Now we expect no mismatches. $this->assertEmpty($task->get_categories_in_a_different_context_to_their_parent()); // Assert that the child categories have been moved to the locations they should have been. $this->assert_category_is_in_context_with_parent($this->coursecontext, null, $wrongchild1->id); $this->assert_category_is_in_context_with_parent($this->coursecontext, $wrongchild1, $wronggrandchild1->id); $this->assert_category_is_in_context_with_parent($course2context, null, $doublywronggrandchild1->id); $this->assert_category_is_in_context_with_parent($course2context, $course2parentcat, $wrongchild2->id); $this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $wronggrandchild2->id); $this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $doublywronggrandchild2->id); } /** * Categories with missing contexts that would violate unique keys if moved to the same context as-is are correctly modified. */ public function test_fix_wrong_parents_conflicting_indexes(): void { global $DB; $this->resetAfterTest(); $this->setup_pre_install_data(); // Create a second course. $course2 = self::getDataGenerator()->create_course(); $course2context = context_course::instance($course2->id); // Create a parent category, and 2 child categories in a non-existant context. $course2parentcat = $this->create_question_category('Course2 parent cat', $course2context->id); $wrongchild1 = $this->create_question_category('Wrong context 1', $this->coursecontext->id + 1000, $course2parentcat->id); $wrongchild2 = $this->create_question_category('Wrong context 2', $this->coursecontext->id + 1000, $course2parentcat->id); // Set the stamp of one child and the idnumber of another child to match that of the parent context. // Moving either of these children to the correct context would cause a conflict. $tamperedstamp = make_unique_id_code(); $wrongchild1->stamp = $tamperedstamp; $course2parentcat->stamp = $tamperedstamp; $tamperedidnumber = random_string(); $wrongchild2->idnumber = $tamperedidnumber; $course2parentcat->idnumber = $tamperedidnumber; $DB->update_record('question_categories', $course2parentcat); $DB->update_record('question_categories', $wrongchild1); $DB->update_record('question_categories', $wrongchild2); // Before we clean up, check that the expected categories are picked up. $task = new transfer_question_categories(); $this->assertEquals( [ $wrongchild1->id => $wrongchild1->contextid, $wrongchild2->id => $wrongchild2->contextid, ], $task->get_categories_in_a_different_context_to_their_parent(), ); // Call the cleanup method. $task->fix_wrong_parents(); // Now we expect no mismatches. $this->assertEmpty($task->get_categories_in_a_different_context_to_their_parent()); // Assert that the child categories have been moved to the parent context. $this->assert_category_is_in_context_with_parent($course2context, $course2parentcat, $wrongchild1->id); $this->assert_category_is_in_context_with_parent($course2context, $course2parentcat, $wrongchild2->id); // Categories with the same stamp should now be different. $this->assertNotEquals( $DB->get_field('question_categories', 'stamp', ['id' => $course2parentcat->id]), $DB->get_field('question_categories', 'stamp', ['id' => $wrongchild1->id]), ); // Categories with same idnumber should now be different. $this->assertNotEquals( $DB->get_field('question_categories', 'idnumber', ['id' => $course2parentcat->id]), $DB->get_field('question_categories', 'idnumber', ['id' => $wrongchild2->id]), ); } /** * Assert that the category with id $categoryid is in context $expectedcontext, with the given parent. * * @param context $expectedcontext the expected context for the category with id $categoryid. * @param stdClass|null $expectedparent the expected parent category. * null means the Top category in $expectedcontext. * @param int $categoryid the id of the category to check. */ protected function assert_category_is_in_context_with_parent( context $expectedcontext, ?stdClass $expectedparent, int $categoryid, ): void { global $DB; if ($expectedparent === null) { $expectedparent = $DB->get_record( 'question_categories', ['contextid' => $expectedcontext->id, 'parent' => 0], '*', MUST_EXIST, ); } $actualcategory = $DB->get_record('question_categories', ['id' => $categoryid]); $this->assertEquals($expectedparent->id, $actualcategory->parent, "Checking parent of category $actualcategory->name."); $this->assertEquals($expectedcontext->id, $actualcategory->contextid, "Checking context of category $actualcategory->name."); } public function test_transfer_questions(): void { global $DB; $this->resetAfterTest(); $this->setup_pre_install_data(); // We should still ahve all the tags in the course context. $coursetags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$this->coursecontext]); $this->assertCount(3, $coursetags); $task = new \mod_qbank\task\transfer_question_categories(); $task->execute(); // Assert that files are still in their original context. $courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC'); $newcourse = end($courses); $coursemodinfo = get_fast_modinfo($newcourse); $coursecatqbanks = $coursemodinfo->get_instances_of('qbank'); $coursecatqbank = reset($coursecatqbanks); $coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC'); $parentcat = end($coursecatqcats); $questions = get_questions_category($parentcat, true); $question = reset($questions); $fs = get_file_storage(); $this->assertTrue($fs->file_exists( $this->coursecatcontext->id, 'question', 'questiontext', $question->id, '/', '1.png' )); $this->assertTrue($fs->file_exists( $this->coursecatcontext->id, 'question', 'generalfeedback', $question->id, '/', '2.png' )); $this->assertTrue($fs->file_exists( $this->coursecatcontext->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png' )); $this->assertFalse($fs->file_exists( $coursecatqbank->context->id, 'question', 'questiontext', $question->id, '/', '1.png' )); $this->assertFalse($fs->file_exists( $coursecatqbank->context->id, 'question', 'generalfeedback', $question->id, '/', '2.png' )); $this->assertFalse($fs->file_exists( $coursecatqbank->context->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png' )); // Assert tags are still in their original contexts. $sitecourse = get_course(SITEID); $sitemodinfo = get_fast_modinfo($sitecourse); $siteqbanks = $sitemodinfo->get_instances_of('qbank'); $siteqbank = reset($siteqbanks); $siteqbankcontext = module::instance($siteqbank->id); $testcourse = get_course($this->coursecontext->instanceid); $testmodinfo = get_fast_modinfo($testcourse); $testqbanks = $testmodinfo->get_instances_of('qbank'); $testqbank = reset($testqbanks); $testqbankcontext = module::instance($testqbank->id); $sitetags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [context_system::instance()]); $this->assertCount(2, $sitetags); $coursetags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$this->coursecontext]); $this->assertCount(2, $coursetags); // The stale tag was removed with the stale question when the categories were moved. $this->assertEmpty(\core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$siteqbankcontext])); $this->assertEmpty(\core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$testqbankcontext])); $this->assertFalse(question_bank_helper::has_bank_migration_task_completed_successfully()); $questiontasks = manager::get_adhoc_tasks(transfer_questions::class); // We should have a transfer_questions task for each category that was moved. // 2 site categories, // 1 coursecat category, // 3 regular course categories, // 2 used/unused course categories. $this->assertCount(8, $questiontasks); $this->expectOutputRegex('~Moving files and tags~'); // Delete one of the categories before running the tasks, to ensure missing categories are handled gracefully. $unusedcat = $DB->get_record('question_categories', ['name' => 'Unused Question Cat']); question_category_delete_safe($unusedcat); $this->expectOutputRegex("~Could not find a category record for id {$unusedcat->id}. Terminating task.~"); $this->runAdhocTasks(); // The files have now been moved to the new context. $this->assertFalse($fs->file_exists( $this->coursecatcontext->id, 'question', 'questiontext', $question->id, '/', '1.png' )); $this->assertFalse($fs->file_exists( $this->coursecatcontext->id, 'question', 'generalfeedback', $question->id, '/', '2.png' )); $this->assertFalse($fs->file_exists( $this->coursecatcontext->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png' )); $this->assertTrue($fs->file_exists( $coursecatqbank->context->id, 'question', 'questiontext', $question->id, '/', '1.png' )); $this->assertTrue($fs->file_exists( $coursecatqbank->context->id, 'question', 'generalfeedback', $question->id, '/', '2.png' )); $this->assertTrue($fs->file_exists( $coursecatqbank->context->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png' )); // Assert tags have been moved still to their new contexts. $sitetags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$siteqbankcontext]); $this->assertCount(2, $sitetags); $coursetags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$testqbankcontext]); $this->assertCount(2, $coursetags); $this->assertEmpty(\core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [system::instance()])); $this->assertEmpty(\core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', [$this->coursecontext])); $this->assertTrue(question_bank_helper::has_bank_migration_task_completed_successfully()); } public function test_transfer_questions_with_duplicate_tags(): void { global $DB; $this->resetAfterTest(); // Create a second course. $course = self::getDataGenerator()->create_course(); $course2context = context_course::instance($course->id); // Create a parent category, and 2 child categories in a non-existant context. $coursecat = $this->create_question_category('Course2 parent cat', $course2context->id); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); $question1 = $questiongenerator->create_question( 'shortanswer', null, ['category' => $coursecat->id, 'status' => question_version_status::QUESTION_STATUS_READY] ); $tag = self::getDataGenerator()->create_tag(['name' => 'testtag']); // Insert two instances of the same tag on the same question, with different contexts. $taginstance1 = (object) [ 'tagid' => $tag->id, 'component' => 'core_question', 'itemid' => $question1->id, 'itemtype' => 'question', 'contextid' => $course2context->id, 'ordering' => 1, 'timecreated' => time(), 'timemodified' => time(), 'tiuserid' => 2, ]; $taginstance1->id = $DB->insert_record('tag_instance', $taginstance1); $taginstance2 = (object) [ 'tagid' => $tag->id, 'component' => 'core_question', 'itemid' => $question1->id, 'itemtype' => 'question', 'contextid' => system::instance()->id, 'ordering' => 2, 'timecreated' => time(), 'timemodified' => time(), 'tiuserid' => 2, ]; $taginstance2->id = $DB->insert_record('tag_instance', $taginstance2); $task = new \mod_qbank\task\transfer_question_categories(); $task->execute(); // Run transfer_questions task. $this->expectOutputRegex('~Moving files and tags for 1 questions~'); $this->run_all_adhoc_tasks(); $newcontextid = $DB->get_field('question_categories', 'contextid', ['id' => $coursecat->id]); $this->assertTrue($DB->record_exists('tag_instance', ['id' => $taginstance1->id, 'contextid' => $newcontextid])); $this->assertFalse($DB->record_exists('tag_instance', ['id' => $taginstance2->id])); } public function test_qbank_install_resilience(): void { global $DB; $this->resetAfterTest(); $this->setup_pre_install_data(); require_once(__DIR__ . '/../fixtures/testable_transfer_question_categories.php'); $task = new testable_transfer_question_categories(); try { $task->execute(); } catch (moodle_exception $e) { // We expect a failure here, but we ignore this. $this->assertStringContainsString('This is a mocked exception for testing purposes.', $e->getMessage()); } // We want to verify a failure does not prevent the creation of tasks with hitherto transferred categories and their data. // We should have a transfer_questions task for two of the categories that were moved. $questiontasks = manager::get_adhoc_tasks(transfer_questions::class); $this->assertCount(2, $questiontasks); } } PK8B\Z mod_form.phpnu[. defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/course/moodleform_mod.php'); /** * mod_qbank settings form definition. * * @package mod_qbank * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_qbank_mod_form extends moodleform_mod { #[\Override] protected function definition(): void { global $CFG; $mform = $this->_form; $striptags = !empty($CFG->formatstringstriptags); $this->standard_hidden_coursemodule_elements(); // We need to force visibility on this here as we don't need the other standard course elements. $mform->addElement('hidden', 'visible', 0); $mform->setType('visible', PARAM_INT); $mform->addElement('hidden', 'type'); $mform->setDefaults('type', \core_question\local\bank\question_bank_helper::TYPE_STANDARD); $mform->setType('type', PARAM_TEXT); $mform->addElement('header', 'generalhdr', get_string('general')); // Add element for name. $mform->addElement('text', 'name', get_string('qbankname', 'mod_qbank'), ['size' => '64']); if ($striptags) { $mform->setType('name', PARAM_TEXT); } else { $mform->setType('name', PARAM_CLEANHTML); } $mform->addHelpButton('name', 'qbankname', 'mod_qbank'); $mform->addRule('name', null, 'required', null, 'client'); $mform->addRule('name', null, 'maxlength', \core_question\local\bank\question_bank_helper::BANK_NAME_MAX_LENGTH, 'client'); // Add intro editor. $mform->addElement('editor', 'introeditor', get_string('moduleintro'), ['rows' => 10], [ 'maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $this->context, 'subdirs' => true, ]); $mform->setType('introeditor', PARAM_RAW); // No XSS prevention here, users must be trusted. if ($CFG->requiremodintro) { $mform->addRule('introeditor', get_string('required'), 'required', null, 'client'); } // Add show description checkbox. $mform->addElement('advcheckbox', 'showdescription', get_string('showdescription', 'mod_qbank')); $mform->addHelpButton('showdescription', 'showdescription', 'mod_qbank'); $mform->addElement('header', 'modstandardelshdr', get_string('modstandardels', 'form')); // Add idnumber element. $mform->addElement('text', 'cmidnumber', get_string('idnumbermod')); if ($striptags) { $mform->setType('cmidnumber', PARAM_TEXT); } else { $mform->setType('cmidnumber', PARAM_CLEANHTML); } $mform->addHelpButton('cmidnumber', 'idnumbermod'); // Add our submission buttons. $buttonarray[] = $mform->createElement('submit', 'submitbutton2', get_string('saveandreturn', 'mod_qbank')); $buttonarray[] = $mform->createElement('submit', 'submitbutton', get_string('saveanddisplay', 'mod_qbank')); $buttonarray[] = $mform->createElement('cancel'); $mform->addGroup($buttonarray, 'buttonar', '', [' '], false); $mform->setType('buttonar', PARAM_RAW); } #[\Override] public function validation($data, $files): array { global $DB; $mform = $this->_form; // We don't want the parent validation as it has completion settings which we don't use. // Best call the grandparent though in case it changes in the future. $errors = moodleform::validation($data, $files); if ($mform->elementExists('name')) { $name = trim($data['name']); if ($name === '') { $errors['name'] = get_string('required'); } } if (!empty($data['cmidnumber'])) { $idnumexists = $DB->record_exists_select( 'course_modules', 'id <> :id AND course = :course AND idnumber = :idnumber', ['course' => $data['course'], 'idnumber' => $data['cmidnumber'], 'id' => $data['coursemodule'] ?? null] ); if ($idnumexists) { $errors['cmidnumber'] = get_string('idnumbertaken'); } } if (!empty($data['type']) && !in_array($data['type'], core_question\local\bank\question_bank_helper::SHARED_TYPES, true)) { $errors['type'] = get_string('unknownbanktype', 'mod_qbank', $data['type']); } return $errors; } } PK8B\yȍdb/install.phpnu[PK8B\c[ db/access.phpnu[PK8B\ܑf<< db/upgrade.phpnu[PK8B\jydb/install.xmlnu[PK8B\a Wlang/en/qbank.phpnu[PK8B\C%Z Z 4O$backup/moodle2/restore_qbank_activity_task.class.phpnu[PK8B\LVV( /backup/moodle2/backup_qbank_stepslib.phpnu[PK8B\Q!)7backup/moodle2/restore_qbank_stepslib.phpnu[PK8B\X r 3@backup/moodle2/backup_qbank_activity_task.class.phpnu[PK8B\Jview.phpnu[PK8B\)nOpix/monologo.svgnu[PK8B\ Wlib.phpnu[PK8B\eEzii kversion.phpnu[PK8B\;oclasses/privacy/provider.phpnu[PK8B\94tclasses/event/course_module_instance_list_viewed.phpnu[PK8B\p #yclasses/task/transfer_questions.phpnu[PK8B\BB-classes/task/transfer_question_categories.phpnu[PK8B\1J88 index.phpnu[PK8B\S 8rtests/fixtures/testable_transfer_question_categories.phpnu[PK8B\єK)K)vtests/backup/restore_test.phpnu[PK8B\"""tests/backup/restore_date_test.phpnu[PK8B\=-Ftests/generator/lib.phpnu[PK8B\=Dn~~0tests/task/transfer_question_categories_test.phpnu[PK8B\Z mod_form.phpnu[PK