ÿØÿà 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-Ї Ç PK\ 7 renderer.phpnu[. /** * Renderers for outputting parts of the question bank. * * @package core_question * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * This renderer outputs parts of the question bank. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_question_bank_renderer extends plugin_renderer_base { /** * Display additional navigation if needed. * * @param string $active * @return string */ public function extra_horizontal_navigation($active = null) { // Horizontal navigation for question bank. if ($questionnode = $this->page->settingsnav->find("questionbank", \navigation_node::TYPE_CONTAINER)) { if ($children = $questionnode->children) { $tabs = []; foreach ($children as $key => $node) { $tabs[] = new \tabobject($node->key, $node->action, $node->text); } if (empty($active) && $questionnode->find_active_node()) { $active = $questionnode->find_active_node()->key; } return \html_writer::div(print_tabs([$tabs], $active, null, null, true), 'questionbank-navigation'); } } return ''; } /** * Output the icon for a question type. * * @param string $qtype the question type. * @return string HTML fragment. */ public function qtype_icon($qtype) { $qtype = question_bank::get_qtype($qtype, false); $namestr = $qtype->local_name(); return $this->image_icon('icon', $namestr, $qtype->plugin_name(), array('title' => $namestr)); } /** * Render the column headers. * * @param array $qbankheaderdata * @return bool|string */ public function render_column_header($qbankheaderdata) { return $this->render_from_template('core_question/column_header', $qbankheaderdata); } /** * Render the column sort elements. * * @param array $sortdata * @return bool|string */ public function render_column_sort($sortdata) { return $this->render_from_template('core_question/column_sort', $sortdata); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated( 'qbank_managecategories\output\renderer::render_category_condition()', since: '4.3', mdl: 'MDL-72321', final: true )] public function render_category_condition($displaydata) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated(null, since: '4.3', mdl: 'MDL-72321', final: true)] public function render_category_condition_advanced($displaydata) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated( 'qbank_deletequestion\output\renderer::render_hidden_condition_advanced()', since: '4.3', mdl: 'MDL-72321', final: true )] public function render_hidden_condition_advanced($displaydata) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Render question pagination. * * @param array $displaydata * @return bool|string */ public function render_question_pagination($displaydata) { return $this->render_from_template('core_question/question_pagination', $displaydata); } /** * Render the showtext option. * * It's not a checkbox any more! [Name your API after the purpose, not the implementation!] * * @param array $displaydata * @return string */ public function render_showtext_checkbox($displaydata) { return $this->render_from_template('core_question/showtext_option', ['selected' . $displaydata['checked'] => true]); } /** * Render bulk actions ui. * * @param array $displaydata * @return bool|string */ public function render_bulk_actions_ui($displaydata) { return $this->render_from_template('core_question/bulk_actions_ui', $displaydata); } } PK\Q`` banks.phpnu[. /** * This script shows a list of all the question banks in a course. * It is normally reached from More -> Question banks in the course navigation. * * @package core_question * @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 */ use core_question\local\bank\question_bank_helper; use core_question\local\bank\question_edit_contexts; use core_question\output\view_banks; require_once(__DIR__ . '/../config.php'); global $CFG, $PAGE, $OUTPUT; $courseid = required_param('courseid', PARAM_INT); $createdefault = optional_param('createdefault', false, PARAM_BOOL); $course = get_course($courseid); $coursecontext = context_course::instance($course->id); require_login($course, false); $canmanage = has_capability('moodle/course:manageactivities', \context_course::instance($course->id)); if (empty(question_bank_helper::get_activity_types_with_shareable_questions())) { throw new moodle_exception('disabledbanks', 'question'); } $allcaps = array_merge(question_edit_contexts::$caps['editq'], question_edit_contexts::$caps['categories']); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions([$course->id], [], $allcaps); $privatebanks = question_bank_helper::get_activity_instances_with_private_questions([$course->id], [], $allcaps); if (!$canmanage && empty($sharedbanks)) { // Only allow non-managing users access to this page if they can access at least 1 activity with shared questions. // If they can access activities with private questions (such as quiz) they can be accessed elsewhere on the course. throw new \core\exception\moodle_exception('nobankpermissions', 'question'); } $pageurl = question_bank_helper::get_url_for_qbank_list($course->id); $PAGE->set_url($pageurl); $PAGE->add_body_class('limitedwidth'); $PAGE->set_heading(format_string($course->fullname, true, ['context' => $coursecontext])); $PAGE->set_title(get_string('questionbank_plural', 'question')); if ($canmanage && $createdefault) { require_sesskey(); question_bank_helper::create_default_open_instance( $course, question_bank_helper::get_bank_name_string('defaultbank', 'core_question', ['coursename' => $course->fullname]), ); \core\notification::add(get_string('defaultcreated', 'question'), \core\notification::SUCCESS); redirect($pageurl); } $output = $PAGE->get_renderer('core_question', 'bank'); echo $output->header(); if (!question_bank_helper::has_bank_migration_task_completed_successfully()) { $defaultactivityname = question_bank_helper::get_default_question_bank_activity_name(); echo $OUTPUT->notification(get_string('transfernotfinished', 'mod_' . $defaultactivityname), \core\output\notification::NOTIFY_WARNING ); } echo $output->render(new view_banks($sharedbanks, $privatebanks, $course)); echo $output->footer(); PK\L lib.phpnu[. /** * Question related functions. * * This file was created just because Fragment API expects callbacks to be defined on lib.php * * Please, do not add new functions to this file. * * @package core_question * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/editlib.php'); /** * Question data fragment to get the question html via ajax call. * * @param array $args Arguments for rendering the fragment. Expected keys: * * view - the view class * * cmid - if in an activity, the course module ID. * * filterquery - the current filters encoded as a URL parameter. * * lastchanged - the ID of the last edited question. * * sortdata - Array of sorted columns. * * filtercondition - the current filters encoded as an object. * * extraparams - additional parameters required for a particular view class. * * @return array|string */ function core_question_output_fragment_question_data(array $args): string { if (empty($args)) { return ''; } [$params, $extraparams] = \core_question\local\bank\filter_condition_manager::extract_parameters_from_fragment_args($args); [ $thispageurl, $contexts, , $cm, , $pagevars ] = question_build_edit_resources('questions', '/question/edit.php', $params); if (is_null($cm)) { $course = get_course(clean_param($args['courseid'], PARAM_INT)); } else { $course = get_course($cm->course); } $viewclass = empty($args['view']) ? \core_question\local\bank\view::class : clean_param($args['view'], PARAM_NOTAGS); // Make sure the class passed through is valid (exists and is view or subclass of view). if (!class_exists($viewclass) || !is_a($viewclass, \core_question\local\bank\view::class, true)) { throw new invalid_parameter_exception('view parameter must be a valid view class'); } if (!empty($args['lastchanged'])) { $thispageurl->param('lastchanged', clean_param($args['lastchanged'], PARAM_INT)); } if (!empty($args['view'])) { $thispageurl->param('view', clean_param($args['view'], PARAM_NOTAGS)); } if (!empty($args['extraparams'])) { $thispageurl->param('extraparams', clean_param($args['extraparams'], PARAM_RAW)); } $questionbank = new $viewclass($contexts, $thispageurl, $course, $cm, $pagevars, $extraparams); $questionbank->add_standard_search_conditions(); ob_start(); $questionbank->display_question_list(); return ob_get_clean(); } /** * Render and return a category selector for the categories in a given question bank. * * @param array $args ['bankcmid' => Course module ID of the question bank] * @return string The rendered selector. */ function core_question_output_fragment_category_selector(array $args): string { global $OUTPUT; $context = \core\context\module::instance($args['bankcmid']); $selector = new \core_question\output\question_category_selector([$context], autocomplete: true); return $OUTPUT->render($selector); } PK\ 44toggleflag.phpnu[. /** * Used by ajax calls to toggle the flagged state of a question in an attempt. * * @package moodlecore * @subpackage questionengine * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define('AJAX_SCRIPT', true); require_once('../config.php'); require_once($CFG->dirroot . '/question/engine/lib.php'); // Parameters $qaid = required_param('qaid', PARAM_INT); $qubaid = required_param('qubaid', PARAM_INT); $questionid = required_param('qid', PARAM_INT); $slot = required_param('slot', PARAM_INT); $newstate = required_param('newstate', PARAM_BOOL); $checksum = required_param('checksum', PARAM_ALPHANUM); // Check user is logged in. require_login(); require_sesskey(); // Check that the requested session really exists question_flags::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, $newstate); echo 'OK'; PK\^Vxx6bank/exportquestions/lang/en/qbank_exportquestions.phpnu[. /** * Strings for component 'qbank_exportquestions', language 'en' * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Export questions'; $string['privacy:metadata'] = 'The Export questions question bank plugin does not store any personal data.'; PK\6 bank/exportquestions/version.phpnu[. /** * Plugin version and other meta-data are defined here. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_exportquestions'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\bank/exportquestions/export.phpnu[. /** * Script for importing questions into the question bank. * * @package qbank_exportquestions * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/renderer.php'); use qbank_exportquestions\exportquestions_helper; use qbank_exportquestions\form\export_form; require_login(); core_question\local\bank\helper::require_plugin_enabled('qbank_exportquestions'); list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) = question_edit_setup('export', '/question/bank/exportquestions/export.php'); // Get display strings. $strexportquestions = get_string('exportquestions', 'question'); list($catid, $catcontext) = explode(',', $pagevars['cat']); $category = $DB->get_record('question_categories', ["id" => $catid, 'contextid' => $catcontext], '*', MUST_EXIST); // Header. $PAGE->set_url($thispageurl); $PAGE->set_title($strexportquestions); $PAGE->set_heading($COURSE->fullname); $PAGE->activityheader->disable(); echo $OUTPUT->header(); // Print horizontal nav if needed. $renderer = $PAGE->get_renderer('core_question', 'bank'); $qbankaction = new \core_question\output\qbank_action_menu($thispageurl); echo $renderer->render($qbankaction); $exportform = new export_form($thispageurl, ['contexts' => $contexts->having_one_edit_tab_cap('export'), 'defaultcategory' => $pagevars['cat']]); if ($fromform = $exportform->get_data()) { $thiscontext = $contexts->lowest(); if (!is_readable($CFG->dirroot . "/question/format/{$fromform->format}/format.php")) { throw new moodle_exception('unknowformat', '', '', $fromform->format); } $withcategories = 'nocategories'; if (!empty($fromform->cattofile)) { $withcategories = 'withcategories'; } $withcontexts = 'nocontexts'; if (!empty($fromform->contexttofile)) { $withcontexts = 'withcontexts'; } $classname = 'qformat_' . $fromform->format; $qformat = new $classname(); $filename = question_default_export_filename($COURSE, $category) . $qformat->export_file_extension(); $exporturl = exportquestions_helper::question_make_export_url($thiscontext->id, $category->id, $fromform->format, $withcategories, $withcontexts, $filename); echo $OUTPUT->box_start(); echo get_string('yourfileshoulddownload', 'question', $exporturl->out()); echo $OUTPUT->box_end(); // Log the export of these questions. $eventparams = [ 'contextid' => $category->contextid, 'other' => ['format' => $fromform->format, 'categoryid' => $category->id], ]; $event = \core\event\questions_exported::create($eventparams); $event->trigger(); // Don't allow force download for behat site, as pop-up can't be handled by selenium. if (!defined('BEHAT_SITE_RUNNING')) { $PAGE->requires->js_function_call('document.location.replace', [$exporturl->out(false)], false, 1); } echo $OUTPUT->continue_button(new moodle_url($PAGE->settingsnav->find( 'questionbank', \navigation_node::TYPE_CONTAINER)->action, $thispageurl->params())); echo $OUTPUT->footer(); exit; } // Display export form. echo $OUTPUT->heading_with_help($strexportquestions, 'exportquestions', 'question'); $exportform->display(); echo $OUTPUT->footer(); PK\1bank/exportquestions/classes/form/export_form.phpnu[. /** * Defines the export questions form. * * @package qbank_exportquestions * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_exportquestions\form; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Form to export questions from the question bank. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class export_form extends moodleform { /** * Build the form definition. * * This adds all the form fields that the export questions feature needs. */ protected function definition() { global $OUTPUT; $mform = $this->_form; $defaultcategory = $this->_customdata['defaultcategory']; $contexts = $this->_customdata['contexts']; // Choice of format, with help. $mform->addElement('header', 'fileformat', get_string('fileformat', 'question')); $fileformatnames = get_import_export_formats('export'); $radioarray = []; $separators = []; foreach ($fileformatnames as $shortname => $fileformatname) { $radioarray[] = $mform->createElement('radio', 'format', '', $fileformatname, $shortname); $separator = ''; if (get_string_manager()->string_exists('pluginname_help', 'qformat_' . $shortname)) { $separator .= $OUTPUT->help_icon('pluginname', 'qformat_' . $shortname); } $separator .= '
'; $separators[] = $separator; } $radioarray[] = $mform->createElement('static', 'makelasthelpiconshowup', ''); $mform->addGroup($radioarray, "formatchoices", '', $separators, false); $mform->addRule("formatchoices", null, 'required', null, 'client'); // Export options. $mform->addElement('header', 'general', get_string('general', 'form')); $mform->addElement('questioncategory', 'category', get_string('exportcategory', 'question'), ['contexts' => $contexts, 'top' => true]); $mform->setDefault('category', $defaultcategory); $mform->addHelpButton('category', 'exportcategory', 'question'); $categorygroup = []; $categorygroup[] = $mform->createElement('checkbox', 'cattofile', '', get_string('tofilecategory', 'question')); $categorygroup[] = $mform->createElement('checkbox', 'contexttofile', '', get_string('tofilecontext', 'question')); $mform->addGroup($categorygroup, 'categorygroup', '', '', false); $mform->disabledIf('categorygroup', 'cattofile', 'notchecked'); $mform->setDefault('cattofile', 1); $mform->setDefault('contexttofile', 1); // Set a template for the format select elements. $renderer = $mform->defaultRenderer(); $template = "{help} {element}\n"; $renderer->setGroupElementTemplate($template, 'format'); // Submit buttons. $this->add_action_buttons(false, get_string('exportquestions', 'question')); } } PK\1bank/exportquestions/classes/privacy/provider.phpnu[. /** * Privacy Subsystem for qbank_exportquestions implementing null_provider. * * @package qbank_exportquestions * @category privacy * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_exportquestions\privacy; /** * Class provider. */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\8{K+bank/exportquestions/classes/navigation.phpnu[. /** * Plugin entrypoint for navigation. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_exportquestions; /** * Class navigation. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation extends \core_question\local\bank\navigation_node_base { public function get_navigation_title(): string { return get_string('export', 'question'); } public function get_navigation_key(): string { return 'export'; } public function get_navigation_url(): \moodle_url { return new \moodle_url('/question/bank/exportquestions/export.php'); } } PK\s)II7bank/exportquestions/classes/exportquestions_helper.phpnu[. /** * Library functions used by qbank_exportquestions/export.php. * * This code is based on lib/questionlib.php by Martin Dougiamas. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_exportquestions; use moodle_url; /** * Class exportquestions_helper contains all the library functions. * * @package qbank_exportquestions */ class exportquestions_helper { /** * Create url for question export. * * @param int $contextid Current context. * @param int $categoryid Category id. * @param string $format Format. * @param string $withcategories nocategories or withcategories text. * @param string $withcontexts nocontexts or withcontexts text. * @param string $filename File name. * @return moodle_url Return an URL. */ public static function question_make_export_url($contextid, $categoryid, $format, $withcategories, $withcontexts, $filename): moodle_url { global $CFG; $urlbase = "$CFG->wwwroot/pluginfile.php"; return moodle_url::make_file_url($urlbase, "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}" . "/{$withcontexts}/{$filename}", true); } } PK\i;/bank/exportquestions/classes/plugin_feature.phpnu[. /** * Entry point for plugin. * * Every qbank plugin must extent this class. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_exportquestions; use core_question\local\bank\navigation_node_base; /** * Class plugin_feature. * * @package qbank_exportquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { public function get_navigation_node(): ?navigation_node_base { return new navigation(); } } PK\ bbbank/usage/styles.cssnu[.question-bank-table td.questionlastused span.date { font-weight: 400; font-size: .8em; } PK\o"bank/usage/lang/en/qbank_usage.phpnu[. /** * Strings for component qbank_usage, language 'en' * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Question usage'; $string['notused'] = 'Never'; $string['privacy:metadata'] = 'The Question usage question bank plugin does not store any personal data.'; $string['questionusage'] = 'Usage'; $string['questionusage_help'] = 'The number of quizzes in which the question is used, with a link to open a window listing the quizzes and the number of attempts.'; $string['questionlastused'] = 'Last used'; $string['questionlastused_help'] = 'The date when the question was last attempted.'; $string['usageheader'] = 'Question usage'; // Table. $string['modulename'] = 'Activity name'; $string['coursename'] = 'Course name'; $string['versions'] = 'Version'; $string['state'] = 'State'; $string['attempts'] = 'Attempts'; $string['questionusageversion'] = 'v{$a}'; PK\&VƋ bank/usage/lib.phpnu[. /** * Helper functions and callbacks. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question usage fragment callback. * * @param array $args * @return string rendered output */ function qbank_usage_output_fragment_question_usage(array $args): string { global $PAGE, $CFG; require_once($CFG->dirroot . '/question/engine/bank.php'); $displaydata = []; $question = question_bank::load_question($args['questionid']); $displaydata['question'] = question_bank::render_preview_of_question($question); $specificversion = clean_param($args['specificversion'] ?? false, PARAM_BOOL); $questionusagetable = new \qbank_usage\tables\question_usage_table('question_usage_table', $question, $specificversion); $questionusagetable->baseurl = new moodle_url(''); if (isset($args['querystring'])) { $querystring = preg_replace('/^\?/', '', $args['querystring']); $params = []; parse_str($querystring, $params); if (isset($params['page'])) { $questionusagetable->currpage = $params['page']; } } $displaydata['tablesql'] = $questionusagetable->export_for_fragment(); $selector = \core_question\output\question_version_selection::make_for_question('question_usage_version_dropdown', $args['questionid']); $qbankrenderer = $PAGE->get_renderer('core_question', 'bank'); $displaydata['versionselection'] = $selector->export_for_template($qbankrenderer); return $PAGE->get_renderer('qbank_usage')->render_usage_fragment($displaydata); } PK\ggbank/usage/version.phpnu[. /** * Version information for qbank_usage. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_usage'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\0}ww'bank/usage/classes/privacy/provider.phpnu[. namespace qbank_usage\privacy; /** * Privacy Subsystem for qbank_usage implementing null_provider. * * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\ Ϋ ,bank/usage/classes/question_usage_column.phpnu[. namespace qbank_usage; use core_question\local\bank\column_base; /** * A column type for the name of the question type. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_usage_column extends column_base { /** * Include Javascript module. * * @return void */ public function init(): void { parent::init(); global $PAGE; $PAGE->requires->js_call_amd('qbank_usage/usage', 'init', [ $this->qbank->is_listing_specific_versions() ]); } public function get_name(): string { return 'questionusage'; } public function get_title(): string { return get_string('questionusage', 'qbank_usage'); } public function help_icon(): ?\help_icon { return new \help_icon('questionusage', 'qbank_usage'); } protected function display_content($question, $rowclasses): void { $usagecount = helper::get_question_entry_usage_count($question, $this->qbank->is_listing_specific_versions()); $attributes = []; if (question_has_capability_on($question, 'view')) { $target = 'questionusagepreview_' . $question->id; $attributes = [ 'href' => '#', 'data-target' => $target, 'data-questionid' => $question->id, 'data-courseid' => $this->qbank->course->id, 'data-contextid' => $question->contextid, ]; } echo \html_writer::tag('a', $usagecount, $attributes); } public function get_extra_classes(): array { return ['pe-3']; } } PK\ꈃbank/usage/classes/helper.phpnu[. namespace qbank_usage; /** * Helper class for usage. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get the usage count for a question. * * @param \question_definition $question * @param bool $specificversion Count usages just for this version of the question? * @return int */ public static function get_question_entry_usage_count($question, bool $specificversion = false) { global $DB; $sql = 'SELECT COUNT(*) FROM (' . self::question_usage_sql($specificversion) . ') quizid'; $params = [$question->id, $question->questionbankentryid, 'mod_quiz', 'slot']; if ($specificversion) { $params[] = $question->id; } return $DB->count_records_sql($sql, $params); } /** * Get the sql for usage data. * * @param bool $specificversion Count usages just for this version of the question? * @return string */ public static function question_usage_sql(bool $specificversion = false): string { $sqlset = "(". self::get_question_attempt_usage_sql($specificversion) .")". "UNION". "(". self::get_question_bank_usage_sql($specificversion) .")"; return $sqlset; } /** * Get question attempt count for the question. * * @param int $questionid * @param int $quizid * @return int */ public static function get_question_attempts_count_in_quiz(int $questionid, $quizid = null): int { global $DB; if ($quizid) { $sql = 'SELECT COUNT(qatt.id) FROM {quiz} qz JOIN {quiz_attempts} qa ON qa.quiz = qz.id JOIN {question_usages} qu ON qu.id = qa.uniqueid JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id JOIN {question} q ON q.id = qatt.questionid WHERE qatt.questionid = :questionid AND qa.preview = 0 AND qz.id = :quizid'; $param = ['questionid' => $questionid, 'quizid' => $quizid]; } else { $sql = 'SELECT COUNT(qatt.id) FROM {quiz_slots} qs JOIN {quiz_attempts} qa ON qa.quiz = qs.quizid JOIN {question_usages} qu ON qu.id = qa.uniqueid JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id JOIN {question} q ON q.id = qatt.questionid WHERE qatt.questionid = ? AND qa.preview = 0'; $param = ['questionid' => $questionid]; } return $DB->count_records_sql($sql, $param); } /** * Get the question bank usage sql. * * The resulting string which represents a sql query has then to be * called accompanying a $params array which includes the necessary * parameters in the correct order which are the question id, then * the component and finally the question area. * * @param bool $specificversion Count usages just for this version of the question? * @return string */ public static function get_question_bank_usage_sql(bool $specificversion = false): string { $sql = "SELECT qz.id as quizid, qz.name as modulename, qz.course as courseid FROM {quiz_slots} slot JOIN {quiz} qz ON qz.id = slot.quizid JOIN {question_references} qr ON qr.itemid = slot.id JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id WHERE qv.questionbankentryid = ? AND qr.component = ? AND qr.questionarea = ?"; if ($specificversion) { // Only get results where the reference matches the specific question ID that was requested, // or the question ID that's requested is the latest version, and the reference is set to null (always latest version). $sql .= " AND qv.questionid = ? AND ( qv.version = qr.version OR ( qr.version IS NULL AND qv.version = ( SELECT MAX(qv1.version) FROM {question_versions} qv1 WHERE qv1.questionbankentryid = qbe.id ) ) )"; } return $sql; } /** * Get the question attempt usage sql. * * The resulting string which represents a sql query has then to be * called accompanying a $params array which includes the necessary * parameter, the question id. * * @param bool $specificversion Count usages just for this version of the question? * @return string */ public static function get_question_attempt_usage_sql(bool $specificversion = false): string { $sql = "SELECT qz.id as quizid, qz.name as modulename, qz.course as courseid FROM {quiz} qz JOIN {quiz_attempts} qa ON qa.quiz = qz.id JOIN {question_usages} qu ON qu.id = qa.uniqueid JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id"; if ($specificversion) { $sql .= " JOIN {question} q ON q.id = qatt.questionid WHERE qa.preview = 0 AND q.id = ?"; } else { $sql .= " JOIN {question_versions} qv ON qv.questionid = qatt.questionid JOIN {question_versions} qv2 ON qv.questionbankentryid = qv2.questionbankentryid WHERE qa.preview = 0 AND qv2.questionid = ?"; } return $sql; } /** * Get the question last used sql. * * @return string */ public static function get_question_last_used_sql(): string { $sql = "SELECT MAX(qa.timemodified) as lastused FROM {quiz} qz JOIN {quiz_attempts} qa ON qa.quiz = qz.id JOIN {question_usages} qu ON qu.id = qa.uniqueid JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id JOIN {question} q ON q.id = qatt.questionid WHERE qa.preview = 0 AND q.id = ?"; return $sql; } } PK\ާ0bank/usage/classes/question_last_used_column.phpnu[. namespace qbank_usage; use core_question\local\bank\column_base; /** * Question bank column for the question last used. * * @package qbank_usage * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_last_used_column extends column_base { public function get_name(): string { return 'questionlastused'; } public function get_title(): string { return get_string('questionlastused', 'qbank_usage'); } public function help_icon(): ?\help_icon { return new \help_icon('questionlastused', 'qbank_usage'); } protected function display_content($question, $rowclasses): void { global $DB, $PAGE; $displaydata = []; $questionusage = $DB->get_record_sql(helper::get_question_last_used_sql(), [$question->id]); $displaydata['lastused'] = get_string('notused', 'qbank_usage'); if (!empty($questionusage->lastused)) { $displaydata['lastused'] = userdate($questionusage->lastused); } echo $PAGE->get_renderer('qbank_usage')->render_last_used_column($displaydata); } public function get_extra_classes(): array { return ['pe-3']; } } PK\8CzQQ&bank/usage/classes/output/renderer.phpnu[. namespace qbank_usage\output; /** * Renderer for usage plugin. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render the html fragment for usage modal. * * @param array $displaydata * @return string */ public function render_usage_fragment(array $displaydata): string { return $this->render_from_template('qbank_usage/usage_modal', $displaydata); } /** * Render the question usage column. * * @param array $displaydata last used date or never * @return string */ public function render_last_used_column(array $displaydata): string { return $this->render_from_template('qbank_usage/last_used', $displaydata); } } PK\χ72bank/usage/classes/tables/question_usage_table.phpnu[. namespace qbank_usage\tables; global $CFG; require_once($CFG->libdir.'/tablelib.php'); use context_course; use html_writer; use moodle_url; use qbank_usage\helper; use table_sql; /** * Class question_usage_table. * An extension of regular Moodle table. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_usage_table extends table_sql { /** * Search string. * * @var string $search */ public $search = ''; /** * Question id. * * @var \question_definition $question */ public $question; /** * @var bool $specificversion Are we displaying the usage for a specific version, rather than all versions of the question? */ protected $specificversion; /** * constructor. * Sets the SQL for the table and the pagination. * * @param string $uniqueid * @param \question_definition $question */ public function __construct(string $uniqueid, \question_definition $question, bool $specificversion = false) { global $PAGE; parent::__construct($uniqueid); $this->question = $question; $columns = ['modulename', 'coursename', 'attempts']; $headers = [ get_string('modulename', 'qbank_usage'), get_string('coursename', 'qbank_usage'), get_string('attempts', 'qbank_usage') ]; $this->is_collapsible = false; $this->no_sorting('modulename'); $this->no_sorting('coursename'); $this->no_sorting('attempts'); $this->define_columns($columns); $this->define_headers($headers); $this->define_baseurl($PAGE->url); $this->specificversion = $specificversion; $this->set_attribute('id', 'question_usage_table'); } public function query_db($pagesize, $useinitialsbar = true) { global $DB; if (!$this->is_downloading()) { $total = helper::get_question_entry_usage_count($this->question, $this->specificversion); $this->pagesize($pagesize, $total); } $sql = helper::question_usage_sql($this->specificversion); $params = [$this->question->id, $this->question->questionbankentryid, 'mod_quiz', 'slot']; if ($this->specificversion) { $params[] = $this->question->id; } if (!$this->is_downloading()) { $this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size()); } else { $this->rawdata = $DB->get_records_sql($sql, $params); } } public function col_modulename(\stdClass $values): string { $cm = get_fast_modinfo($values->courseid)->instances['quiz'][$values->quizid]; return html_writer::link(new moodle_url('/mod/quiz/view.php', ['q' => $values->quizid]), $cm->get_formatted_name()); } public function col_coursename(\stdClass $values): string { $course = get_course($values->courseid); $context = context_course::instance($course->id); return html_writer::link(course_get_url($course), format_string($course->fullname, true, [ 'context' => $context, ])); } public function col_attempts(\stdClass $values): string { return helper::get_question_attempts_count_in_quiz($this->question->id, $values->quizid); } /** * Export this data so it can be used as the context for a mustache template/fragment. * * @return string */ public function export_for_fragment(): string { ob_start(); $this->out(10, true); return ob_get_clean(); } } PK\" %bank/usage/classes/plugin_feature.phpnu[. namespace qbank_usage; use core_question\local\bank\view; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { public function get_question_columns(view $qbank): array { return [ new question_usage_column($qbank), new question_last_used_column($qbank) ]; } } PK\SMPP)bank/usage/templates/usage_modal.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_usage/usage_modal Example context (json): { "usagedata": [ { "question": "question html" } ] } }} {{#versionselection}} {{> core_question/question_version_selection }} {{/versionselection}}
{{{question}}}
{{{tablesql}}}
PK\ա'bank/usage/templates/last_used.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_usage/last_used Example context (json): { "displaydata": [ { "lastused": "Never" } ] } }} {{lastused}} PK\fR  bank/usage/amd/src/usage.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Usage column selector js. * * @module qbank_usage/usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Fragment from 'core/fragment'; import ModalCancel from 'core/modal_cancel'; import Notification from 'core/notification'; import * as Str from 'core/str'; let modal = null; /** * Event listeners for the module. * * @method clickEvent * @param {int} questionId * @param {int} contextId * @param {boolean} specificVersion Is the view listing specific question versions? */ const usageEvent = async(questionId, contextId, specificVersion) => { const args = { questionid: questionId, specificversion: specificVersion, }; if (modal === null) { try { modal = await ModalCancel.create({ title: Str.get_string('usageheader', 'qbank_usage'), body: Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args), large: true, show: true, }); } catch (e) { Notification.exception(e); return; } modal.getRoot().on('click', 'a[href].page-link', function(e) { e.preventDefault(); let attr = e.target.getAttribute("href"); if (attr !== '#') { args.querystring = attr; modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args)); } }); // Version selection event. modal.getRoot().on('change', '#question_usage_version_dropdown', function(e) { args.questionid = e.target.value; modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args)); }); } else { modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args)); modal.show(); } }; /** * Entrypoint of the js. * * @method init * @param {boolean} specificVersion Is the view listing specific question versions? */ export const init = (specificVersion = false) => { const target = document.querySelector('#categoryquestions'); if (target !== null) { target.addEventListener('click', (e) => { if (e.target.dataset.target && e.target.dataset.target.includes('questionusagepreview')) { // Call for the event listener to listed for clicks in any usage count row. usageEvent(e.target.dataset.questionid, e.target.dataset.contextid, specificVersion); } }); } }; PK\sç%bank/usage/amd/build/usage.min.js.mapnu[{"version":3,"file":"usage.min.js","sources":["../src/usage.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Usage column selector js.\n *\n * @module qbank_usage/usage\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Fragment from 'core/fragment';\nimport ModalCancel from 'core/modal_cancel';\nimport Notification from 'core/notification';\nimport * as Str from 'core/str';\n\nlet modal = null;\n\n/**\n * Event listeners for the module.\n *\n * @method clickEvent\n * @param {int} questionId\n * @param {int} contextId\n * @param {boolean} specificVersion Is the view listing specific question versions?\n */\nconst usageEvent = async(questionId, contextId, specificVersion) => {\n const args = {\n questionid: questionId,\n specificversion: specificVersion,\n };\n if (modal === null) {\n try {\n modal = await ModalCancel.create({\n title: Str.get_string('usageheader', 'qbank_usage'),\n body: Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args),\n large: true,\n show: true,\n });\n } catch (e) {\n Notification.exception(e);\n return;\n }\n\n modal.getRoot().on('click', 'a[href].page-link', function(e) {\n e.preventDefault();\n let attr = e.target.getAttribute(\"href\");\n if (attr !== '#') {\n args.querystring = attr;\n modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args));\n }\n });\n // Version selection event.\n modal.getRoot().on('change', '#question_usage_version_dropdown', function(e) {\n args.questionid = e.target.value;\n modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args));\n });\n } else {\n modal.setBody(Fragment.loadFragment('qbank_usage', 'question_usage', contextId, args));\n modal.show();\n }\n\n};\n\n/**\n * Entrypoint of the js.\n *\n * @method init\n * @param {boolean} specificVersion Is the view listing specific question versions?\n */\nexport const init = (specificVersion = false) => {\n const target = document.querySelector('#categoryquestions');\n if (target !== null) {\n target.addEventListener('click', (e) => {\n if (e.target.dataset.target && e.target.dataset.target.includes('questionusagepreview')) {\n // Call for the event listener to listed for clicks in any usage count row.\n usageEvent(e.target.dataset.questionid, e.target.dataset.contextid, specificVersion);\n }\n });\n }\n};\n"],"names":["modal","usageEvent","async","questionId","contextId","specificVersion","args","questionid","specificversion","ModalCancel","create","title","Str","get_string","body","Fragment","loadFragment","large","show","e","exception","getRoot","on","preventDefault","attr","target","getAttribute","querystring","setBody","value","document","querySelector","addEventListener","dataset","includes","contextid"],"mappings":";;;;;;;;g4BA6BIA,MAAQ,WAUNC,WAAaC,MAAMC,WAAYC,UAAWC,yBACtCC,KAAO,CACTC,WAAYJ,WACZK,gBAAiBH,oBAEP,OAAVL,MAAgB,KAEZA,YAAcS,sBAAYC,OAAO,CAC7BC,MAAOC,IAAIC,WAAW,cAAe,eACrCC,KAAMC,kBAASC,aAAa,cAAe,iBAAkBZ,UAAWE,MACxEW,OAAO,EACPC,MAAM,IAEZ,MAAOC,qCACQC,UAAUD,GAI3BnB,MAAMqB,UAAUC,GAAG,QAAS,qBAAqB,SAASH,GACtDA,EAAEI,qBACEC,KAAOL,EAAEM,OAAOC,aAAa,QACpB,MAATF,OACAlB,KAAKqB,YAAcH,KACnBxB,MAAM4B,QAAQb,kBAASC,aAAa,cAAe,iBAAkBZ,UAAWE,WAIxFN,MAAMqB,UAAUC,GAAG,SAAU,oCAAoC,SAASH,GACtEb,KAAKC,WAAaY,EAAEM,OAAOI,MAC3B7B,MAAM4B,QAAQb,kBAASC,aAAa,cAAe,iBAAkBZ,UAAWE,eAGpFN,MAAM4B,QAAQb,kBAASC,aAAa,cAAe,iBAAkBZ,UAAWE,OAChFN,MAAMkB,sBAWM,eAACb,8EACXoB,OAASK,SAASC,cAAc,sBACvB,OAAXN,QACAA,OAAOO,iBAAiB,SAAUb,IAC1BA,EAAEM,OAAOQ,QAAQR,QAAUN,EAAEM,OAAOQ,QAAQR,OAAOS,SAAS,yBAE5DjC,WAAWkB,EAAEM,OAAOQ,QAAQ1B,WAAYY,EAAEM,OAAOQ,QAAQE,UAAW9B"}PK\ !bank/usage/amd/build/usage.min.jsnu[define("qbank_usage/usage",["exports","core/fragment","core/modal_cancel","core/notification","core/str"],(function(_exports,_fragment,_modal_cancel,_notification,Str){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Usage column selector js. * * @module qbank_usage/usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_fragment=_interopRequireDefault(_fragment),_modal_cancel=_interopRequireDefault(_modal_cancel),_notification=_interopRequireDefault(_notification),Str=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Str);let modal=null;const usageEvent=async(questionId,contextId,specificVersion)=>{const args={questionid:questionId,specificversion:specificVersion};if(null===modal){try{modal=await _modal_cancel.default.create({title:Str.get_string("usageheader","qbank_usage"),body:_fragment.default.loadFragment("qbank_usage","question_usage",contextId,args),large:!0,show:!0})}catch(e){return void _notification.default.exception(e)}modal.getRoot().on("click","a[href].page-link",(function(e){e.preventDefault();let attr=e.target.getAttribute("href");"#"!==attr&&(args.querystring=attr,modal.setBody(_fragment.default.loadFragment("qbank_usage","question_usage",contextId,args)))})),modal.getRoot().on("change","#question_usage_version_dropdown",(function(e){args.questionid=e.target.value,modal.setBody(_fragment.default.loadFragment("qbank_usage","question_usage",contextId,args))}))}else modal.setBody(_fragment.default.loadFragment("qbank_usage","question_usage",contextId,args)),modal.show()};_exports.init=function(){let specificVersion=arguments.length>0&&void 0!==arguments[0]&&arguments[0];const target=document.querySelector("#categoryquestions");null!==target&&target.addEventListener("click",(e=>{e.target.dataset.target&&e.target.dataset.target.includes("questionusagepreview")&&usageEvent(e.target.dataset.questionid,e.target.dataset.contextid,specificVersion)}))}})); //# sourceMappingURL=usage.min.js.mapPK\6L)L) bank/usage/tests/helper_test.phpnu[. namespace qbank_usage; defined('MOODLE_INTERNAL') || die(); use mod_quiz\quiz_attempt; global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Helper test. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_usage\helper */ final class helper_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * @var \stdClass $quiz */ protected $quiz; /** * @var \stdClass $user */ protected $user; /** * @var \core_question_generator $questiongenerator */ protected $questiongenerator; /** * @var array $questions */ protected $questions = []; /** * Test setup. */ public function setup(): void { $this->resetAfterTest(); $layout = '1,2,0'; // Make a user to do the quiz. $this->user = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $this->quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout]); $this->questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $this->questiongenerator->create_question_category(); $page = 1; foreach (explode(',', $layout) as $slot) { if ($slot == 0) { $page += 1; continue; } $question = $this->questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $this->quiz, $page); $this->questions [] = $question; } } /** * Record a quiz attempt. * * @return void */ protected function attempt_quiz(): void { $quizobj = \mod_quiz\quiz_settings::create($this->quiz->id, $this->user->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); quiz_attempt::create($attempt->id); } /** * Test question attempt count. * * @covers ::get_question_attempts_count_in_quiz */ public function test_get_question_attempts_count_in_quiz(): void { $this->attempt_quiz(); foreach ($this->questions as $question) { $questionattemptcount = helper::get_question_attempts_count_in_quiz($question->id, $this->quiz->id); // Test the attempt count matches the usage count, each question should have one count. $this->assertEquals(1, $questionattemptcount); } } /** * Test test usage data. * * @covers ::get_question_entry_usage_count */ public function test_get_question_entry_usage_count(): void { foreach ($this->questions as $question) { $count = helper::get_question_entry_usage_count(\question_bank::load_question($question->id)); // Test that the attempt data matches the usage data for the count. $this->assertEquals(1, $count); } } /** * If a question has been included via a random question attempt, this should be counted as a usage. * * @covers ::get_question_entry_usage_count * @return void */ public function test_get_random_question_attempts_usage_count(): void { $this->setAdminUser(); $cat = $this->questiongenerator->create_question_category(); $question = $this->questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->add_random_questions($this->quiz->id, 1, $cat->id, 1); $qdef = \question_bank::load_question($question->id); $count = helper::get_question_entry_usage_count($qdef); $this->assertEquals(0, $count); $this->attempt_quiz(); $count = helper::get_question_entry_usage_count($qdef); $this->assertEquals(1, $count); } /** * When a question referenced directly is edited, the usage count of all versions remains the same. * * When checking usage of separate versions, the new version should show usages but the original version should not. * * @covers ::get_question_entry_usage_count * @return void */ public function test_edited_question_usage_counts(): void { foreach ($this->questions as $question) { $qdef = \question_bank::load_question($question->id); $count1 = helper::get_question_entry_usage_count($qdef); // Each question should have 1 usage. $this->assertEquals(1, $count1); $newversion = $this->questiongenerator->update_question($question); $newqdef = \question_bank::load_question($newversion->id); // Either version should return the same count if not checking a specific version. $count2 = helper::get_question_entry_usage_count($qdef); $this->assertEquals(1, $count2); $count3 = helper::get_question_entry_usage_count($newqdef); $this->assertEquals(1, $count3); // Checking the specific version count should return the counts for each version. // The original version is no longer included in the quiz, so has 0 usages. $count4 = helper::get_question_entry_usage_count($qdef, true); $this->assertEquals(0, $count4); // The new version is now included in the quiz, so has 1 usage. $count5 = helper::get_question_entry_usage_count($newqdef, true); $this->assertEquals(1, $count5); } } /** * When a question referenced directly with attempts is edited, the usage count of all versions remains the same. * * When checking usage of separate versions, both versions should show usage. * * @covers ::get_question_entry_usage_count * @return void */ public function test_edited_attempted_question_usage_counts(): void { $this->attempt_quiz(); foreach ($this->questions as $question) { $qdef = \question_bank::load_question($question->id); $count1 = helper::get_question_entry_usage_count($qdef); // Each question should have 1 usage. $this->assertEquals(1, $count1); $newversion = $this->questiongenerator->update_question($question); $newqdef = \question_bank::load_question($newversion->id); // Either version should return the same count if not checking a specific version. $count2 = helper::get_question_entry_usage_count($qdef); $this->assertEquals(1, $count2); $count3 = helper::get_question_entry_usage_count($newqdef); $this->assertEquals(1, $count3); // Checking the specific version count should return the counts for each version. // The original version is no longer included in the quiz. However, the is still an attempt using this question version, // so it has 1 usage. $count4 = helper::get_question_entry_usage_count($qdef, true); $this->assertEquals(1, $count4); // The new version is now included in the quiz, so has 1 usage. $count5 = helper::get_question_entry_usage_count($newqdef, true); $this->assertEquals(1, $count5); } } /** * When a random question with attempts is edited, it should still have the same usage count. * * When checking usage of separate versions, the original version should still show usage but the new version should not. * * @covers ::get_question_entry_usage_count * @return void */ public function test_edited_attempted_random_question_usage_count(): void { $this->setAdminUser(); $cat = $this->questiongenerator->create_question_category(); $question = $this->questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->add_random_questions($this->quiz->id, 1, $cat->id, 1); $this->attempt_quiz(); $qdef = \question_bank::load_question($question->id); $count1 = helper::get_question_entry_usage_count($qdef); $this->assertEquals(1, $count1); $newversion = $this->questiongenerator->update_question($question); $newqdef = \question_bank::load_question($newversion->id); // Either version should return the same count if not checking a specific version. $count2 = helper::get_question_entry_usage_count($qdef); $this->assertEquals(1, $count2); $count3 = helper::get_question_entry_usage_count($newqdef); $this->assertEquals(1, $count3); // Checking the specific version count should return the counts for each version. // There is still an attempt of the original version has part of the random question attempt, so it has 1 usage. $count4 = helper::get_question_entry_usage_count($qdef, true); $this->assertEquals(1, $count4); // There is no attempt of the new version, so it has 0 usages. $count5 = helper::get_question_entry_usage_count($newqdef, true); $this->assertEquals(0, $count5); } } PK\ (bank/usage/tests/question_usage_test.phpnu[. namespace qbank_usage; use mod_quiz\quiz_attempt; /** * Tests for the data of question usage from differnet areas like helper or usage table. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_usage\tables\question_usage_table * @covers \qbank_usage_output_fragment_question_usage */ final class question_usage_test extends \advanced_testcase { /** * Test question usage data. */ public function test_question_usage(): void { global $PAGE; $this->resetAfterTest(true); $layout = '1,2,0'; // Make a user to do the quiz. $user = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout]); $quizobj = \mod_quiz\quiz_settings::create($quiz->id, $user->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $questions = []; $page = 1; foreach (explode(',', $layout) as $slot) { if ($slot == 0) { $page += 1; continue; } $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz, $page); $questions[] = $question; } $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $attemptdata = quiz_attempt::create($attempt->id); $this->setAdminUser(); $PAGE->set_url(new \moodle_url('/')); foreach ($questions as $question) { $questionusagetable = qbank_usage_output_fragment_question_usage(['questionid' => $question->id]); // Test usage table contains the quiz data which was attempted. $this->assertStringContainsString($quiz->name, $questionusagetable); // Test usage table contains the course data where the quiz was attempted. $this->assertStringContainsString($course->fullname, $questionusagetable); } } } PK\S 8bank/usage/tests/behat/question_last_used_column.featurenu[@qbank @qbank_usage @javascript Feature: Use the qbank plugin manager page for question last used In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | Scenario: Enable/disable question last usage column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question usage" When I click on "Disable" "link" in the "Question usage" "table_row" And I am on the "Test quiz" "quiz activity" page And I navigate to "Question bank" in current page administration And I apply question bank filter "Category" with value "Test questions" Then I should not see "Last used" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question usage" "table_row" And I am on the "Test quiz" "quiz activity" page And I navigate to "Question bank" in current page administration And I apply question bank filter "Category" with value "Test questions" And I should see "Last used" PK\e e 4bank/usage/tests/behat/question_usage_column.featurenu[@qbank @qbank_usage Feature: Use the qbank plugin manager page for question usage In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | And I change window size to "large" Scenario: Enable/disable question usage column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question usage" When I click on "Disable" "link" in the "Question usage" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "Usage" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question usage" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should see "Usage" @javascript Scenario: Question usage modal should work without any usage data And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I should see "Default for Test quiz" And I should see "0" on the usage column When I click "0" on the usage column Then I should see "Version 1" And I should see "v1 (latest)" in the "Question 1" "question" And I click on "Close" "button" in the ".modal-dialog" "css_element" And I should see "0" on the usage column @javascript Scenario: Question usage modal should work with usage data Given quiz "Test quiz" contains the following questions: | question | page | | First question | 1 | And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I should see "Default for Test quiz" And I should see "1" on the usage column When I click "1" on the usage column Then "Test quiz" "table_row" should exist in the "question-usage_table" "region" PK\*- ,bank/usage/tests/behat/behat_qbank_usage.phpnu[. // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/../../../../tests/behat/behat_question_base.php'); use Behat\Mink\Exception\ExpectationException as ExpectationException, Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; /** * Steps definitions to deal with the usage in question. * * @package qbank_usage * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qbank_usage extends behat_question_base { /** * Looks for a table, then looks for a row that contains the given text. * Once it finds the right row, it clicks a link in that row. * * @When I click :arg1 on the usage column * @param string $linkname */ public function i_click_on_the_usage_column($linkname) { $exception = new ElementNotFoundException($this->getSession(), 'Cannot find any row on the page containing the text ' . $linkname); $row = $this->find('css', sprintf('table tbody tr td.questionusage a:contains("%s")', $linkname), $exception); $row->click(); } /** * Looks for the appropriate usage count in the column. * * @Then I should see :arg1 on the usage column * @param string $linkdata */ public function i_should_see_on_the_usage_column($linkdata) { $exception = new ElementNotFoundException($this->getSession(), 'Cannot find any row with the usage count of ' . $linkdata . ' on the column named Usage'); $this->find('css', sprintf('table tbody tr td.questionusage a:contains("%s")', $linkdata), $exception); } } PK\1(bank/bulkmove/lang/en/qbank_bulkmove.phpnu[. /** * Strings for component qbank_bulkmove, language 'en' * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['bulkmoveheader'] = 'Move the selected questions to...'; $string['close'] = 'Close'; $string['confirmmove'] = 'Are you sure you want to move these questions?'; $string['movequestions'] = 'Move questions'; $string['movetobulkaction'] = 'Move to...'; $string['pluginname'] = 'Bulk move questions'; $string['privacy:metadata'] = 'The Bulk move questions question bank plugin does not store any personal data.'; $string['questionsmoved'] = 'Questions successfully moved'; $string['warning'] = 'You must select a question bank before you can select a category.'; PK\bank/bulkmove/lib.phpnu[. /** * qbank_bulkmove lib functions. * * @package qbank_bulkmove * @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 */ /** * Generates the bulkmove bank and category chooser for display in the modal. * * @param array $args * @return bool|string */ function qbank_bulkmove_output_fragment_bulk_move(array $args) { global $OUTPUT; $currentbankid = clean_param($args['context']->instanceid, PARAM_INT); $currentcategoryid = clean_param($args['categoryid'], PARAM_INT); $qbankcatchooser = new \qbank_bulkmove\output\bulk_move($currentbankid, $currentcategoryid); return $OUTPUT->render($qbankcatchooser); } PK\ppbank/bulkmove/version.phpnu[. /** * Version information for qbank_bulkmove. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_bulkmove'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\`rg*bank/bulkmove/classes/privacy/provider.phpnu[. namespace qbank_bulkmove\privacy; /** * Privacy Subsystem for qbank_deletequestion implementing null_provider. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\Q\l *bank/bulkmove/classes/bulk_move_action.phpnu[. namespace qbank_bulkmove; use moodle_exception; /** * Class bulk_move_action is the base class for moving questions. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class bulk_move_action extends \core_question\local\bank\bulk_action_base { public function get_bulk_action_title(): string { return get_string('movetobulkaction', 'qbank_bulkmove'); } public function get_key(): string { return 'move'; } public function get_bulk_action_url(): \moodle_url { return new \moodle_url('/question/bank/bulkmove/move.php'); } public function get_bulk_action_capabilities(): ?array { return [ 'moodle/question:moveall', 'moodle/question:add', ]; } /** * Initialise the modal js with the current bank context id and question category id. * @return void */ public function initialise_javascript(): void { global $PAGE; $category = $this->qbank->get_pagevars('cat'); if (!empty($category)) { [$categoryid, $contextid] = explode(',', $category); } else { $defaultcategory = question_get_default_category($this->qbank->cm->context->id, true); $categoryid = $defaultcategory->id; $contextid = $defaultcategory->contextid; } $PAGE->requires->js_call_amd( 'qbank_bulkmove/modal_question_bank_bulkmove', 'init', [ 'contextid' => $contextid, 'categoryid' => $categoryid, ] ); } } PK\b bank/bulkmove/classes/helper.phpnu[. namespace qbank_bulkmove; /** * Bulk move helper. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Bulk move questions to a category. * * @param string $movequestionselected comma separated string of questions to be moved. * @param \stdClass $tocategory the category where the questions will be moved to. */ public static function bulk_move_questions(string $movequestionselected, \stdClass $tocategory): void { global $DB, $CFG; require_once($CFG->libdir .'/questionlib.php'); if ($questionids = explode(',', $movequestionselected)) { [$usql, $params] = $DB->get_in_or_equal($questionids); $sql = "SELECT q.*, c.contextid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} c ON c.id = qbe.questioncategoryid WHERE q.id {$usql}"; $questions = $DB->get_records_sql($sql, $params); foreach ($questions as $question) { question_require_capability_on($question, 'move'); } question_move_questions_to_category($questionids, $tocategory->id); } } /** * Get the display data for the move form. * * @param array $addcontexts the array of contexts to be considered in order to render the category select menu. * @param \moodle_url $moveurl the url where the move script will point to. * @param \moodle_url $returnurl return url in case the form is cancelled. * @return array the data to be rendered in the mustache where it contains the dropdown, move url and return url. * @deprecated since Moodle 5.0. * @todo MDL-82413 Final deprecation in Moodle 6.0. */ #[\core\attribute\deprecated( replacement: 'replaced by a modal and webservice. See qbank_bulkmove/modal_question_bank_bulkmove and core_question_external\move_questions', since: '5.0', mdl: 'MDL-71378' )] public static function get_displaydata(array $addcontexts, \moodle_url $moveurl, \moodle_url $returnurl): array { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); $displaydata = []; $displaydata ['categorydropdown'] = \qbank_managecategories\helper::question_category_select_menu($addcontexts, false, 0, '', -1, true); $displaydata ['moveurl'] = $moveurl; $displaydata['returnurl'] = $returnurl; return $displaydata; } /** * Process the question came from the form post. * * @param array $rawquestions raw questions came as a part of post. * @return array question ids got from the post are processed and structured in an array. */ public static function process_question_ids(array $rawquestions): array { $questionids = []; $questionlist = ''; foreach ($rawquestions as $key => $notused) { // Parse input for question ids. if (preg_match('!^q([0-9]+)$!', $key, $matches)) { $key = $matches[1]; $questionids[] = $key; } } if (!empty($questionids)) { $questionlist = implode(',', $questionids); } return [$questionids, $questionlist]; } } PK\Q?f)bank/bulkmove/classes/output/renderer.phpnu[. namespace qbank_bulkmove\output; /** * Class renderer. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render bulk move. * * @param array $displaydata * @return string * @deprecated since Moodle 5.0. * @todo MDL-82413 Final deprecation in Moodle 6.0. */ #[\core\attribute\deprecated('qbank_bulkmove\output\bulk_move', since: '5.0', mdl: 'MDL-71378')] public function render_bulk_move_form($displaydata) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); return $this->render_from_template('qbank_bulkmove/bulk_move', $displaydata); } } PK\1.-AA*bank/bulkmove/classes/output/bulk_move.phpnu[. /** * Output class file. * * @package qbank_bulkmove * @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 */ namespace qbank_bulkmove\output; use cm_info; use core_question\local\bank\question_bank_helper; use core_question\output\question_category_selector; use moodle_url; use renderer_base; use single_button; /** * Output class to create a modal template with selects for question banks, question categories, and a move button. */ class bulk_move implements \renderable, \templatable { /** @var int The question bank id you are currently moving the question(s) from */ protected int $currentbankid; /** @var int The question category id you are moving the question(s) from */ protected int $currentcategoryid; /** * Instantiate the output class. * * @param int $currentbankid * @param int $currentcategoryid */ public function __construct(int $currentbankid, int $currentcategoryid) { $this->currentbankid = $currentbankid; $this->currentcategoryid = $currentcategoryid; } /** * Export data for use by the template. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output) { [, $cmrec] = get_module_from_cmid($this->currentbankid); $currentbankcm = cm_info::create($cmrec); // Get the current bank and its categories. All other banks and categories will be loaded dynamically. if (plugin_supports('mod', $currentbankcm->modname, FEATURE_PUBLISHES_QUESTIONS, false)) { $banktorender = question_bank_helper::get_activity_instances_with_shareable_questions( havingcap: ['moodle/question:add'], currentbankid: $this->currentbankid, filtercontext: $currentbankcm->context, limit: 1, )[0]; } else { $banktorender = question_bank_helper::get_activity_instances_with_private_questions( incourseids: [$currentbankcm->course], havingcap: ['moodle/question:add'], currentbankid: $this->currentbankid, filtercontext: $currentbankcm->context, )[0]; } $categoryselector = new question_category_selector( [$currentbankcm->context], selected: "{$this->currentcategoryid},{$currentbankcm->context->id}", autocomplete: true, ); $savebutton = new single_button( new moodle_url('#'), get_string('movequestions', 'qbank_bulkmove'), 'post', single_button::BUTTON_PRIMARY, [ 'data-action' => 'bulkmovesave', 'disabled' => 'disabled', ] ); return [ 'bank' => $banktorender, 'categories' => $categoryselector->export_for_template($output), 'save' => $savebutton->export_for_template($output), 'contextid' => $currentbankcm->context->id, ]; } } PK\C=''(bank/bulkmove/classes/plugin_feature.phpnu[. namespace qbank_bulkmove; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class plugin_feature is the entrypoint for the features. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { /** * Initialise the bulk action. * @param view $qbank * @return bulk_move_action[] */ public function get_bulk_actions(view $qbank): array { // Don't return bulkmove if we are on the question history page. if ($qbank->is_listing_specific_versions()) { return []; } else { return [ new bulk_move_action($qbank), ]; } } } PK\/E E *bank/bulkmove/templates/bulk_move.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_bulkmove/bulk_move Template for qbank_bulkmove/modal_question_bank_bulkmove Example context (json): { "bank": [ { "name": "Question bank 1", "modid": "2", "contextid": 2, "coursenamebankname": "c1 - Question bank 1", "cminfo": {}, "questioncategories": [ ] } ], "categories": { "categories": [ { "value": "0", "disabled": true, "label": "Bank name 1" }, { "value": "123,456", "selected": true, "label": "Category 1" }, { "value": "124,456", "label": "Category 2" } ] }, "save": { "id": "single_button669e368d496707", "formid": null, "method": "post", "url": "#", "label": "Move questions", "classes": "singlebutton", "disabled": false, "tooltip": null, "type": "primary", "attributes": [ { "name": "data-action", "value": "bulkmovesave" }, { "name": "disabled", "value": "disabled" } ], "params": [ { "name": "sesskey", "value": "abcde12345" } ], "actions": [], "hasactions": false }, "contextid": 1000 } }}
{{#str}}questionbank, question{{/str}}
{{#str}}questioncategories, question{{/str}}
{{#categories}} {{> core_question/question_category_selector }} {{/categories}}
{{#str}}warning, qbank_bulkmove{{/str}}
{{#save}}
{{>core/single_button}}
{{/save}} PK\+4" ,,1bank/bulkmove/templates/bulk_move_footer.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_bulkmove/bulk_move_footer Template for qbank_bulkmove/modal_question_bank_bulkmove Example context (json): { } }} PK\فW82825bank/bulkmove/amd/src/modal_question_bank_bulkmove.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Contain the logic for the bulkmove questions modal. * * @module qbank_bulkmove/modal_question_bank_bulkmove * @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 */ import Modal from 'core/modal'; import * as Fragment from 'core/fragment'; import {getString} from 'core/str'; import AutoComplete from 'core/form-autocomplete'; import {moveQuestions} from 'core_question/repository'; import Templates from 'core/templates'; import Notification from 'core/notification'; import Pending from 'core/pending'; export default class ModalQuestionBankBulkmove extends Modal { static TYPE = 'qbank_bulkmove/bulkmove'; static SELECTORS = { SAVE_BUTTON: '[data-action="bulkmovesave"]', SELECTED_QUESTIONS: 'table#categoryquestions input[id^="checkq"]', SEARCH_BANK: '#searchbanks', SEARCH_CATEGORY: '.selectcategory', QUESTION_CATEGORY_SELECTOR: '.question_category_selector', CATEGORY_OPTIONS: '.selectcategory option', BANK_OPTIONS: '#searchbanks option', CATEGORY_ENHANCED_INPUT: '.search-categories input', ORIGINAL_SELECTS: 'select.bulk-move', CATEGORY_WARNING: '#searchcatwarning', CATEGORY_SUGGESTION: '.search-categories span.form-autocomplete-downarrow', CATEGORY_SELECTION: '.search-categories span[role="option"][data-active-selection="true"]', CONFIRM_BUTTON: '.bulk-move-footer button[data-action="save"]', CANCEL_BUTTON: '.bulk-move-footer button[data-action="cancel"]' }; /** * @param {integer} contextId The current bank context id. * @param {integer} categoryId The current question category id. */ static init(contextId, categoryId) { document.addEventListener('click', (e) => { const trigger = e.target; if (trigger.className === 'dropdown-item' && trigger.getAttribute('name') === 'move') { e.preventDefault(); ModalQuestionBankBulkmove.create({ contextId, title: getString('bulkmoveheader', 'qbank_bulkmove'), show: true, categoryId: categoryId, }); } }); } /** * Set the initialised config on the class. * * @param {Object} modalConfig */ configure(modalConfig) { this.contextId = modalConfig.contextId; this.targetBankContextId = modalConfig.contextId; this.initSelectedCategoryId(modalConfig.categoryId); modalConfig.removeOnClose = true; super.configure(modalConfig); } /** * Initialise the category select based on the data passed to the JS or if a filter is applied in the url. * @param {integer} categoryId */ initSelectedCategoryId(categoryId) { const filter = new URLSearchParams(window.location.href).get('filter'); if (filter) { const filteredCategoryId = JSON.parse(filter)?.category.values[0]; this.currentCategoryId = filteredCategoryId > 0 ? filteredCategoryId : null; this.targetCategoryId = filteredCategoryId; return; } this.currentCategoryId = categoryId; this.targetCategoryId = categoryId; } /** * Render the modal contents. * @return {Promise} */ show() { void this.display(this.contextId, this.currentCategoryId); return super.show(); } /** * Get the content to display and enhance the selects into auto complete fields. * @param {integer} currentBankContextId * @param {integer} currentCategoryId */ async display(currentBankContextId, currentCategoryId) { const displayPending = new Pending('qbank_bulkmove/bulk_move_modal'); this.bodyPromise = await Fragment.loadFragment( 'qbank_bulkmove', 'bulk_move', currentBankContextId, { 'categoryid': currentCategoryId, } ); await this.setBody(this.bodyPromise); await this.enhanceSelects(); this.registerEnhancedEventListeners(); this.updateSaveButtonState(); displayPending.resolve(); } /** * Register event listeners on the enhanced selects. Must be done after they have been enhanced. */ registerEnhancedEventListeners() { document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", () => { this.updateSaveButtonState(); }); document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change", async(e) => { if (parseInt(e.target.value) === 0) { // The autocomplete contains a dummy option containing the text that the limit has been reached and the user // has to refine the search. Selection of this dummy option has to be handled separately. await this.updateCategorySelector(null); return; } await this.updateCategorySelector(e.currentTarget.value); this.updateSaveButtonState(); // In updateCategorySelector() the HTML of the SEARCH_CATEGORY select is replaced, // so we need to add a new event listener. document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", () => { this.updateSaveButtonState(); }); }); this.getModal().on("click", ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON, (e) => { e.preventDefault(); void this.displayConfirmMove(); }); } /** * Update the body with a confirmation prompt and set confirm cancel buttons in the footer. * @return {Promise} */ async displayConfirmMove() { this.setTitle(getString('confirm', 'core')); this.setBody(getString('confirmmove', 'qbank_bulkmove')); if (!this.hasFooterContent()) { // We don't have the footer yet so go grab it and register event listeners on the buttons. this.setFooter(Templates.render('qbank_bulkmove/bulk_move_footer', {})); await this.getFooterPromise(); document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CONFIRM_BUTTON).addEventListener("click", (e) => { e.preventDefault(); this.moveQuestionsAfterConfirm(this.targetBankContextId, this.targetCategoryId); }); document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CANCEL_BUTTON).addEventListener("click", (e) => { e.preventDefault(); this.setTitle(getString('bulkmoveheader', 'qbank_bulkmove')); this.setBodyContent(Templates.renderForPromise('core/loading', {})); this.hideFooter(); this.display(this.targetBankContextId, this.targetCategoryId); }); } else { // We already have a footer so just show it. this.showFooter(); } } /** * Update the category selector based on the selected question bank. * * @param {Number} selectedBankCmId * @return {Promise} Resolved when the update is complete. */ updateCategorySelector(selectedBankCmId) { if (!selectedBankCmId) { this.updateCategorySelectorState(false); return Promise.resolve(); } else { return Fragment.loadFragment( 'core_question', 'category_selector', this.contextId, { 'bankcmid': selectedBankCmId, } ) .then((html, js) => { const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.QUESTION_CATEGORY_SELECTOR); return Templates.replaceNode(categorySelector, html, js); }) .then(() => { document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING).classList.add('d-none'); return this.enhanceSelects(); }) .catch(Notification.exception); } } /** * Disable/enable the enhanced category selector field. * @param {boolean} toEnable True to enable, false to disable the field. */ updateCategorySelectorState(toEnable) { const warning = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING); const enhancedInput = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_ENHANCED_INPUT); const suggestionButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SUGGESTION); const selection = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SELECTION); if (toEnable) { warning.classList.add('d-none'); enhancedInput.removeAttribute('disabled'); suggestionButton.classList.remove('d-none'); } else { warning.classList.remove('d-none'); enhancedInput.setAttribute('disabled', 'disabled'); suggestionButton.classList.add('d-none'); selection.click(); // Clear selected category. } } /** * Disable the button if the selected category is the same as the one the questions already belong to. Enable it otherwise. */ updateSaveButtonState() { const saveButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON); const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY); [this.targetCategoryId, this.targetBankContextId] = categorySelector.value.split(','); if (this.targetCategoryId && this.targetCategoryId !== this.currentCategoryId) { saveButton.removeAttribute('disabled'); } else { saveButton.setAttribute('disabled', 'disabled'); } } /** * Move the selected questions to their new target category. * @param {integer} targetContextId the target bank context id. * @param {integer} targetCategoryId the target question category id. * @return {Promise} */ async moveQuestionsAfterConfirm(targetContextId, targetCategoryId) { await this.setBody(Templates.render('core/loading', {})); const qelements = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.SELECTED_QUESTIONS); const questionids = []; qelements.forEach((element) => { if (element.checked) { const name = element.getAttribute('name'); questionids.push(name.substr(1, name.length)); } }); if (questionids.length === 0) { await Notification.exception('No questions selected'); } try { window.location.href = await moveQuestions( targetContextId, targetCategoryId, questionids.join(), window.location.href ); } catch (error) { await Notification.exception(error); } } /** * Take the provided select options and enhance them into auto-complete fields. * * @return {Promise} */ async enhanceSelects() { const placeholder = await getString('searchbyname', 'mod_quiz'); await AutoComplete.enhance( ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK, false, 'core_question/question_banks_datasource', placeholder, false, true, '', true, ); await AutoComplete.enhance( ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY, false, null, placeholder, false, true, '', true, ); } } PK\ c]GG?bank/bulkmove/amd/build/modal_question_bank_bulkmove.min.js.mapnu[{"version":3,"file":"modal_question_bank_bulkmove.min.js","sources":["../src/modal_question_bank_bulkmove.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contain the logic for the bulkmove questions modal.\n *\n * @module qbank_bulkmove/modal_question_bank_bulkmove\n * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Simon Adams \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Modal from 'core/modal';\nimport * as Fragment from 'core/fragment';\nimport {getString} from 'core/str';\nimport AutoComplete from 'core/form-autocomplete';\nimport {moveQuestions} from 'core_question/repository';\nimport Templates from 'core/templates';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\n\n\nexport default class ModalQuestionBankBulkmove extends Modal {\n static TYPE = 'qbank_bulkmove/bulkmove';\n\n static SELECTORS = {\n SAVE_BUTTON: '[data-action=\"bulkmovesave\"]',\n SELECTED_QUESTIONS: 'table#categoryquestions input[id^=\"checkq\"]',\n SEARCH_BANK: '#searchbanks',\n SEARCH_CATEGORY: '.selectcategory',\n QUESTION_CATEGORY_SELECTOR: '.question_category_selector',\n CATEGORY_OPTIONS: '.selectcategory option',\n BANK_OPTIONS: '#searchbanks option',\n CATEGORY_ENHANCED_INPUT: '.search-categories input',\n ORIGINAL_SELECTS: 'select.bulk-move',\n CATEGORY_WARNING: '#searchcatwarning',\n CATEGORY_SUGGESTION: '.search-categories span.form-autocomplete-downarrow',\n CATEGORY_SELECTION: '.search-categories span[role=\"option\"][data-active-selection=\"true\"]',\n CONFIRM_BUTTON: '.bulk-move-footer button[data-action=\"save\"]',\n CANCEL_BUTTON: '.bulk-move-footer button[data-action=\"cancel\"]'\n };\n\n /**\n * @param {integer} contextId The current bank context id.\n * @param {integer} categoryId The current question category id.\n */\n static init(contextId, categoryId) {\n document.addEventListener('click', (e) => {\n const trigger = e.target;\n if (trigger.className === 'dropdown-item' && trigger.getAttribute('name') === 'move') {\n e.preventDefault();\n ModalQuestionBankBulkmove.create({\n contextId,\n title: getString('bulkmoveheader', 'qbank_bulkmove'),\n show: true,\n categoryId: categoryId,\n });\n }\n });\n }\n\n /**\n * Set the initialised config on the class.\n *\n * @param {Object} modalConfig\n */\n configure(modalConfig) {\n this.contextId = modalConfig.contextId;\n this.targetBankContextId = modalConfig.contextId;\n this.initSelectedCategoryId(modalConfig.categoryId);\n modalConfig.removeOnClose = true;\n super.configure(modalConfig);\n }\n\n /**\n * Initialise the category select based on the data passed to the JS or if a filter is applied in the url.\n * @param {integer} categoryId\n */\n initSelectedCategoryId(categoryId) {\n const filter = new URLSearchParams(window.location.href).get('filter');\n if (filter) {\n const filteredCategoryId = JSON.parse(filter)?.category.values[0];\n this.currentCategoryId = filteredCategoryId > 0 ? filteredCategoryId : null;\n this.targetCategoryId = filteredCategoryId;\n return;\n }\n this.currentCategoryId = categoryId;\n this.targetCategoryId = categoryId;\n }\n\n /**\n * Render the modal contents.\n * @return {Promise}\n */\n show() {\n void this.display(this.contextId, this.currentCategoryId);\n return super.show();\n }\n\n /**\n * Get the content to display and enhance the selects into auto complete fields.\n * @param {integer} currentBankContextId\n * @param {integer} currentCategoryId\n */\n async display(currentBankContextId, currentCategoryId) {\n const displayPending = new Pending('qbank_bulkmove/bulk_move_modal');\n this.bodyPromise = await Fragment.loadFragment(\n 'qbank_bulkmove',\n 'bulk_move',\n currentBankContextId,\n {\n 'categoryid': currentCategoryId,\n }\n );\n\n await this.setBody(this.bodyPromise);\n await this.enhanceSelects();\n this.registerEnhancedEventListeners();\n this.updateSaveButtonState();\n displayPending.resolve();\n }\n\n /**\n * Register event listeners on the enhanced selects. Must be done after they have been enhanced.\n */\n registerEnhancedEventListeners() {\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener(\"change\", () => {\n this.updateSaveButtonState();\n });\n\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener(\"change\", async(e) => {\n if (parseInt(e.target.value) === 0) {\n // The autocomplete contains a dummy option containing the text that the limit has been reached and the user\n // has to refine the search. Selection of this dummy option has to be handled separately.\n await this.updateCategorySelector(null);\n return;\n }\n await this.updateCategorySelector(e.currentTarget.value);\n this.updateSaveButtonState();\n // In updateCategorySelector() the HTML of the SEARCH_CATEGORY select is replaced,\n // so we need to add a new event listener.\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener(\"change\", () => {\n this.updateSaveButtonState();\n });\n });\n\n this.getModal().on(\"click\", ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON, (e) => {\n e.preventDefault();\n void this.displayConfirmMove();\n });\n }\n\n /**\n * Update the body with a confirmation prompt and set confirm cancel buttons in the footer.\n * @return {Promise}\n */\n async displayConfirmMove() {\n this.setTitle(getString('confirm', 'core'));\n this.setBody(getString('confirmmove', 'qbank_bulkmove'));\n if (!this.hasFooterContent()) {\n // We don't have the footer yet so go grab it and register event listeners on the buttons.\n this.setFooter(Templates.render('qbank_bulkmove/bulk_move_footer', {}));\n await this.getFooterPromise();\n\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CONFIRM_BUTTON).addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.moveQuestionsAfterConfirm(this.targetBankContextId, this.targetCategoryId);\n });\n\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CANCEL_BUTTON).addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.setTitle(getString('bulkmoveheader', 'qbank_bulkmove'));\n this.setBodyContent(Templates.renderForPromise('core/loading', {}));\n this.hideFooter();\n this.display(this.targetBankContextId, this.targetCategoryId);\n });\n } else {\n // We already have a footer so just show it.\n this.showFooter();\n }\n }\n\n /**\n * Update the category selector based on the selected question bank.\n *\n * @param {Number} selectedBankCmId\n * @return {Promise} Resolved when the update is complete.\n */\n updateCategorySelector(selectedBankCmId) {\n if (!selectedBankCmId) {\n this.updateCategorySelectorState(false);\n return Promise.resolve();\n } else {\n return Fragment.loadFragment(\n 'core_question',\n 'category_selector',\n this.contextId,\n {\n 'bankcmid': selectedBankCmId,\n }\n )\n .then((html, js) => {\n const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.QUESTION_CATEGORY_SELECTOR);\n return Templates.replaceNode(categorySelector, html, js);\n })\n .then(() => {\n document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING).classList.add('d-none');\n return this.enhanceSelects();\n })\n .catch(Notification.exception);\n }\n }\n\n /**\n * Disable/enable the enhanced category selector field.\n * @param {boolean} toEnable True to enable, false to disable the field.\n */\n updateCategorySelectorState(toEnable) {\n const warning = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING);\n const enhancedInput = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_ENHANCED_INPUT);\n const suggestionButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SUGGESTION);\n const selection = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SELECTION);\n\n if (toEnable) {\n warning.classList.add('d-none');\n enhancedInput.removeAttribute('disabled');\n suggestionButton.classList.remove('d-none');\n } else {\n warning.classList.remove('d-none');\n enhancedInput.setAttribute('disabled', 'disabled');\n suggestionButton.classList.add('d-none');\n selection.click(); // Clear selected category.\n }\n }\n\n /**\n * Disable the button if the selected category is the same as the one the questions already belong to. Enable it otherwise.\n */\n updateSaveButtonState() {\n const saveButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON);\n const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY);\n [this.targetCategoryId, this.targetBankContextId] = categorySelector.value.split(',');\n\n if (this.targetCategoryId && this.targetCategoryId !== this.currentCategoryId) {\n saveButton.removeAttribute('disabled');\n } else {\n saveButton.setAttribute('disabled', 'disabled');\n }\n }\n\n /**\n * Move the selected questions to their new target category.\n * @param {integer} targetContextId the target bank context id.\n * @param {integer} targetCategoryId the target question category id.\n * @return {Promise}\n */\n async moveQuestionsAfterConfirm(targetContextId, targetCategoryId) {\n await this.setBody(Templates.render('core/loading', {}));\n const qelements = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.SELECTED_QUESTIONS);\n const questionids = [];\n qelements.forEach((element) => {\n if (element.checked) {\n const name = element.getAttribute('name');\n questionids.push(name.substr(1, name.length));\n }\n });\n if (questionids.length === 0) {\n await Notification.exception('No questions selected');\n }\n\n try {\n window.location.href = await moveQuestions(\n targetContextId,\n targetCategoryId,\n questionids.join(),\n window.location.href\n );\n } catch (error) {\n await Notification.exception(error);\n }\n }\n\n /**\n * Take the provided select options and enhance them into auto-complete fields.\n *\n * @return {Promise}\n */\n async enhanceSelects() {\n const placeholder = await getString('searchbyname', 'mod_quiz');\n\n await AutoComplete.enhance(\n ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK,\n false,\n 'core_question/question_banks_datasource',\n placeholder,\n false,\n true,\n '',\n true,\n );\n\n await AutoComplete.enhance(\n ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY,\n false,\n null,\n placeholder,\n false,\n true,\n '',\n true,\n );\n }\n}\n"],"names":["ModalQuestionBankBulkmove","Modal","contextId","categoryId","document","addEventListener","e","trigger","target","className","getAttribute","preventDefault","create","title","show","configure","modalConfig","targetBankContextId","initSelectedCategoryId","removeOnClose","filter","URLSearchParams","window","location","href","get","filteredCategoryId","JSON","parse","_JSON$parse","category","values","currentCategoryId","targetCategoryId","this","display","super","currentBankContextId","displayPending","Pending","bodyPromise","Fragment","loadFragment","setBody","enhanceSelects","registerEnhancedEventListeners","updateSaveButtonState","resolve","querySelector","SELECTORS","SEARCH_CATEGORY","SEARCH_BANK","async","parseInt","value","updateCategorySelector","currentTarget","getModal","on","SAVE_BUTTON","displayConfirmMove","setTitle","hasFooterContent","showFooter","setFooter","Templates","render","getFooterPromise","CONFIRM_BUTTON","moveQuestionsAfterConfirm","CANCEL_BUTTON","setBodyContent","renderForPromise","hideFooter","selectedBankCmId","then","html","js","categorySelector","QUESTION_CATEGORY_SELECTOR","replaceNode","CATEGORY_WARNING","classList","add","catch","Notification","exception","updateCategorySelectorState","Promise","toEnable","warning","enhancedInput","CATEGORY_ENHANCED_INPUT","suggestionButton","CATEGORY_SUGGESTION","selection","CATEGORY_SELECTION","removeAttribute","remove","setAttribute","click","saveButton","split","targetContextId","qelements","querySelectorAll","SELECTED_QUESTIONS","questionids","forEach","element","checked","name","push","substr","length","join","error","placeholder","AutoComplete","enhance","CATEGORY_OPTIONS","BANK_OPTIONS","ORIGINAL_SELECTS"],"mappings":"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"}PK\f!!;bank/bulkmove/amd/build/modal_question_bank_bulkmove.min.jsnu[define("qbank_bulkmove/modal_question_bank_bulkmove",["exports","core/modal","core/fragment","core/str","core/form-autocomplete","core_question/repository","core/templates","core/notification","core/pending"],(function(_exports,_modal,Fragment,_str,_formAutocomplete,_repository,_templates,_notification,_pending){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=_interopRequireDefault(_modal),Fragment=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Fragment),_formAutocomplete=_interopRequireDefault(_formAutocomplete),_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);class ModalQuestionBankBulkmove extends _modal.default{static init(contextId,categoryId){document.addEventListener("click",(e=>{const trigger=e.target;"dropdown-item"===trigger.className&&"move"===trigger.getAttribute("name")&&(e.preventDefault(),ModalQuestionBankBulkmove.create({contextId:contextId,title:(0,_str.getString)("bulkmoveheader","qbank_bulkmove"),show:!0,categoryId:categoryId}))}))}configure(modalConfig){this.contextId=modalConfig.contextId,this.targetBankContextId=modalConfig.contextId,this.initSelectedCategoryId(modalConfig.categoryId),modalConfig.removeOnClose=!0,super.configure(modalConfig)}initSelectedCategoryId(categoryId){const filter=new URLSearchParams(window.location.href).get("filter");if(filter){var _JSON$parse;const filteredCategoryId=null===(_JSON$parse=JSON.parse(filter))||void 0===_JSON$parse?void 0:_JSON$parse.category.values[0];return this.currentCategoryId=filteredCategoryId>0?filteredCategoryId:null,void(this.targetCategoryId=filteredCategoryId)}this.currentCategoryId=categoryId,this.targetCategoryId=categoryId}show(){return this.display(this.contextId,this.currentCategoryId),super.show()}async display(currentBankContextId,currentCategoryId){const displayPending=new _pending.default("qbank_bulkmove/bulk_move_modal");this.bodyPromise=await Fragment.loadFragment("qbank_bulkmove","bulk_move",currentBankContextId,{categoryid:currentCategoryId}),await this.setBody(this.bodyPromise),await this.enhanceSelects(),this.registerEnhancedEventListeners(),this.updateSaveButtonState(),displayPending.resolve()}registerEnhancedEventListeners(){document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change",(()=>{this.updateSaveButtonState()})),document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change",(async e=>{0!==parseInt(e.target.value)?(await this.updateCategorySelector(e.currentTarget.value),this.updateSaveButtonState(),document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change",(()=>{this.updateSaveButtonState()}))):await this.updateCategorySelector(null)})),this.getModal().on("click",ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON,(e=>{e.preventDefault(),this.displayConfirmMove()}))}async displayConfirmMove(){this.setTitle((0,_str.getString)("confirm","core")),this.setBody((0,_str.getString)("confirmmove","qbank_bulkmove")),this.hasFooterContent()?this.showFooter():(this.setFooter(_templates.default.render("qbank_bulkmove/bulk_move_footer",{})),await this.getFooterPromise(),document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CONFIRM_BUTTON).addEventListener("click",(e=>{e.preventDefault(),this.moveQuestionsAfterConfirm(this.targetBankContextId,this.targetCategoryId)})),document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CANCEL_BUTTON).addEventListener("click",(e=>{e.preventDefault(),this.setTitle((0,_str.getString)("bulkmoveheader","qbank_bulkmove")),this.setBodyContent(_templates.default.renderForPromise("core/loading",{})),this.hideFooter(),this.display(this.targetBankContextId,this.targetCategoryId)})))}updateCategorySelector(selectedBankCmId){return selectedBankCmId?Fragment.loadFragment("core_question","category_selector",this.contextId,{bankcmid:selectedBankCmId}).then(((html,js)=>{const categorySelector=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.QUESTION_CATEGORY_SELECTOR);return _templates.default.replaceNode(categorySelector,html,js)})).then((()=>(document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING).classList.add("d-none"),this.enhanceSelects()))).catch(_notification.default.exception):(this.updateCategorySelectorState(!1),Promise.resolve())}updateCategorySelectorState(toEnable){const warning=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING),enhancedInput=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_ENHANCED_INPUT),suggestionButton=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SUGGESTION),selection=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SELECTION);toEnable?(warning.classList.add("d-none"),enhancedInput.removeAttribute("disabled"),suggestionButton.classList.remove("d-none")):(warning.classList.remove("d-none"),enhancedInput.setAttribute("disabled","disabled"),suggestionButton.classList.add("d-none"),selection.click())}updateSaveButtonState(){const saveButton=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON),categorySelector=document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY);[this.targetCategoryId,this.targetBankContextId]=categorySelector.value.split(","),this.targetCategoryId&&this.targetCategoryId!==this.currentCategoryId?saveButton.removeAttribute("disabled"):saveButton.setAttribute("disabled","disabled")}async moveQuestionsAfterConfirm(targetContextId,targetCategoryId){await this.setBody(_templates.default.render("core/loading",{}));const qelements=document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.SELECTED_QUESTIONS),questionids=[];qelements.forEach((element=>{if(element.checked){const name=element.getAttribute("name");questionids.push(name.substr(1,name.length))}})),0===questionids.length&&await _notification.default.exception("No questions selected");try{window.location.href=await(0,_repository.moveQuestions)(targetContextId,targetCategoryId,questionids.join(),window.location.href)}catch(error){await _notification.default.exception(error)}}async enhanceSelects(){const placeholder=await(0,_str.getString)("searchbyname","mod_quiz");await _formAutocomplete.default.enhance(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0),await _formAutocomplete.default.enhance(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY,!1,null,placeholder,!1,!0,"",!0)}}return _exports.default=ModalQuestionBankBulkmove,_defineProperty(ModalQuestionBankBulkmove,"TYPE","qbank_bulkmove/bulkmove"),_defineProperty(ModalQuestionBankBulkmove,"SELECTORS",{SAVE_BUTTON:'[data-action="bulkmovesave"]',SELECTED_QUESTIONS:'table#categoryquestions input[id^="checkq"]',SEARCH_BANK:"#searchbanks",SEARCH_CATEGORY:".selectcategory",QUESTION_CATEGORY_SELECTOR:".question_category_selector",CATEGORY_OPTIONS:".selectcategory option",BANK_OPTIONS:"#searchbanks option",CATEGORY_ENHANCED_INPUT:".search-categories input",ORIGINAL_SELECTS:"select.bulk-move",CATEGORY_WARNING:"#searchcatwarning",CATEGORY_SUGGESTION:".search-categories span.form-autocomplete-downarrow",CATEGORY_SELECTION:'.search-categories span[role="option"][data-active-selection="true"]',CONFIRM_BUTTON:'.bulk-move-footer button[data-action="save"]',CANCEL_BUTTON:'.bulk-move-footer button[data-action="cancel"]'}),_exports.default})); //# sourceMappingURL=modal_question_bank_bulkmove.min.js.mapPK\jNbank/bulkmove/UPGRADING.mdnu[# qbank_bulkmove Upgrade notes ## 5.0 ### Deprecated - qbank_bulkmove/helper::get_displaydata Superceded by a modal and webservice, see qbank_bulkmove/modal_question_bank_bulkmove and core_question_external\move_questions For more information see [MDL-71378](https://tracker.moodle.org/browse/MDL-71378) - qbank_bulkmove\output\renderer::render_bulk_move_form Superceded by qbank_bulkmove\output\bulk_move For more information see [MDL-71378](https://tracker.moodle.org/browse/MDL-71378) PK\t1#bank/bulkmove/tests/helper_test.phpnu[. namespace qbank_bulkmove; use core_question\local\bank\question_edit_contexts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); /** * Bulk move helper tests. * * @package qbank_bulkmove * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_bulkmove\helper */ final class helper_test extends \advanced_testcase { /** * @var false|object|\stdClass|null $cat */ protected $cat; /** * @var \stdClass $questiondata1 */ protected $questiondata1; /** * @var \stdClass $questiondata2 */ protected $questiondata2; /** * @var bool|\context|\context_course $context */ protected $context; /** * @var \core_question\local\bank\question_edit_contexts $contexts */ protected $contexts; /** * @var \stdClass $course */ protected $course; /** * @var array $rawdata */ protected $rawdata; /** * @var object $secondcategory */ protected $secondcategory; /** * Setup the test. */ protected function helper_setup(): void { $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); /** @var \core_question_generator $questiongenerator */ $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course. $this->course = $generator->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['name' => 'QBANK 1', 'course' => $this->course->id]); $this->context = \context_module::instance($qbank->cmid); // Create a question in the default category. $this->contexts = new question_edit_contexts($this->context); $this->cat = question_get_default_category($this->contexts->lowest()->id, true); $this->questiondata1 = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $this->cat->id]); // Create a second category to move questions. $this->secondcategory = $questiongenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $this->cat->id]); // Ensure the question is not in the cache. $cache = \cache::make('core', 'questiondata'); $cache->delete($this->questiondata1->id); $this->questiondata2 = $questiongenerator->create_question('numerical', null, ['name' => 'Example question second', 'category' => $this->cat->id]); // Ensure the question is not in the cache. $cache = \cache::make('core', 'questiondata'); $cache->delete($this->questiondata2->id); // Posted raw data. $this->rawdata = [ 'courseid' => $this->course->id, 'cat' => "{$this->cat->id},{$this->context->id}", 'qpage' => '0', "q{$this->questiondata1->id}" => '1', "q{$this->questiondata2->id}" => '1', 'move' => 'Move to' ]; } /** * Count how many questions in the list belong to the given category. * * @param string $categoryid a category id * @param array $questionids list of question ids * @return int */ private function count_category_questions(string $categoryid, array $questionids): int { global $DB; $this->assertNotEmpty($questionids); list($insql, $inparams) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED); $sql = "SELECT COUNT(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.id = :categoryid AND q.id $insql"; return $DB->count_records_sql($sql, array_merge(['categoryid' => $categoryid], $inparams)); } /** * Assert that the given category contains following questions * * @param string $categoryid a category id * @param array $questionids list of question ids * @return void */ protected function assert_category_contains_questions(string $categoryid, array $questionids) { // The category need to contain all the questions. $this->assertEquals(count($questionids), $this->count_category_questions($categoryid, $questionids)); } /** * Assert that the given category does not contain following questions * * @param string $categoryid a category id * @param array $questionids list of question ids * @return void */ protected function assert_category_does_not_contain_questions(string $categoryid, array $questionids) { // The category does not contain any question. $this->assertEquals(0, $this->count_category_questions($categoryid, $questionids)); } /** * Test bulk move of questions. * * @covers ::bulk_move_questions */ public function test_bulk_move_questions(): void { global $DB; $this->helper_setup(); // Get the processed question ids. $questionlist = $this->process_question_ids_test(); $questionids = array_map('intval', explode(',', $questionlist)); // Verify that the questions are available in the current view. $this->assert_category_contains_questions($this->cat->id, $questionids); helper::bulk_move_questions($questionlist, $this->secondcategory); // Verify the questions are not in the current category. $this->assert_category_does_not_contain_questions($this->cat->id, $questionids); // Verify the questions are in the new category. $this->assert_category_contains_questions($this->secondcategory->id, $questionids); } /** * Test the question processing and return the question list. * * @return mixed * @covers ::process_question_ids */ protected function process_question_ids_test() { // Test the raw data processing. list($questionids, $questionlist) = helper::process_question_ids($this->rawdata); $this->assertEquals([$this->questiondata1->id, $this->questiondata2->id], $questionids); $this->assertEquals("{$this->questiondata1->id},{$this->questiondata2->id}", $questionlist); return $questionlist; } /** * Test the question displaydata. * * @covers ::get_displaydata */ public function test_get_displaydata(): void { $this->helper_setup(); $contexts = new question_edit_contexts($this->context); $addcontexts = $contexts->having_cap('moodle/question:add'); $url = new \moodle_url('/question/bank/bulkmove/move.php'); $displaydata = \qbank_bulkmove\helper::get_displaydata($addcontexts, $url, $url); $this->assertDebuggingCalled(); $this->assertStringContainsString('Test question category 1', $displaydata['categorydropdown']); $this->assertStringContainsString('Default for QBANK 1', $displaydata['categorydropdown']); $this->assertEquals($url, $displaydata ['moveurl']); $this->assertEquals($url, $displaydata ['returnurl']); } } PK\pDD+bank/bulkmove/tests/behat/bulk_move.featurenu[@qbank @qbank_bulkmove Feature: Use the qbank plugin manager page for bulkmove In order to check the plugin behaviour with enable and disable Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | | Course 2 | C2 | 0 | | Course 3 | C3 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | teacher1 | C2 | editingteacher | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | | qbank | Question bank 1 | C1 | qbank1 | | qbank | Question bank 2 | C2 | qbank2 | | qbank | Question bank 3 | C3 | qbank3 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions 1 | | Activity module | qbank1 | Test questions 2 | | Activity module | qbank2 | Test questions 3 | | Activity module | qbank3 | Test questions 4 | | Activity module | qbank1 | Test questions 5 | | Activity module | quiz1 | Test questions 6 | | Course | C1 | Test questions | | Course | C1 | Moved questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions 1 | truefalse | First question | Answer the first question | | Test questions 2 | truefalse | Second question | Answer the second question | | Test questions 3 | truefalse | Third question | Answer the third question | | Test questions 4 | truefalse | Fourth question | Answer the fourth question | | Test questions 5 | truefalse | Fifth question | Answer the fifth question | | Test questions 6 | truefalse | Sixth question | Answer the sixth question | @javascript Scenario: Enable/disable bulk move questions bulk action from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Bulk move questions" And I click on "Disable" "link" in the "Bulk move questions" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions 1" And I click on "First question" "checkbox" And I click on "With selected" "button" Then I should not see question bulk action "move" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Bulk move questions" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions 1" And I click on "First question" "checkbox" And I click on "With selected" "button" And I should see question bulk action "move" @javascript Scenario: Selecting a shared question bank limits the available categories to those belonging to the selected bank. Given I log in as "teacher1" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions 1" And I click on "First question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And I open the autocomplete suggestions list in the ".search-categories" "css_element" And "Test questions 1" "autocomplete_suggestions" should exist And "Test questions 2" "autocomplete_suggestions" should not exist And "Test questions 3" "autocomplete_suggestions" should not exist And "Test questions 4" "autocomplete_suggestions" should not exist And "Test questions 5" "autocomplete_suggestions" should not exist And "Test questions 6" "autocomplete_suggestions" should exist When I open the autocomplete suggestions list in the ".search-banks" "css_element" Then I should not see "C3 - Question bank 3" in the ".search-banks" "css_element" And I click on "C1 - Question bank 1" item in the autocomplete list Then I should not see "Test questions 1" in the ".search-categories .form-autocomplete-selection" "css_element" And the field "selectcategory" matches value "Default for Question bank 1" And I open the autocomplete suggestions list in the ".search-categories" "css_element" And "Test questions 2 (1)" "autocomplete_suggestions" should exist And "Test questions 3" "autocomplete_suggestions" should not exist And "Test questions 4" "autocomplete_suggestions" should not exist And "Test questions 5" "autocomplete_suggestions" should exist @javascript Scenario: Move a question from one bank category to another. Given I log in as "teacher1" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions 1" And I click on "First question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And I open the autocomplete suggestions list in the ".search-categories" "css_element" And I click on "Test questions 6 (1)" item in the autocomplete list And I click on "Move questions" "button" Then I should see "Are you sure you want to move these questions?" And I click on "Confirm" "button" And I wait until the page is ready Then I should see "Questions successfully moved" @javascript Scenario: Questions can be bulk moved from the question bank Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question 1 | Answer the first question | | Test questions | missingtype | Question 2 | Write something | | Test questions | essay | Question 3 | frog | And I am on the "Course 1" "core_question > course question bank" page logged in as teacher1 And I apply question bank filter "Category" with value "Test questions" # Select questions to be moved. And I click on "Question 1" "checkbox" And I click on "Question 2" "checkbox" And I click on "With selected" "button" When I press "Move to" # Select a different category to move the questions into. And I open the autocomplete suggestions list in the ".search-categories" "css_element" And I click on "Moved questions" item in the autocomplete list And I press "Move questions" And I click on "Confirm" "button" # Confirm that selected questions are moved to selected category while unselected questions are not moved. Then I should see "Moved questions" And I should see "Question 1" And I should see "Question 2" And I should not see "Question 3" # No questions are highlighted when bulk-moved. And the "class" attribute of "Question 1" "table_row" should not contain "highlight" And the "class" attribute of "Question 2" "table_row" should not contain "highlight" @javascript Scenario: Move a question to a different bank in a specified category Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank2 | Test questions 3b | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question 1 | Answer the first question | And I am on the "Course 1" "core_question > course question bank" page logged in as teacher1 And I apply question bank filter "Category" with value "Test questions" And I click on "Question 1" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And I open the autocomplete suggestions list in the ".search-banks" "css_element" And I click on "C2 - Question bank 2" item in the autocomplete list And I open the autocomplete suggestions list in the ".search-categories" "css_element" And I click on "Test questions 3b" item in the autocomplete list And I click on "Move questions" "button" Then I should see "Are you sure you want to move these questions?" And I click on "Confirm" "button" # Confirm that selected questions are moved to selected category while unselected questions are not moved. Then I should see "Questions successfully moved" And I should see "Test questions 3b" And I should see "Question 1" And I should not see "Question 2" And I should not see "Question 3" @javascript Scenario: Unable to bulk move questions from history page Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions 1" And I choose "History" action for "First question" in the question bank And I click on "First question" "checkbox" And I click on "With selected" "button" Then I should see question bulk action "deleteselected" And I should not see question bulk action "move" @javascript Scenario: Questions can be moved to a different bank, if the user has the correct capability Given the following "role" exists: | name | Question adder | | shortname | adder | And the following "role capability" exists: | role | adder | | moodle/question:add | allow | And the following "course" exists: | fullname | Course 4 | | shortname | C4 | | category | 0 | And the following "course enrolment" exists: | course | C4 | | user | teacher1 | | role | adder | And the following "activity" exists: | activity | qbank | | course | C4 | | name | Question bank 4 | | idnumber | qbank4 | And the following "question category" exists: | contextlevel | Activity module | | reference | qbank4 | | name | Test questions 7 | Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions 1" And I press "Create a new question ..." And I set the field "item_qtype_truefalse" to "1" # Manually create a new question so additional parameters are included in the URL, and we can test they are handled correctly # during the move operation. And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I set the following fields to these values: | Question name | Seventh question | | Question text | test | And I press "id_submitbutton" And I click on "Seventh question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And the field "searchbanks" matches value "C1 - Test quiz" And the field "selectcategory" matches value "Test questions 1 (2)" And I open the autocomplete suggestions list in the ".search-banks" "css_element" And I should see "C1 - Question bank 1" in the ".search-banks .form-autocomplete-suggestions" "css_element" And I should see "C2 - Question bank 2" in the ".search-banks .form-autocomplete-suggestions" "css_element" And I should see "C4 - Question bank 4" in the ".search-banks .form-autocomplete-suggestions" "css_element" And I should not see "C3 - Question bank 3" in the ".search-banks .form-autocomplete-suggestions" "css_element" And I click on "C1 - Question bank 1" item in the autocomplete list And I open the autocomplete suggestions list in the ".question_category_selector" "css_element" And I click on "Test questions 2 (1)" item in the autocomplete list And I click on "Move questions" "button" And I should see "Are you sure you want to move these questions?" When I click on "Confirm" "button" Then I should see "Questions successfully moved" # The move dialogue should default to the new bank and category. And I click on "Seventh question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And the field "searchbanks" matches value "C1 - Question bank 1" And the field "selectcategory" matches value "Test questions 2 (2)" # The moved question should be highlighted And the "class" attribute of "Seventh question" "table_row" should contain "highlight" @javascript Scenario: A new question bank is available in the move dialogue immediately Given I am on the "C1" "Course" page logged in as "teacher1" And the following "user preferences" exist: | user | preference | value | | teacher1 | htmleditor | textarea | And I navigate to "Question banks" in current page administration And I press "Add" And I set the field "Question bank name" to "New question bank" And I press "Save and return to question bank list" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions 1" And I click on "First question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And I open the autocomplete suggestions list in the ".search-banks" "css_element" Then "New question bank" "autocomplete_suggestions" should exist @javascript Scenario: Clicking the dummy option for showing the reached limit in course does not throw an error Given the following "activities" exist: | activity | name | course | idnumber | | qbank | Question bank Course 1 1 | C1 | qbankc101 | | qbank | Question bank Course 1 2 | C1 | qbankc102 | | qbank | Question bank Course 1 3 | C1 | qbankc103 | | qbank | Question bank Course 1 4 | C1 | qbankc104 | | qbank | Question bank Course 1 5 | C1 | qbankc105 | | qbank | Question bank Course 1 6 | C1 | qbankc106 | | qbank | Question bank Course 1 7 | C1 | qbankc107 | | qbank | Question bank Course 1 8 | C1 | qbankc108 | | qbank | Question bank Course 1 9 | C1 | qbankc109 | | qbank | Question bank Course 1 10 | C1 | qbankc110 | | qbank | Question bank Course 2 1 | C2 | qbankc201 | | qbank | Question bank Course 2 2 | C2 | qbankc202 | | qbank | Question bank Course 2 3 | C2 | qbankc203 | | qbank | Question bank Course 2 4 | C2 | qbankc204 | | qbank | Question bank Course 2 5 | C2 | qbankc205 | | qbank | Question bank Course 2 6 | C2 | qbankc206 | | qbank | Question bank Course 2 7 | C2 | qbankc207 | | qbank | Question bank Course 2 8 | C2 | qbankc208 | | qbank | Question bank Course 2 9 | C2 | qbankc209 | | qbank | Question bank Course 2 10 | C2 | qbankc210 | | qbank | Question bank Course 3 1 | C3 | qbankc301 | | qbank | Question bank Course 3 2 | C3 | qbankc302 | | qbank | Question bank Course 3 2 | C3 | qbankc303 | | qbank | Question bank Course 3 4 | C3 | qbankc304 | | qbank | Question bank Course 3 5 | C3 | qbankc305 | | qbank | Question bank Course 3 6 | C3 | qbankc306 | | qbank | Question bank Course 3 7 | C3 | qbankc307 | | qbank | Question bank Course 3 8 | C3 | qbankc308 | | qbank | Question bank Course 3 9 | C3 | qbankc309 | | qbank | Question bank Course 3 10 | C3 | qbankc310 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C3 | editingteacher | When I am on the "C1" "Course" page logged in as "teacher1" And I navigate to "Question banks" in current page administration And I follow "Question bank 1" And I select "Categories" from the "Question bank tertiary navigation" singleselect And I follow "Test questions 5" And I click on "Fifth question" "checkbox" And I click on "With selected" "button" And I click on "move" "button" And I wait until the page is ready And "Question bank" "autocomplete_selection" should exist And I press the tab key And I press the tab key And I press the tab key And I type "Question bank Course" Then I click on "More than 20 results. You need to refine your search." item in the autocomplete list And "More than 20 results. You need to refine your search." "autocomplete_selection" should exist And I should see "You must select a question bank before you can select a category." PK\&!bank/editquestion/db/services.phpnu[. /** * External services definition for qbank_editquestion. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'qbank_editquestion_set_status' => [ 'classname' => 'qbank_editquestion\external\update_question_version_status', 'description' => 'Update the question status.', 'type' => 'write', 'ajax' => true, ], ]; PK\vrr0bank/editquestion/lang/en/qbank_editquestion.phpnu[. /** * Strings for component qbank_editquestion, language 'en'. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['filter:status'] = 'Status of latest version'; $string['filter:invalidstatus'] = 'Invalid value "{$a}" for status filter.'; $string['pluginname'] = 'Edit questions'; $string['privacy:metadata'] = 'The Edit questions question bank plugin does not store any personal data.'; // Question status. $string['questionstatus'] = 'Status'; $string['questionstatusready'] = 'Ready'; $string['questionstatushidden'] = 'Hidden'; $string['questionstatusdraft'] = 'Draft'; $string['questionstatusheader'] = 'Change question status'; $string['unrecognizedstatus'] = 'Unrecognised status'; // Edit form. $string['versioninfo'] = 'Version'; $string['status'] = 'Question status'; PK\s\  !bank/editquestion/addquestion.phpnu[. /** * Shows a screen where the user can choose a question type, before being redirected to question.php * * @package qbank_editquestion * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once(__DIR__ . '/../../editlib.php'); use qbank_editquestion\editquestion_helper; // Read URL parameters. $categoryid = required_param('category', PARAM_INT); $cmid = required_param('cmid', PARAM_INT); $returnurl = optional_param('returnurl', 0, PARAM_LOCALURL); $appendqnumstring = optional_param('appendqnumstring', '', PARAM_ALPHA); $validationerror = optional_param('validationerror', false, PARAM_BOOL); \core_question\local\bank\helper::require_plugin_enabled('qbank_editquestion'); // Place to accumulate hidden params for the form we will print. $hiddenparams = array('category' => $categoryid); // Validate params. if (!$category = $DB->get_record('question_categories', array('id' => $categoryid))) { throw new moodle_exception('categorydoesnotexist', 'question', $returnurl); } list($module, $cm) = get_module_from_cmid($cmid); require_login($cm->course, false, $cm); $thiscontext = context_module::instance($cmid); $hiddenparams['cmid'] = $cmid; // Check permissions. $categorycontext = context::instance_by_id($category->contextid); require_capability('moodle/question:add', $categorycontext); // Ensure other optional params get passed on to question.php. if (!empty($returnurl)) { $hiddenparams['returnurl'] = $returnurl; } if (!empty($appendqnumstring)) { $hiddenparams['appendqnumstring'] = $appendqnumstring; } $PAGE->set_url('/question/bank/editquestion/addquestion.php', $hiddenparams); $questionbankurl = new moodle_url('/question/edit.php', ['cmid' => $cmid]); navigation_node::override_active_url($questionbankurl); $chooseqtype = get_string('chooseqtypetoadd', 'question'); $PAGE->set_heading($COURSE->fullname); $PAGE->navbar->add($chooseqtype); $PAGE->set_title($chooseqtype); // Display a form to choose the question type. echo $OUTPUT->header(); echo $OUTPUT->notification(get_string('youmustselectaqtype', 'question')); echo $OUTPUT->box_start('generalbox boxwidthnormal boxaligncenter', 'chooseqtypebox'); echo editquestion_helper::print_choose_qtype_to_add_form($hiddenparams, null, false); echo $OUTPUT->box_end(); echo $OUTPUT->footer(); PK\ ||bank/editquestion/version.phpnu[. /** * Version information for qbank_editquestion. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_editquestion'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\U : :bank/editquestion/question.phpnu[. /** * Page for editing questions. * * @package qbank_editquestion * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once(__DIR__ . '/../../editlib.php'); require_once($CFG->libdir . '/filelib.php'); require_once($CFG->libdir . '/formslib.php'); // Read URL parameters telling us which question to edit. $id = optional_param('id', 0, PARAM_INT); // Question id. $makecopy = optional_param('makecopy', 0, PARAM_BOOL); $qtype = optional_param('qtype', '', PARAM_COMPONENT); $categoryid = optional_param('category', 0, PARAM_INT); $cmid = required_param('cmid', PARAM_INT); $wizardnow = optional_param('wizardnow', '', PARAM_ALPHA); $originalreturnurl = optional_param('returnurl', 0, PARAM_LOCALURL); $appendqnumstring = optional_param('appendqnumstring', '', PARAM_ALPHA); $inpopup = optional_param('inpopup', 0, PARAM_BOOL); $mdlscrollto = optional_param('mdlscrollto', 0, PARAM_INT); \core_question\local\bank\helper::require_plugin_enabled('qbank_editquestion'); $url = new moodle_url('/question/bank/editquestion/question.php'); if ($id !== 0) { $url->param('id', $id); } if ($makecopy) { $url->param('makecopy', $makecopy); } if ($qtype !== '') { $url->param('qtype', $qtype); } if ($categoryid !== 0) { $url->param('category', $categoryid); } if ($cmid !== 0) { $url->param('cmid', $cmid); } if ($wizardnow !== '') { $url->param('wizardnow', $wizardnow); } if ($originalreturnurl !== 0) { $url->param('returnurl', $originalreturnurl); } if ($appendqnumstring !== '') { $url->param('appendqnumstring', $appendqnumstring); } if ($inpopup !== 0) { $url->param('inpopup', $inpopup); } if ($mdlscrollto) { $url->param('mdlscrollto', $mdlscrollto); } $PAGE->set_url($url); $questionbankurl = new moodle_url('/question/edit.php', ['cmid' => $cmid]); navigation_node::override_active_url($questionbankurl); if ($originalreturnurl) { if (strpos($originalreturnurl, '/') !== 0) { throw new coding_exception("returnurl must be a local URL starting with '/'. $originalreturnurl was given."); } $returnurl = new moodle_url($originalreturnurl); } else { $returnurl = $questionbankurl; } if ($mdlscrollto) { $returnurl->param('mdlscrollto', $mdlscrollto); } list($module, $cm) = get_module_from_cmid($cmid); require_login($cm->course, false, $cm); $thiscontext = context_module::instance($cmid); $contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $PAGE->set_pagelayout('admin'); if (optional_param('addcancel', false, PARAM_BOOL)) { redirect($returnurl); } if ($id) { if (!$question = $DB->get_record('question', array('id' => $id))) { throw new moodle_exception('questiondoesnotexist', 'question', $returnurl); } get_question_options($question, true); } else if ($categoryid && $qtype) { // Only for creating new questions. $question = new stdClass(); $question->category = $categoryid; $question->qtype = $qtype; $question->createdby = $USER->id; // Check that users are allowed to create this question type at the moment. if (!question_bank::qtype_enabled($qtype)) { throw new moodle_exception('cannotenable', 'question', $returnurl, $qtype); } } else if ($categoryid) { // Category, but no qtype. They probably came from the addquestion.php // script without choosing a question type. Send them back. $addurl = new moodle_url('/question/bank/editquestion/addquestion.php', $url->params()); $addurl->param('validationerror', 1); redirect($addurl); } else { throw new moodle_exception('notenoughdatatoeditaquestion', 'question', $returnurl); } // If we get to the editing page from somewhere outside the question bank (such as the quiz) while migration is still // pending, redirect back with a warning. if (!\core_question\local\bank\question_bank_helper::has_bank_migration_task_completed_successfully()) { $defaultactivityname = \core_question\local\bank\question_bank_helper::get_default_question_bank_activity_name(); redirect( $returnurl, get_string('transfernotfinished', 'mod_' . $defaultactivityname), messagetype: \core\output\notification::NOTIFY_WARNING, ); } $qtypeobj = question_bank::get_qtype($question->qtype); if (isset($question->categoryobject)) { $category = $question->categoryobject; } else { // Validate the question category. if (!$category = $DB->get_record('question_categories', array('id' => $question->category))) { throw new moodle_exception('categorydoesnotexist', 'question', $returnurl); } } // Check permissions. $question->formoptions = new stdClass(); $categorycontext = context::instance_by_id($category->contextid); $question->contextid = $category->contextid; $addpermission = has_capability('moodle/question:add', $categorycontext); if ($id) { $question->formoptions->canedit = question_has_capability_on($question, 'edit'); $question->formoptions->canmove = $addpermission && question_has_capability_on($question, 'move'); $question->formoptions->cansaveasnew = $addpermission && (question_has_capability_on($question, 'view') || $question->formoptions->canedit); $question->formoptions->repeatelements = $question->formoptions->canedit || $question->formoptions->cansaveasnew; $formeditable = $question->formoptions->canedit || $question->formoptions->cansaveasnew || $question->formoptions->canmove; if (!$formeditable) { question_require_capability_on($question, 'view'); } $question->beingcopied = false; if ($makecopy) { // If we are duplicating a question, add some indication to the question name. $question->name = get_string('questionnamecopy', 'question', $question->name); $question->idnumber = isset($question->idnumber) ? core_question_find_next_unused_idnumber($question->idnumber, $category->id) : ''; $question->beingcopied = true; } } else { // Creating a new question. $question->formoptions->canedit = question_has_capability_on($question, 'edit'); $question->formoptions->canmove = (question_has_capability_on($question, 'move') && $addpermission); $question->formoptions->cansaveasnew = false; $question->formoptions->repeatelements = true; $formeditable = true; require_capability('moodle/question:add', $categorycontext); } $question->formoptions->mustbeusable = (bool) $appendqnumstring; // Validate the question type. $PAGE->set_pagetype('question-type-' . $question->qtype); // Create the question editing form. if ($wizardnow !== '') { $mform = $qtypeobj->next_wizard_form('question.php', $question, $wizardnow, $formeditable); } else { $mform = $qtypeobj->create_editing_form('question.php', $question, $category, $contexts, $formeditable); } $toform = fullclone($question); // Send the question object and a few more parameters to the form. $toform->category = "{$category->id},{$category->contextid}"; $toform->mdlscrollto = $mdlscrollto; if ($formeditable && $id) { $toform->categorymoveto = $toform->category; } $toform->appendqnumstring = $appendqnumstring; $toform->returnurl = $originalreturnurl; $toform->makecopy = $makecopy; $toform->idnumber = null; if (!empty($question->id)) { $questionobject = question_bank::load_question($question->id); $toform->status = $questionobject->status; $toform->idnumber = $questionobject->idnumber; } else { $toform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; } if ($makecopy) { $toform->idnumber = core_question_find_next_unused_idnumber($toform->idnumber, $category->id); } if ($cm !== null) { $toform->cmid = $cm->id; $toform->courseid = $cm->course; } else { $toform->courseid = $COURSE->id; } $toform->inpopup = $inpopup; // Prepare custom fields data. $customfieldhandler = qbank_customfields\customfield\question_handler::create(); $customfieldhandler->instance_form_before_set_data($toform); $mform->set_data($toform); if ($mform->is_cancelled()) { if ($inpopup) { close_window(); } else { redirect($returnurl); } } else if ($fromform = $mform->get_data()) { // If we are saving as a copy, break the connection to the old question. if ($makecopy) { $question->id = 0; // Copies should not be hidden. $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; } // If is will be added directly to a module send the module name to be referenced. if ($appendqnumstring && $cm) { $fromform->modulename = 'mod_' . $cm->modname; } // Process the combination of usecurrentcat, categorymoveto and category form // fields, so the save_question method only has to consider $fromform->category. if (empty($fromform->usecurrentcat) && !empty($fromform->categorymoveto)) { $fromform->category = $fromform->categorymoveto; } // If we are moving a question, check we have permission to move it from // whence it came (Where we are moving to is validated by the form). list($newcatid, $newcontextid) = explode(',', $fromform->category); if (!empty($question->id) && $newcatid != $question->categoryobject->id) { $contextid = $newcontextid; question_require_capability_on($question, 'move'); } else { $contextid = $category->contextid; } // Ensure we redirect back to the category the question is being saved into. $returnurl->param('category', $fromform->category); // We are actually saving the question. if (!empty($question->id)) { question_require_capability_on($question, 'edit'); } else { require_capability('moodle/question:add', context::instance_by_id($contextid)); if (!empty($fromform->makecopy) && !$question->formoptions->cansaveasnew) { throw new moodle_exception('nopermissions', '', '', 'edit'); } } // If this is a new question and question defaults save is enabled, save defaults for user in user_preferences table. if (empty($question->id) && !empty($CFG->questiondefaultssave)) { $qtypeobj->save_defaults_for_new_questions($fromform); } $question = $qtypeobj->save_question($question, $fromform); if (isset($fromform->tags)) { // If we have any question context level tags then set those tags now. core_tag_tag::set_item_tags('core_question', 'question', $question->id, context::instance_by_id($contextid), $fromform->tags, 0); } if (isset($fromform->coursetags)) { // If we have and course context level tags then set those now. core_tag_tag::set_item_tags('core_question', 'question', $question->id, context_course::instance($fromform->courseid), $fromform->coursetags, 0); } // Update custom fields if there are any of them in the form. $customfieldhandler->instance_form_save($fromform); // Purge this question from the cache. question_bank::notify_question_edited($question->id); // If we are saving and continuing to edit the question. if (!empty($fromform->updatebutton)) { $url->param('id', $question->id); $url->remove_params('makecopy'); redirect($url); } if ($qtypeobj->finished_edit_wizard($fromform)) { if ($inpopup) { echo $OUTPUT->notification(get_string('changessaved'), ''); close_window(3); } else { $returnurl->param('lastchanged', $question->id); if ($appendqnumstring) { $returnurl->param($appendqnumstring, $question->id); $returnurl->param('sesskey', sesskey()); $returnurl->param('cmid', $cmid); } // Update the filter param to the updated category if the return have any. if (!empty($returnurl->param('filter'))) { $filter = json_decode($returnurl->param('filter'), true); if (isset($filter['category']['values'])) { $filter['category']['values'][0] = $question->category; $returnurl->param('filter', json_encode($filter)); } } redirect($returnurl); } } else { $nexturlparams = [ 'returnurl' => $originalreturnurl, 'appendqnumstring' => $appendqnumstring, 'mdlscrollto' => $mdlscrollto, ]; if (isset($fromform->nextpageparam) && is_array($fromform->nextpageparam)) { // Useful for passing data to the next page which is not saved in the database. $nexturlparams += $fromform->nextpageparam; } $nexturlparams['id'] = $question->id; $nexturlparams['wizardnow'] = $fromform->wizard; $nexturl = new moodle_url($url, $nexturlparams); if ($cmid) { $nexturl->param('cmid', $cmid); } else { $nexturl->param('courseid', $COURSE->id); } // Update the filter param to the updated category if the return url have any. if (!empty($nexturl->param('filter'))) { $filter = json_decode($nexturl->param('filter'), true); if (isset($filter['category']['values'])) { $filter['category']['values'][0] = $question->category; $nexturl->param('filter', json_encode($filter)); } } redirect($nexturl); } } $streditingquestion = $qtypeobj->get_heading(); $PAGE->set_title($streditingquestion); $PAGE->set_heading($COURSE->fullname); $PAGE->activityheader->disable(); $PAGE->navbar->add($streditingquestion); if ($PAGE->course->id == $SITE->id) { $PAGE->set_primary_active_tab('home'); } // Display a heading, question editing form and possibly some extra content needed for // for this question type. echo $OUTPUT->header(); $qtypeobj->display_question_editing_page($mform, $question, $wizardnow); echo $OUTPUT->footer(); PK\T1bank/editquestion/classes/editquestion_helper.phpnu[. /** * Helper class for adding/editing a question. * * This code is based on question/editlib.php by Martin Dougiamas. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use core_question\local\bank\question_version_status; use qbank_editquestion\output\add_new_question; /** * Class editquestion_helper for methods related to add/edit/copy * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin */ class editquestion_helper { /** * Print a form to let the user choose which question type to add. * When the form is submitted, it goes to the question.php script. * * @param array|null $hiddenparams hidden parameters to add to the form, in addition to * the qtype radio buttons. * @param array|null $allowedqtypes optional list of qtypes that are allowed. If given, only * those qtypes will be shown. Example value array('description', 'multichoice'). * @param bool $enablejs * @return bool|string */ public static function print_choose_qtype_to_add_form(array $hiddenparams, ?array $allowedqtypes = null, $enablejs = true) { global $PAGE; $chooser = \qbank_editquestion\qbank_chooser::get($PAGE->course, $hiddenparams, $allowedqtypes); $renderer = $PAGE->get_renderer('qbank_editquestion'); return $renderer->render($chooser); } /** * Print a button for creating a new question. This will open question/addquestion.php, * which in turn goes to question/question.php before getting back to $params['returnurl'] * (by default the question bank screen). * * @param int $categoryid The id of the category that the new question should be added to. * @param array $params Other paramters to add to the URL. You need either $params['cmid'] or * $params['courseid'], and you should probably set $params['returnurl'] * @param bool $canadd the text to display on the button. * @param string $tooltip a tooltip to add to the button (optional). * @param bool $disabled if true, the button will be disabled. * @deprecated since Moodle 4.3. Use {@see add_new_question} renderable instead * @todo Final deprecation in Moodle 4.7 */ public static function create_new_question_button($categoryid, $params, $canadd, $tooltip = '', $disabled = false) { global $OUTPUT; debugging('create_new_question_button() is deprecated. Use the add_new_question renderable instead.'); return $OUTPUT->render(new add_new_question($categoryid, $params, $canadd)); } /** * Get the string for the status of the question. * * @param string $status * @return string */ public static function get_question_status_string($status): string { return get_string('questionstatus' . $status, 'qbank_editquestion'); } /** * Get the array of status of the questions. * * @param bool $includehidden Include QUESTION_STATUS_HIDDEN in the returned list? * @return array */ public static function get_question_status_list(bool $includehidden = false): array { $statuslist = []; $statuslist[question_version_status::QUESTION_STATUS_READY] = get_string('questionstatusready', 'qbank_editquestion'); $statuslist[question_version_status::QUESTION_STATUS_DRAFT] = get_string('questionstatusdraft', 'qbank_editquestion'); if ($includehidden) { $statuslist[question_version_status::QUESTION_STATUS_HIDDEN] = get_string('questionstatushidden', 'qbank_editquestion'); } return $statuslist; } } PK\71-QXXEbank/editquestion/classes/external/update_question_version_status.phpnu[. namespace qbank_editquestion\external; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/bank.php'); use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; use qbank_editquestion\editquestion_helper; use question_bank; /** * Update question status external api. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_question_version_status extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters. */ public static function execute_parameters() { return new external_function_parameters([ 'questionid' => new external_value(PARAM_INT, 'The question id'), 'status' => new external_value(PARAM_TEXT, 'The updated question status') ]); } /** * Handles the status form submission. * * @param int $questionid The question id. * @param string $status The updated question status. * @return array The created or modified question tag */ public static function execute($questionid, $status) { global $DB; $result = [ 'status' => false, 'statusname' => '', 'error' => '' ]; // Parameter validation. $params = self::validate_parameters(self::execute_parameters(), [ 'questionid' => $questionid, 'status' => $status ]); $statuslist = editquestion_helper::get_question_status_list(); $statusexists = array_key_exists($status, $statuslist); if (!$statusexists) { return [ 'status' => false, 'statusname' => '', 'error' => get_string('unrecognizedstatus', 'qbank_editquestion') ]; } $question = question_bank::load_question($params['questionid']); $editingcontext = \context::instance_by_id($question->contextid); self::validate_context($editingcontext); $canedit = question_has_capability_on($question, 'edit'); if ($canedit) { $versionrecord = $DB->get_record('question_versions', ['questionid' => $params['questionid']]); $versionrecord->status = $params['status']; $DB->update_record('question_versions', $versionrecord); question_bank::notify_question_edited($question->id); $result = [ 'status' => true, 'statusname' => editquestion_helper::get_question_status_string($versionrecord->status), 'error' => '' ]; $event = \core\event\question_updated::create_from_question_instance($question, $editingcontext); $event->trigger(); } return $result; } /** * Returns description of method result value. */ public static function execute_returns() { return new external_single_structure([ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'statusname' => new external_value(PARAM_RAW, 'statusname: name of the status'), 'error' => new external_value(PARAM_TEXT, 'Error message if error exists') ]); } } PK\?L.bank/editquestion/classes/privacy/provider.phpnu[. /** * Version information for qbank_editquestion. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion\privacy; /** * Privacy Subsystem for qbank_editquestion implementing null_provider. * * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\p/22+bank/editquestion/classes/qbank_chooser.phpnu[. /** * The qbank_chooser renderable. * * @package qbank_editquestion * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use context; use context_course; use core\output\chooser_section; use lang_string; use moodle_url; use question_bank; use stdClass; /** * The qbank_chooser renderable class. * * @package qbank_editquestion * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbank_chooser extends \core\output\chooser { /** * Constructor. * * @param array $real The real question types. * @param array $fake The fake question types. * @param stdClass $course The course. * @param array $hiddenparams Hidden parameters. * @param context $context The relevant context. */ public function __construct($real, $fake, $course, $hiddenparams, $context) { $sections = []; $sections[] = new chooser_section('questions', new lang_string('questions', 'question'), array_map(function($qtype) use ($context) { return new qbank_chooser_item($qtype, $context); }, $real)); if (!empty($fake)) { $sections[] = new chooser_section('other', new lang_string('other'), array_map(function ($qtype) use ($context) { return new qbank_chooser_item($qtype, $context); }, $fake)); } parent::__construct(new moodle_url('/question/bank/editquestion/question.php'), new lang_string('chooseqtypetoadd', 'question'), $sections, 'qtype'); $this->set_instructions(new lang_string('selectaqtypefordescription', 'question')); $this->set_method('get'); $this->add_param('courseid', $course->id); foreach ($hiddenparams as $k => $v) { $this->add_param($k, $v); } } /** * Get an instance of the question bank chooser. * * @param stdClass $course The course. * @param array $hiddenparams Hidden parameters. * @param array|null $allowedqtypes Allowed question types. * @return qbank_chooser */ public static function get($course, $hiddenparams, ?array $allowedqtypes = null): qbank_chooser { $realqtypes = array(); $fakeqtypes = array(); foreach (question_bank::get_creatable_qtypes() as $qtypename => $qtype) { if ($allowedqtypes && !in_array($qtypename, $allowedqtypes)) { continue; } if ($qtype->is_real_question_type()) { $realqtypes[] = $qtype; } else { $fakeqtypes[] = $qtype; } } return new static($realqtypes, $fakeqtypes, $course, $hiddenparams, context_course::instance($course->id)); } } PK\:.@ @ -bank/editquestion/classes/output/renderer.phpnu[. /** * Renderer for adding/editing a question. * * This code is based on question/renderer.php by The Open University. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion\output; /** * Renderer for add/edit/copy * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render a qbank_chooser. * * @param \renderable $qbankchooser The chooser. * @return string */ public function render_qbank_chooser(\renderable $qbankchooser) { return $this->render_from_template('qbank_editquestion/qbank_chooser', $qbankchooser->export_for_template($this)); } /** * Render add question button. * * @param array $addquestiondata * @return bool|string * @deprecated since Moodle 4.3. Use {@see add_new_question} renderable instead * @todo Final deprecation in Moodle 4.7 */ public function render_create_new_question_button($addquestiondata) { debugging('render_create_new_question_button() is deprecated. ' . 'Pass the add_new_question renderable to render() instead.'); return $this->render_from_template('qbank_editquestion/add_new_question', $addquestiondata); } /** * Render question information for edit form. * * @param array $questiondata * @return bool|string */ public function render_question_info($questiondata) { return $this->render_from_template('qbank_editquestion/question_info', $questiondata); } /** * Render status dropdown. * * @param array $dropdownoptions * @return bool|string */ public function render_status_dropdown($dropdownoptions) { return $this->render_from_template('qbank_editquestion/question_status_dropdown', $dropdownoptions); } } PK\|, , 5bank/editquestion/classes/output/add_new_question.phpnu[. namespace qbank_editquestion\output; use qbank_editquestion\editquestion_helper; use renderer_base; /** * Create new question button * * @package qbank_editquestion * @copyright 2023 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 add_new_question implements \renderable, \templatable { /** @var int $categoryid The ID of the category the quesiton will be added to. */ protected int $categoryid; /** @var array $params URL parameters to pass to the add question form. */ protected array $params; /** @var bool $canadd True if the add question button should be displayed. If false, a placeholder will be shown.*/ protected bool $canadd; /** * Store data for building the template context. * * @param int $categoryid * @param array $params * @param bool $canadd */ public function __construct(int $categoryid, array $params, bool $canadd) { $this->categoryid = $categoryid; $this->params = $params; $this->canadd = $canadd; } public function export_for_template(renderer_base $output): array { $addquestiondisplay = []; $addquestiondisplay['canadd'] = $this->canadd; if ($this->canadd) { $this->params['category'] = $this->categoryid; $url = new \moodle_url('/question/bank/editquestion/addquestion.php', $this->params); $addquestiondisplay['buttonhtml'] = $output->single_button( $url, get_string('createnewquestion', 'question'), 'get', ['disabled' => 'disabled'], ); $addquestiondisplay['qtypeform'] = editquestion_helper::print_choose_qtype_to_add_form([]); } return $addquestiondisplay; } } PK\xIm m )bank/editquestion/classes/edit_action.phpnu[. /** * Class for question bank edit question column. * * @package qbank_editquestion * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use core_question\local\bank\question_action_base; use moodle_url; /** * Class for question bank edit question column. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_action extends question_action_base { /** * Contains the string. * @var string */ protected $stredit; /** * Contains the string. * @var string */ protected $strview; /** * Contains the url of the edit question page. * @var moodle_url|string */ public $editquestionurl; public function init(): void { parent::init(); $this->stredit = get_string('editquestion', 'question'); $this->strview = get_string('view'); $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', array('returnurl' => $this->qbank->returnurl)); $this->editquestionurl->param('cmid', $this->qbank->cm->id); } public function get_menu_position(): int { return 200; } /** * Get the URL for editing a question as a link. * * @param int $questionid the question id. * @return moodle_url the URL, HTML-escaped. */ public function edit_question_moodle_url($questionid): moodle_url { return new moodle_url($this->editquestionurl, ['id' => $questionid]); } protected function get_url_icon_and_label(\stdClass $question): array { if (!\question_bank::is_qtype_installed($question->qtype)) { // It sometimes happens that people end up with junk questions // in their question bank of a type that is no longer installed. // We cannot do most actions on them, because that leads to errors. return [null, null, null]; } if (question_has_capability_on($question, 'edit')) { return [$this->edit_question_moodle_url($question->id), 't/edit', $this->stredit]; } else if (question_has_capability_on($question, 'view')) { return [$this->edit_question_moodle_url($question->id), 'i/info', $this->strview]; } else { return [null, null, null]; } } } PK\>E 4bank/editquestion/classes/question_status_column.phpnu[. namespace qbank_editquestion; use core_question\local\bank\column_base; use core_question\local\bank\question_version_status; /** * A column to show the status of the question. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_status_column extends column_base { public function get_name(): string { return 'questionstatus'; } public function get_title(): string { return get_string('questionstatus', 'qbank_editquestion'); } protected function display_content($question, $rowclasses): void { global $PAGE; if (question_has_capability_on($question, 'edit') && $question->status !== question_version_status::QUESTION_STATUS_HIDDEN) { $options = []; $options['questionid'] = $question->id; $statuslist = editquestion_helper::get_question_status_list(); foreach ($statuslist as $value => $displaystatus) { $options['options'][] = [ 'name' => $displaystatus, 'value' => $value, 'selected' => ($question->status) === $value ? true : false ]; } echo $PAGE->get_renderer('qbank_editquestion')->render_status_dropdown($options); $PAGE->requires->js_call_amd('qbank_editquestion/question_status', 'init', [$question->id]); } else { $statuslist = editquestion_helper::get_question_status_list(true); echo $statuslist[$question->status]; } } public function get_extra_classes(): array { return ['pe-3']; } } PK\5V )bank/editquestion/classes/copy_action.phpnu[. /** * Question bank column for the duplicate action icon. * * @package qbank_editquestion * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use core_question\local\bank\question_action_base; use moodle_url; /** * Question bank column for the duplicate action icon. * * @copyright 2013 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class copy_action extends question_action_base { /** @var string avoids repeated calls to get_string('duplicate'). */ protected $strcopy; /** * Contains the url of the edit question page. * @var moodle_url|string */ public $duplicatequestionurl; public function init(): void { parent::init(); $this->strcopy = get_string('duplicate'); $this->duplicatequestionurl = new \moodle_url('/question/bank/editquestion/question.php', array('returnurl' => $this->qbank->returnurl)); $this->duplicatequestionurl->param('cmid', $this->qbank->cm->id); } public function get_menu_position(): int { return 250; } /** * Get the URL for duplicating a question as a moodle_url. * * @param int $questionid the question id. * @return \moodle_url the URL. */ public function duplicate_question_moodle_url($questionid): moodle_url { return new \moodle_url($this->duplicatequestionurl, ['id' => $questionid, 'makecopy' => 1]); } protected function get_url_icon_and_label(\stdClass $question): array { if (!\question_bank::is_qtype_installed($question->qtype)) { // It sometimes happens that people end up with junk questions // in their question bank of a type that is no longer installed. // We cannot do most actions on them, because that leads to errors. return [null, null, null]; } // To copy a question, you need permission to add a question in the same // category as the existing question, and ability to access the details of // the question being copied. if (question_has_capability_on($question, 'add') && (question_has_capability_on($question, 'edit') || question_has_capability_on($question, 'view'))) { return [$this->duplicate_question_moodle_url($question->id), 't/copy', $this->strcopy]; } return [null, null, null]; } } PK\#0bank/editquestion/classes/qbank_chooser_item.phpnu[. /** * The qbank_chooser_item renderable. * * @package qbank_editquestion * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use context; use lang_string; use pix_icon; /** * The qbank_chooser_item renderable class. * * @package qbank_editquestion * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbank_chooser_item extends \core\output\chooser_item { /** * Constructor. * * @param object $qtype The question type. * @param context $context The relevant context. */ public function __construct($qtype, $context) { $icon = new pix_icon('icon', $qtype->local_name(), $qtype->plugin_name(), [ 'class' => 'icon', 'title' => $qtype->local_name() ]); $help = new lang_string('pluginnamesummary', $qtype->plugin_name()); parent::__construct($qtype->plugin_name(), $qtype->menu_name(), $qtype->name(), $icon, $help, $context); } } PK\ՕL L ,bank/editquestion/classes/plugin_feature.phpnu[. /** * Plugin entrypoint for columns. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_editquestion; use core\context; use core_question\local\bank\view; use qbank_editquestion\output\add_new_question; /** * Class columns is the entrypoint for the columns. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { public function get_question_columns($qbank): array { return [ new question_status_column($qbank), ]; } public function get_question_actions(view $qbank): array { return [ new edit_action($qbank), new copy_action($qbank), ]; } /** * Return "Add new question" control. * * @param view $qbank The question bank view. * @param context $context The current context, for permission checks. * @param int $categoryid The current question category ID. * @return \renderable[] */ public function get_question_bank_controls(view $qbank, context $context, int $categoryid): array { if (!$qbank->allow_add_questions()) { return []; } $canadd = has_capability('moodle/question:add', $context); $urlparams = (new edit_action($qbank))->editquestionurl->params(); return [ 100 => new add_new_question($categoryid, $urlparams, $canadd), ]; } #[\Override] public function get_question_filters(?view $qbank = null): array { return [ new status_condition($qbank), ]; } } PK\ī .bank/editquestion/classes/status_condition.phpnu[. namespace qbank_editquestion; use core_question\local\bank\condition; use core_question\local\bank\question_version_status; /** * Filter condition for the status column * * @package qbank_editquestion * @copyright 2024 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 status_condition extends condition { #[\Override] public function get_title() { return get_string('filter:status', 'qbank_editquestion'); } #[\Override] public static function get_condition_key() { return 'status'; } #[\Override] public function get_filter_class() { return 'qbank_editquestion/datafilter/filtertypes/status'; } /** * Return a single join type, we don't want a join type selector for this condition. * * @return array */ public function get_join_list(): array { return [ self::JOINTYPE_DEFAULT, ]; } /** * Return an array mapping the values returned from the filter to the values required for the query. * * @return array */ protected static function get_status_list() { return [ 0 => question_version_status::QUESTION_STATUS_READY, 1 => question_version_status::QUESTION_STATUS_DRAFT, ]; } /** * Return an SQL condition to filter qv.status on the selected status. * * @param array $filter * @return array */ public static function build_query_from_filter(array $filter): array { if (!isset($filter['values'][0])) { return ['', []]; } $statuses = self::get_status_list(); if (!array_key_exists($filter['values'][0], $statuses)) { throw new \moodle_exception('filter:invalidstatus', 'qbank_editquestion', '', $filter['values'][0]); } return [ 'qv.status = :status', ['status' => $statuses[$filter['values'][0]]], ]; } } PK\||=bank/editquestion/templates/question_status_dropdown.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_editquestion/question_status_dropdown Dropdrown selector for question status in question bank. Context variables required for this template: * options - list of options for question status containing name, value, selected. * questionid - Question id for status selection. Example context (json): { "options" : [ { "name": "Ready", "value": "ready", "selected": true } ], "questionid" : "12" } }} PK\1!Q2bank/editquestion/templates/question_info.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_editquestion/question_info Example context (json): { "elements": [ {"pluginhtml":"
Version: 1
"}, {"pluginhtml":"
Usage: 1
"} ] } }}
{{#editelements}} {{{pluginhtml}}} {{/editelements}}
PK\j95bank/editquestion/templates/add_new_question.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_editquestion/add_new_question Example context (json): { "addquestiondata": [ { "canadd": true, "buttonhtml": "HTML for button", "qtypeform": "html for qtype" } ] } }}
{{^canadd}} {{#str}} nopermissionadd, question{{/str}} {{/canadd}} {{#canadd}} {{{buttonhtml}}}
{{{qtypeform}}}
{{/canadd}}
PK\^82bank/editquestion/templates/qbank_chooser.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_editquestion/qbank_chooser }} {{> core/chooser }} {{#js}} require([ 'core/yui' ], function(Y) { Y.use('moodle-qbank_editquestion-chooser', function() { M.question.init_chooser(); }); }); {{/js}} PK\O-,bank/editquestion/yui/src/chooser/build.jsonnu[{ "name": "moodle-qbank_editquestion-chooser", "builds": { "moodle-qbank_editquestion-chooser": { "jsfiles": [ "chooser.js" ] } } } PK\/f3bank/editquestion/yui/src/chooser/meta/chooser.jsonnu[{ "moodle-qbank_editquestion-chooser": { "requires": [ "moodle-core-chooserdialogue" ] } } PK\&| /bank/editquestion/yui/src/chooser/js/chooser.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript required by the add question pop-up. * * @module moodle-qbank_editquestion-chooser */ var SELECTORS = { CREATENEWQUESTION: 'div.createnewquestion', CREATENEWQUESTIONFORM: 'div.createnewquestion form', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; function Chooser() { Chooser.superclass.constructor.apply(this, arguments); } Y.extend(Chooser, M.core.chooserdialogue, { initializer: function() { Y.all(SELECTORS.CREATENEWQUESTIONFORM).each(function(createForm) { if (createForm.get('id') === 'chooserform') { // Not the singlebutton form. Ignore. return; } createForm.on('submit', this.displayQuestionChooser, this); createForm.one('button').set('disabled', false); }, this); }, displayQuestionChooser: function(e) { var dialogue = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERDIALOGUE), header = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERHEADER); if (this.container === null) { // Setup the dialogue, and then prepare the chooser if it's not already been set up. this.setup_chooser_dialogue(dialogue, header, {}); this.prepare_chooser(); } // Update all of the hidden fields within the questionbank form. var originForm = e.target.ancestor('form', true), targetForm = this.container.one('form'), hiddenElements = originForm.all('input[type="hidden"]'); targetForm.all('input.customfield').remove(); hiddenElements.each(function(field) { targetForm.appendChild(field.cloneNode()) .removeAttribute('id') .addClass('customfield'); }); // Display the chooser dialogue. this.display_chooser(e); } }, { NAME: 'questionChooser' }); M.question = M.question || {}; M.question.init_chooser = function(config) { return new Chooser(config); }; PK\WASfbank/editquestion/yui/build/moodle-qbank_editquestion-chooser/moodle-qbank_editquestion-chooser-min.jsnu[YUI.add("moodle-qbank_editquestion-chooser",function(n,e){var s="div.createnewquestion",o="div.createnewquestion form",r="div.chooserdialoguebody",u="div.choosertitle";function i(){i.superclass.constructor.apply(this,arguments)}n.extend(i,M.core.chooserdialogue,{initializer:function(){n.all(o).each(function(e){"chooserform"!==e.get("id")&&(e.on("submit",this.displayQuestionChooser,this),e.one("button").set("disabled",!1))},this)},displayQuestionChooser:function(e){var o,i=n.one(s+" "+r),t=n.one(s+" "+u);null===this.container&&(this.setup_chooser_dialogue(i,t,{}),this.prepare_chooser()),i=e.target.ancestor("form",!0),o=this.container.one("form"),t=i.all('input[type="hidden"]'),o.all("input.customfield").remove(),t.each(function(e){o.appendChild(e.cloneNode()).removeAttribute("id").addClass("customfield")}),this.display_chooser(e)}},{NAME:"questionChooser"}),M.question=M.question||{},M.question.init_chooser=function(e){return new i(e)}},"@VERSION@",{requires:["moodle-core-chooserdialogue"]});PK\/fA A bbank/editquestion/yui/build/moodle-qbank_editquestion-chooser/moodle-qbank_editquestion-chooser.jsnu[YUI.add('moodle-qbank_editquestion-chooser', function (Y, NAME) { // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript required by the add question pop-up. * * @module moodle-qbank_editquestion-chooser */ var SELECTORS = { CREATENEWQUESTION: 'div.createnewquestion', CREATENEWQUESTIONFORM: 'div.createnewquestion form', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; function Chooser() { Chooser.superclass.constructor.apply(this, arguments); } Y.extend(Chooser, M.core.chooserdialogue, { initializer: function() { Y.all(SELECTORS.CREATENEWQUESTIONFORM).each(function(createForm) { if (createForm.get('id') === 'chooserform') { // Not the singlebutton form. Ignore. return; } createForm.on('submit', this.displayQuestionChooser, this); createForm.one('button').set('disabled', false); }, this); }, displayQuestionChooser: function(e) { var dialogue = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERDIALOGUE), header = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERHEADER); if (this.container === null) { // Setup the dialogue, and then prepare the chooser if it's not already been set up. this.setup_chooser_dialogue(dialogue, header, {}); this.prepare_chooser(); } // Update all of the hidden fields within the questionbank form. var originForm = e.target.ancestor('form', true), targetForm = this.container.one('form'), hiddenElements = originForm.all('input[type="hidden"]'); targetForm.all('input.customfield').remove(); hiddenElements.each(function(field) { targetForm.appendChild(field.cloneNode()) .removeAttribute('id') .addClass('customfield'); }); // Display the chooser dialogue. this.display_chooser(e); } }, { NAME: 'questionChooser' }); M.question = M.question || {}; M.question.init_chooser = function(config) { return new Chooser(config); }; }, '@VERSION@', {"requires": ["moodle-core-chooserdialogue"]}); PK\/fA A hbank/editquestion/yui/build/moodle-qbank_editquestion-chooser/moodle-qbank_editquestion-chooser-debug.jsnu[YUI.add('moodle-qbank_editquestion-chooser', function (Y, NAME) { // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript required by the add question pop-up. * * @module moodle-qbank_editquestion-chooser */ var SELECTORS = { CREATENEWQUESTION: 'div.createnewquestion', CREATENEWQUESTIONFORM: 'div.createnewquestion form', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; function Chooser() { Chooser.superclass.constructor.apply(this, arguments); } Y.extend(Chooser, M.core.chooserdialogue, { initializer: function() { Y.all(SELECTORS.CREATENEWQUESTIONFORM).each(function(createForm) { if (createForm.get('id') === 'chooserform') { // Not the singlebutton form. Ignore. return; } createForm.on('submit', this.displayQuestionChooser, this); createForm.one('button').set('disabled', false); }, this); }, displayQuestionChooser: function(e) { var dialogue = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERDIALOGUE), header = Y.one(SELECTORS.CREATENEWQUESTION + ' ' + SELECTORS.CHOOSERHEADER); if (this.container === null) { // Setup the dialogue, and then prepare the chooser if it's not already been set up. this.setup_chooser_dialogue(dialogue, header, {}); this.prepare_chooser(); } // Update all of the hidden fields within the questionbank form. var originForm = e.target.ancestor('form', true), targetForm = this.container.one('form'), hiddenElements = originForm.all('input[type="hidden"]'); targetForm.all('input.customfield').remove(); hiddenElements.each(function(field) { targetForm.appendChild(field.cloneNode()) .removeAttribute('id') .addClass('customfield'); }); // Display the chooser dialogue. this.display_chooser(e); } }, { NAME: 'questionChooser' }); M.question = M.question || {}; M.question.init_chooser = function(config) { return new Chooser(config); }; }, '@VERSION@', {"requires": ["moodle-core-chooserdialogue"]}); PK\fn,bank/editquestion/amd/src/question_status.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Status column selector js. * * @module qbank_editquestion/question_status * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Ajax from 'core/ajax'; import Notification from 'core/notification'; /** * Set the question status. * * @param {Number} questionId The question id. * @param {String} status The updated question status. * @return {Array} The modified question status */ const setQuestionStatus = (questionId, status) => Ajax.call([{ methodname: 'qbank_editquestion_set_status', args: { questionid: questionId, status: status } }])[0]; /** * Entrypoint of the js. * * @method init * @param {Number} questionId Question id. */ export const init = (questionId) => { let target = document.querySelector('#question_status_dropdown-' + questionId); target.addEventListener('change', (e) => { const questionStatus = e.target.value; setQuestionStatus(questionId, questionStatus) .then((response) => { if (response.error) { Notification.addNotification({ type: 'error', message: response.error }); } return; }).catch(); }); }; PK\*[:bank/editquestion/amd/src/datafilter/filtertypes/status.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Filter for Status - Ready/Draft * * @module qbank_editquestion/datafilter/filtertypes/status * @author Mark Johnson * @copyright 2024 Catalyst IT Europe Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Binary from 'core/datafilter/filtertypes/binary'; import {get_strings as getStrings} from 'core/str'; export default class extends Binary { /** * Return strings for Draft/Ready statuses. * * @returns {Promise} */ getTextValues() { return getStrings([ {key: 'questionstatusready', component: 'qbank_editquestion'}, {key: 'questionstatusdraft', component: 'qbank_editquestion'} ]); } } PK\2bank/editquestion/amd/build/question_status.min.jsnu[define("qbank_editquestion/question_status",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Status column selector js. * * @module qbank_editquestion/question_status * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);_exports.init=questionId=>{document.querySelector("#question_status_dropdown-"+questionId).addEventListener("change",(e=>{const questionStatus=e.target.value;((questionId,status)=>_ajax.default.call([{methodname:"qbank_editquestion_set_status",args:{questionid:questionId,status:status}}])[0])(questionId,questionStatus).then((response=>{response.error&&_notification.default.addNotification({type:"error",message:response.error})})).catch()}))}})); //# sourceMappingURL=question_status.min.js.mapPK\{ 6bank/editquestion/amd/build/question_status.min.js.mapnu[{"version":3,"file":"question_status.min.js","sources":["../src/question_status.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Status column selector js.\n *\n * @module qbank_editquestion/question_status\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\n\n/**\n * Set the question status.\n *\n * @param {Number} questionId The question id.\n * @param {String} status The updated question status.\n * @return {Array} The modified question status\n */\nconst setQuestionStatus = (questionId, status) => Ajax.call([{\n methodname: 'qbank_editquestion_set_status',\n args: {\n questionid: questionId,\n status: status\n }\n}])[0];\n\n/**\n * Entrypoint of the js.\n *\n * @method init\n * @param {Number} questionId Question id.\n */\nexport const init = (questionId) => {\n let target = document.querySelector('#question_status_dropdown-' + questionId);\n target.addEventListener('change', (e) => {\n const questionStatus = e.target.value;\n setQuestionStatus(questionId, questionStatus)\n .then((response) => {\n if (response.error) {\n Notification.addNotification({\n type: 'error',\n message: response.error\n });\n }\n return;\n }).catch();\n });\n};\n"],"names":["questionId","document","querySelector","addEventListener","e","questionStatus","target","value","status","Ajax","call","methodname","args","questionid","setQuestionStatus","then","response","error","addNotification","type","message","catch"],"mappings":";;;;;;;;wLAgDqBA,aACJC,SAASC,cAAc,6BAA+BF,YAC5DG,iBAAiB,UAAWC,UACzBC,eAAiBD,EAAEE,OAAOC,MAjBd,EAACP,WAAYQ,SAAWC,cAAKC,KAAK,CAAC,CACzDC,WAAY,gCACZC,KAAM,CACFC,WAAYb,WACZQ,OAAQA,WAEZ,GAYIM,CAAkBd,WAAYK,gBAC7BU,MAAMC,WACCA,SAASC,6BACIC,gBAAgB,CACzBC,KAAM,QACNC,QAASJ,SAASC,WAI3BI"}PK\Dbank/editquestion/amd/build/datafilter/filtertypes/status.min.js.mapnu[{"version":3,"file":"status.min.js","sources":["../../../src/datafilter/filtertypes/status.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Filter for Status - Ready/Draft\n *\n * @module qbank_editquestion/datafilter/filtertypes/status\n * @author Mark Johnson \n * @copyright 2024 Catalyst IT Europe Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Binary from 'core/datafilter/filtertypes/binary';\nimport {get_strings as getStrings} from 'core/str';\n\nexport default class extends Binary {\n /**\n * Return strings for Draft/Ready statuses.\n *\n * @returns {Promise}\n */\n getTextValues() {\n return getStrings([\n {key: 'questionstatusready', component: 'qbank_editquestion'},\n {key: 'questionstatusdraft', component: 'qbank_editquestion'}\n ]);\n }\n}\n"],"names":["Binary","getTextValues","key","component"],"mappings":";;;;;;;;oKA2B6BA,gBAMzBC,uBACW,oBAAW,CACd,CAACC,IAAK,sBAAuBC,UAAW,sBACxC,CAACD,IAAK,sBAAuBC,UAAW"}PK\z=zz@bank/editquestion/amd/build/datafilter/filtertypes/status.min.jsnu[define("qbank_editquestion/datafilter/filtertypes/status",["exports","core/datafilter/filtertypes/binary","core/str"],(function(_exports,_binary,_str){var obj; /** * Filter for Status - Ready/Draft * * @module qbank_editquestion/datafilter/filtertypes/status * @author Mark Johnson * @copyright 2024 Catalyst IT Europe Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_binary=(obj=_binary)&&obj.__esModule?obj:{default:obj};class _default extends _binary.default{getTextValues(){return(0,_str.get_strings)([{key:"questionstatusready",component:"qbank_editquestion"},{key:"questionstatusdraft",component:"qbank_editquestion"}])}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=status.min.js.mapPK\nnHbank/editquestion/tests/external/update_question_version_status_test.phpnu[. namespace qbank_editquestion; use core_question\local\bank\question_version_status; use qbank_editquestion\external\update_question_version_status; /** * Submit status external api test. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @covers \core_question\local\bank\question_version_status * @coversDefaultClass \qbank_editquestion\external\update_question_version_status */ final class update_question_version_status_test extends \advanced_testcase { /** @var \stdClass course record. */ protected $course; /** @var mixed. */ protected $user; /** * Called before every test. */ public function setUp(): void { global $USER; parent::setUp(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->user = $USER; } /** * Test if the submit status webservice changes the status of the question. * * @covers ::execute * @covers \qbank_editquestion\editquestion_helper::get_question_status_string */ public function test_submit_status_updates_the_question_status(): void { global $DB; $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $numq = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); $result = update_question_version_status::execute($numq->id, 'draft'); // Test if the version actually changed. $currentstatus = $DB->get_record('question_versions', ['questionid' => $numq->id]); $this->assertEquals(editquestion_helper::get_question_status_string($currentstatus->status), $result['statusname']); } /** * Test submit status webservice only takes an existing parameter status. * * @covers ::execute */ public function test_submit_status_error(): void { global $DB; $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $numq = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Passing a wrong status to web service. $result = update_question_version_status::execute($numq->id, 'frog'); // Tests web service returns error. $this->assertEquals(false, $result['status']); $this->assertEquals('', $result['statusname']); $this->assertEquals(get_string('unrecognizedstatus', 'qbank_editquestion'), $result['error']); // Test version did not change. $currentstatus = $DB->get_record('question_versions', ['questionid' => $numq->id]); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $currentstatus->status); } /** * Test that updating the status does not create a new version. * * @covers ::execute */ public function test_submit_status_does_not_create_a_new_version(): void { global $DB; $this->resetAfterTest(); // Find out the start count in 'question_versions' table. $versioncount = $DB->count_records('question_versions'); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $numq = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); $countcurrentrecords = $DB->count_records('question_versions'); // New version count should be equal to start + 1. $this->assertEquals($versioncount + 1, $countcurrentrecords); $result = update_question_version_status::execute($numq->id, 'draft'); $countafterupdate = $DB->count_records('question_versions'); $this->assertEquals($countcurrentrecords, $countafterupdate); } } PK\GaQQ8bank/editquestion/tests/behat/qbank_editquestion.featurenu[@qbank @qbank_editquestion Feature: Use the qbank plugin manager page for editquestion In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | | Default for Test quiz | truefalse | First question second | Answer the first question | Scenario: Enable/disable edit question columns from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Edit question" And I click on "Disable" "link" in the "Edit question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "Status" And the "Edit question" action should not exist for the "First question" question in the question bank And the "Duplicate" action should not exist for the "First question" question in the question bank And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Edit question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should see "Status" And the "Edit question" action should exist for the "First question" question in the question bank And the "Duplicate" action should exist for the "First question" question in the question bank PK\o00 5bank/editquestion/tests/behat/question_status.featurenu[@qbank @qbank_editquestion Feature: Use the qbank base view to test the status change using the pop up Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | | Test questions | truefalse | Second question | Answer the first question | @javascript Scenario: Question status dropdown should change the status of the question Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "Test questions" And the field "question_status_dropdown" in the "First question" "table_row" matches value "Ready" And the field "question_status_dropdown" in the "Second question" "table_row" matches value "Ready" And I set the field "question_status_dropdown" to "Draft" And I reload the page And the field "question_status_dropdown" in the "First question" "table_row" matches value "Draft" And the field "question_status_dropdown" in the "Second question" "table_row" matches value "Ready" And I set the field "question_status_dropdown" in the "Second question" "table_row" to "Draft" And I set the field "question_status_dropdown" in the "First question" "table_row" to "Ready" And I reload the page And the field "question_status_dropdown" in the "First question" "table_row" matches value "Ready" And the field "question_status_dropdown" in the "Second question" "table_row" matches value "Draft" @javascript Scenario: Non-editing users see a static output of the status Given the following "users" exist: | username | firstname | lastname | | teacher1 | Teacher | 1 | And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/question:editall | Prevent | editingteacher | System | | And the following "course enrolments" exist: | course | user | role | | C1 | teacher1 | editingteacher | And the following "questions" exist: | questioncategory | qtype | name | questiontext | status | | Test questions | truefalse | Third question | Answer the first question | draft | | Test questions | truefalse | Fourth question | Answer the first question | hidden | When I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I apply question bank filter "Show hidden questions" with value "Yes" Then I should see "Test questions" And "question_status_dropdown" "field" should not exist And I should see "Ready" in the "First question" "table_row" And I should see "Ready" in the "Second question" "table_row" And I should see "Draft" in the "Third question" "table_row" And I should see "Hidden" in the "Fourth question" "table_row" PK\p/AAFbank/editquestion/tests/behat/filter_condition_question_status.featurenu[@qbank @qbank_editquestion @javascript Feature: Filter questions by status As a teacher In order to quickly find questions in Draft or Ready status I want to filter the list of questions by status Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | status | | Default for Qbank 1 | truefalse | First question | Answer the first question | ready | | Default for Qbank 1 | numerical | Second question | Answer the second question | draft | | Default for Qbank 1 | essay | Third question | Answer the third question | ready | Scenario: Filter by ready status Given I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I should see "First question" And I should see "Second question" And I should see "Third question" When I apply question bank filter "Status of latest version" with value "Ready" Then I should see "First question" And I should not see "Second question" And I should see "Third question" Scenario: Filter by draft status Given I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I should see "First question" And I should see "Second question" And I should see "Third question" When I apply question bank filter "Status of latest version" with value "Draft" Then I should not see "First question" And I should see "Second question" And I should not see "Third question" PK\N_ %%.bank/viewcreator/lang/en/qbank_viewcreator.phpnu[. /** * Strings for component qbank_viewcreator, language 'en' * * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['history'] = 'History'; $string['modifiedby'] = 'Modified by'; $string['timemodified'] = 'Time modified'; $string['pluginname'] = 'View creator'; $string['privacy:metadata'] = 'The View creator question bank plugin does not store any personal data.'; // Deprecated since Moodle 4.5. $string['version'] = 'Version {$a}'; PK\aw w bank/viewcreator/lib.phpnu[. /** * Helper functions and callbacks. * * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Edit page callback for information. * * @param object $question * @return string */ function qbank_viewcreator_edit_form_display($question): string { global $DB, $PAGE, $OUTPUT; $question = question_bank::load_question($question->id); $versiondata = []; $versioninfo = new \core_question\output\question_version_info($question, true); $versiondata['versionnumber'] = $versioninfo->export_for_template($OUTPUT)['versioninfo']; // Currently the history only display the question versions for just only default category. // To display question in the other category. // So we need to add filter param so that we can display the question in different category. $filterparam = json_encode([ 'category' => [ 'jointype' => 1, 'values' => [$question->category], 'filteroptions' => ['includesubcategories' => false], ], ]); // We need a return url param so that click close button on history page should redirect back to edit question page. // Set params filter to returnurl so that when we use the move feature It will not cause any error. $returnurl = $PAGE->url; $returnurl->param('filter', $filterparam); $versiondata['historyurl'] = new moodle_url('/question/bank/history/history.php', [ 'entryid' => $question->questionbankentryid, 'returnurl' => $returnurl, 'courseid' => $PAGE->course->id, 'filter' => $filterparam, 'cmid' => $PAGE->url->param('cmid'), ]); if (!empty($question->createdby)) { $a = new stdClass(); $a->time = userdate($question->timecreated); $a->user = fullname($DB->get_record('user', ['id' => $question->createdby])); $versiondata['createdby'] = get_string('created', 'question') . ' ' . get_string('byandon', 'question', $a); } return $PAGE->get_renderer('qbank_viewcreator')->render_version_info($versiondata); } PK\$bank/viewcreator/version.phpnu[. /** * Version information for qbank_viewcreator. * * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_viewcreator'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\\**3bank/viewcreator/classes/timemodified_condition.phpnu[. namespace qbank_viewcreator; use core\exception\moodle_exception; use core\plugininfo\filter; use core_question\local\bank\condition; /** * Filter condition for date/time modified * * @package qbank_viewcreator * @copyright 2024 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 timemodified_condition extends condition { /** * @var string Search for times before the specified date. */ const MODE_BEFORE = 'before'; /** * @var string Search for times after the specified date. */ const MODE_AFTER = 'after'; /** * @var string Search for times between the specified dates. */ const MODE_BETWEEN = 'between'; #[\Override] public function get_title() { return get_string('timemodified', 'qbank_viewcreator'); } #[\Override] public static function get_condition_key() { return 'timemodified'; } #[\Override] public function get_filter_class() { return 'core/datafilter/filtertypes/datetime'; } /** * Set a single valid jointype, so we don't display the jointype selector. * * We have a separate filter option to control how this condition is applied, Any/All/None doesn't apply here. * * @return array */ public function get_join_list(): array { return [ self::JOINTYPE_DEFAULT, ]; } /** * Build an SQL WHERE condition to filter questions based on q.timemodified. * * $filter['values'][0] contains the datetime to search after, $filter['values'][1] contains the datetime * to search before. Whether to use these dates to search after, before, or between these dates is determined * by the value of $filter['fileroptions']['mode']. * * The datetime values are in the format YYYY-MM-DDTHH:mm, as provided by the datetime-local input type. * * @param array $filter ['values' => [$before, $after], 'filteroptions' => ['mode' => $mode]] * @return array * @throws moodle_exception If an invalid mode or range is provided. */ public static function build_query_from_filter(array $filter): array { if (!isset($filter['filteroptions']['mode']) || empty($filter['values'])) { return ['', []]; } $mode = $filter['filteroptions']['mode']; if (!in_array($mode, [self::MODE_AFTER, self::MODE_BEFORE, self::MODE_BETWEEN])) { throw new moodle_exception('invaliddatetimemode', 'error', a: $filter['filteroptions']['mode']); } $tz = new \DateTimeZone(\core_date::get_user_timezone(99)); $datetimeafter = new \DateTime($filter['values'][0], $tz); $datetimebefore = new \DateTime($filter['values'][1], $tz); if ($mode === self::MODE_AFTER) { $conditions = 'q.timemodified > :timemodifiedafter'; $params['timemodifiedafter'] = $datetimeafter->getTimestamp(); } else if ($mode === self::MODE_BEFORE) { $conditions = 'q.timemodified < :timemodifiedbefore'; $params['timemodifiedbefore'] = $datetimebefore->getTimestamp(); } else { if ($datetimeafter > $datetimebefore) { throw new moodle_exception( 'invaliddatetimebetween', 'error', a: (object) [ 'before' => $datetimebefore->format('Y-m-d H:i'), 'after' => $datetimeafter->format('Y-m-d H:i'), ], ); } $conditions = 'q.timemodified > :timemodifiedafter AND q.timemodified < :timemodifiedbefore'; $params = [ 'timemodifiedafter' => $datetimeafter->getTimestamp(), 'timemodifiedbefore' => $datetimebefore->getTimestamp(), ]; } return [$conditions, $params]; } /** * Return the default datetime values for the filter. * * This generates values formatted for datetime-local fields. The first value returned is the current time, * for use as the default "before" datetime. The second is midnight 1 week ago, for use as the default "after" * datetime. * * @return array[] */ public function get_initial_values(): array { $tz = new \DateTimeZone(\core_date::get_user_timezone()); // Datetime format used by the field. $format = 'Y-m-d\TH:i'; $now = (new \DateTime('now', $tz))->format($format); $oneweek = (new \DateTime('midnight 1 week ago', $tz))->format($format); return [ [ 'value' => $now, 'title' => $now, ], [ 'value' => $oneweek, 'title' => $oneweek, ], ]; } } PK\h|F F +bank/viewcreator/classes/user_condition.phpnu[. namespace qbank_viewcreator; use core\output\datafilter; use core_question\local\bank\condition; /** * Abstract class for conditions filtering by user. * * @package qbank_viewcreator * @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 */ abstract class user_condition extends condition { /** * Return the alias for the instance of the user table to filter on. * * @return string */ abstract protected static function get_table_alias(): string; #[\Override] public function get_filter_class() { return 'core/datafilter/filtertypes/keyword'; } #[\Override] public static function build_query_from_filter(array $filter): array { global $DB; $conditions = []; $params = []; $notlike = $filter['jointype'] === datafilter::JOINTYPE_NONE; $tablealias = static::get_table_alias(); $allnames = array_map(fn($field) => "{$tablealias}.{$field}", \core_user\fields::get_name_fields()); $allnames = $DB->sql_concat(...$allnames); $conditionkey = static::get_condition_key(); foreach ($filter['values'] as $key => $value) { $params["{$conditionkey}{$key}"] = "%$value%"; $conditions[] = $DB->sql_like($allnames, ":{$conditionkey}{$key}", casesensitive: false, notlike: $notlike); } $delimiter = $filter['jointype'] === datafilter::JOINTYPE_ANY ? ' OR ' : ' AND '; return [ implode($delimiter, $conditions), $params, ]; } } PK\xD-bank/viewcreator/classes/privacy/provider.phpnu[. namespace qbank_viewcreator\privacy; /** * Privacy Subsystem for qbank_viewcreator implementing null_provider. * * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\Nuu1bank/viewcreator/classes/modifiedby_condition.phpnu[. namespace qbank_viewcreator; /** * Filter condition for filtering on modifier name * * @package qbank_viewcreator * @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 modifiedby_condition extends user_condition { #[\Override] public function get_title() { return get_string('modifiedby', 'qbank_viewcreator'); } #[\Override] public static function get_condition_key() { return 'modifiername'; } #[\Override] protected static function get_table_alias(): string { return 'um'; } } PK\ ݺ0bank/viewcreator/classes/createdby_condition.phpnu[. namespace qbank_viewcreator; use core\output\datafilter; use core_question\local\bank\condition; /** * Filter condition for filtering on creator name * * @package qbank_viewcreator * @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 createdby_condition extends user_condition { #[\Override] public function get_title() { return get_string('createdby', 'question'); } #[\Override] public static function get_condition_key() { return 'createdby'; } #[\Override] protected static function get_table_alias(): string { return 'uc'; } } PK\ 0bank/viewcreator/classes/creator_name_column.phpnu[. namespace qbank_viewcreator; use core_question\local\bank\column_base; /** * A column type for the name of the question creator. * * @package qbank_viewcreator * @copyright 2009 Tim Hunt * @author 2021 Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class creator_name_column extends column_base { public function get_name(): string { return 'creatorname'; } public function get_title(): string { return get_string('createdby', 'question'); } protected function display_content($question, $rowclasses): void { global $PAGE; $displaydata = []; if (!empty($question->creatorfirstname) && !empty($question->creatorlastname)) { $u = new \stdClass(); $u = username_load_fields_from_object($u, $question, 'creator'); $displaydata['date'] = userdate($question->timecreated, get_string('strftimedatetime', 'langconfig')); $displaydata['creator'] = fullname($u); echo $PAGE->get_renderer('qbank_viewcreator')->render_creator_name($displaydata); } } public function get_extra_joins(): array { return ['uc' => 'LEFT JOIN {user} uc ON uc.id = q.createdby']; } public function get_required_fields(): array { $allnames = \core_user\fields::get_name_fields(); $requiredfields = []; foreach ($allnames as $allname) { $requiredfields[] = 'uc.' . $allname . ' AS creator' . $allname; } $requiredfields[] = 'q.timecreated'; return $requiredfields; } public function is_sortable(): array { return [ 'firstname' => ['field' => 'uc.firstname', 'title' => get_string('firstname')], 'lastname' => ['field' => 'uc.lastname', 'title' => get_string('lastname')], 'timecreated' => ['field' => 'q.timecreated', 'title' => get_string('date')] ]; } public function get_extra_classes(): array { return ['pe-3']; } } PK\:Z)),bank/viewcreator/classes/output/renderer.phpnu[. namespace qbank_viewcreator\output; /** * Class renderer. * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render question creator. * * @param array $displaydata * @return string */ public function render_creator_name($displaydata) { return $this->render_from_template('qbank_viewcreator/creator_display', $displaydata); } /** * Render question edit form callback. * * @param array $displaydata * @return string */ public function render_version_info($displaydata) { return $this->render_from_template('qbank_viewcreator/version_info', $displaydata); } } PK\+wh 1bank/viewcreator/classes/modifier_name_column.phpnu[. namespace qbank_viewcreator; use core_question\local\bank\column_base; /** * A column for info of the question modifier. * * @package qbank_viewcreator * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class modifier_name_column extends column_base { public function get_name(): string { return 'modifiername'; } public function get_title(): string { return get_string('modifiedby', 'qbank_viewcreator'); } protected function display_content($question, $rowclasses): void { global $PAGE; $displaydata = []; if (!empty($question->modifierfirstname) && !empty($question->modifierlastname)) { $u = new \stdClass(); $u = username_load_fields_from_object($u, $question, 'modifier'); $displaydata['date'] = userdate($question->timemodified, get_string('strftimedatetime', 'langconfig')); $displaydata['creator'] = fullname($u); echo $PAGE->get_renderer('qbank_viewcreator')->render_creator_name($displaydata); } } public function get_extra_joins(): array { return ['um' => 'LEFT JOIN {user} um ON um.id = q.modifiedby']; } public function get_required_fields(): array { $allnames = \core_user\fields::get_name_fields(); $requiredfields = []; foreach ($allnames as $allname) { $requiredfields[] = 'um.' . $allname . ' AS modifier' . $allname; } $requiredfields[] = 'q.timemodified'; return $requiredfields; } public function is_sortable(): array { return [ 'firstname' => ['field' => 'um.firstname', 'title' => get_string('firstname')], 'lastname' => ['field' => 'um.lastname', 'title' => get_string('lastname')], 'timemodified' => ['field' => 'q.timemodified', 'title' => get_string('date')] ]; } public function get_extra_classes(): array { return ['pe-3']; } } PK\VV+bank/viewcreator/classes/plugin_feature.phpnu[. namespace qbank_viewcreator; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_viewcreator * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { #[\Override] public function get_question_columns($qbank): array { return [ new creator_name_column($qbank), new modifier_name_column($qbank), ]; } #[\Override] public function get_question_filters(?view $qbank = null): array { return [ new timemodified_condition($qbank), new createdby_condition($qbank), new modifiedby_condition($qbank), ]; } } PK\L[a3bank/viewcreator/templates/creator_display.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_viewcreator/creator_display Example context (json): { "displaydata": [ { "creator": "Marc Ghaly", "date": "2 June 2021, 5:32 PM", } ] } }} {{creator}}
{{date}} PK\"NN0bank/viewcreator/templates/version_info.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_viewcreator/version_info Example context (json): { "addquestiondata": [ { "versionnumber": 1, "historyurl": "www.example.com/question/bank/history/history.php", "createdby": "Admin User on Wednesday, 20 October 2021, 5:33 AM" } ] } }} {{#createdby}}
{{{createdby}}}
{{/createdby}} PK\Dbank/viewcreator/deprecated.txtnu[version,qbank_viewcreator PK\pU=bank/viewcreator/tests/behat/view_view_creator_column.featurenu[@qbank @qbank_viewcreator Feature: Use the qbank plugin manager page for viewcreator plugin In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | Scenario: Enable/disable viewcreator column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "View creator" When I click on "Disable" "link" in the "View creator" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "Created by" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View creator" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should see "Created by" PK\ňBbank/viewcreator/tests/behat/filter_condition_timemodified.featurenu[@qbank @qbank_viewcreator @javascript Feature: Time modified filter condition As a teacher In order to organise my questions I want to filter the list of questions by the time and date of last modification Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | | Test questions | truefalse | Second question | Answer the second question | | Test questions | truefalse | Third question | Answer the third question | And the following "core_question > updated questions" exist: | questioncategory | question | name | timemodified | | Test questions | First question | First question | ## 2024-01-10 10:00 ## | | Test questions | Second question | Second question | ## 2024-01-10 11:00 ## | | Test questions | Third question | Third question | ## 2024-01-10 12:00 ## | Given I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "First question" And I should see "Second question" And I should see "Third question" Scenario: Filter by questions modified before time When I add question bank filter "Time modified" And the field "Time modified before" matches value "## now ##%FT%R##" And I set the field "Time modified before" to "2024-01-10T10:59" And I press "Apply filters" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by questions modified after time When I add question bank filter "Time modified" And I set the field "Select dates" to "After" And the field "Time modified after" matches value "## midnight 1 week ago ##%FT%R##" And I set the field "Time modified after" to "2024-01-10T10:59" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should see "Third question" Scenario: Filter by questions modified between times When I add question bank filter "Time modified" And I set the field "Select dates" to "Between" And I set the field "Time modified after" to "2024-01-10T10:59" And I set the field "Time modified before" to "2024-01-10T11:01" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should not see "Third question" Scenario: Apply filter between invalid times When I add question bank filter "Time modified" And I set the field "Select dates" to "Between" And I set the field "Time modified after" to "2024-01-10T10:59" And I set the field "Time modified before" to "2024-01-10T11:01" And I press "Apply filters" And I should not see "First question" And I should see "Second question" And I should not see "Third question" And I set the field "Time modified after" to "2024-01-10T11:01" And I set the field "Time modified before" to "2024-01-10T10:59" And I press "Apply filters" # Invalid filters should not be applied. And I should not see "First question" And I should see "Second question" And I should not see "Third question" # Invalid values should not be set in the URL. And I reload the page And the field "Time modified after" matches value "2024-01-10T10:59" And the field "Time modified before" matches value "2024-01-10T11:01" And I should not see "First question" And I should see "Second question" And I should not see "Third question" PK\̏hhBbank/viewcreator/tests/behat/filter_condition_creator_name.featurenu[@qbank @qbank_viewcreator @javascript Feature: Filter questions by creator name As a teacher In order to organise my questions I want to filter the list of questions by creator name Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "users" exist: | username | firstname | lastname | firstnamephonetic | lastnamephonetic | middlename | alternatename | | aa | Aaron | Aaronson | Aron | Aronsun | Andrew | Andy | | bb | Bob | Bobson | | | | | | cc | Clare | Clareson | | | | | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "course enrolments" exist: | course | user | role | | C1 | aa | editingteacher | | C1 | bb | editingteacher | | C1 | cc | editingteacher | And the following "questions" exist: | qtype | questioncategory | name | user | | truefalse | Test questions | First question | aa | | truefalse | Test questions | Second question | bb | | truefalse | Test questions | Third question | cc | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "First question" And I should see "Second question" And I should see "Third question" Scenario: Filter by a single word When I apply question bank filter "Created by" with value "Aaron" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by any word When I apply question bank filter "Created by" with value "Aaron, Clare" Then I should see "First question" And I should not see "Second question" And I should see "Third question" Scenario: Filter by all words When I add question bank filter "Created by" And I set the field "Created by" to "son, Aar" And I set the field "Match" in the "Filter 3" "fieldset" to "All" And I press "Apply filters" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by additional name fields When I apply question bank filter "Created by" with value "Aron" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Created by" with value "sun" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Created by" with value "drew" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Created by" with value "Andy" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Exclude names by filter When I add question bank filter "Created by" And I set the field "Created by" to "Aron, Clare" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should not see "Third question" PK\` 7bank/viewcreator/tests/behat/modified_by_column.featurenu[@qbank @qbank_viewcreator Feature: Question bank Modified by column In order to help keep track of the changes of the question version in the question bank As a teacher I need to know who modified the question and when the modification made in the question bank Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher1 | 1 | teacher1@example.com | | teacher2 | Teacher2 | 2 | teacher2@example.com | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | teacher2 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | @javascript Scenario: Enable/disable modified by column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "View creator" When I click on "Disable" "link" in the "View creator" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" Then I should not see "Modified by" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View creator" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" Then I should see "Modified by" @javascript Scenario: Editing a question shows the modifier of the question And I log in as "teacher1" And I am on the "Test quiz" "mod_quiz > question bank" page And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" And I should see "First question" And I choose "Edit question" action for "First question" in the question bank And "History" "link" should exist And I should see "v1 (latest)" And I set the field "id_name" to "Renamed question v2" And I set the field "id_questiontext" to "edited question" And I press "id_submitbutton" And I should see "Teacher1" And I log in as "teacher2" And I am on the "Test quiz" "mod_quiz > question bank" page And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" And I choose "Edit question" action for "Renamed question v2" in the question bank Then I should see "v2 (latest)" And I set the field "id_name" to "Renamed question v3" And I set the field "id_questiontext" to "edited question v3" And I press "id_submitbutton" And I should not see "Teacher1" And I should see "Teacher2" PK\̕UCbank/viewcreator/tests/behat/filter_condition_modifier_name.featurenu[@qbank @qbank_viewcreator @javascript Feature: Filter questions by modifier name As a teacher In order to organise my questions I want to filter the list of questions by modifier name Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "users" exist: | username | firstname | lastname | firstnamephonetic | lastnamephonetic | middlename | alternatename | | aa | Aaron | Aaronson | Aron | Aronsun | Andrew | Andy | | bb | Bob | Bobson | | | | | | cc | Clare | Clareson | | | | | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "course enrolments" exist: | course | user | role | | C1 | aa | editingteacher | | C1 | bb | editingteacher | | C1 | cc | editingteacher | And the following "questions" exist: | qtype | questioncategory | name | | truefalse | Test questions | First question | | truefalse | Test questions | Second question | | truefalse | Test questions | Third question | And the following "core_question > updated questions" exist: | questioncategory | question | name | modifiedbyuser | | Test questions | First question | First question | aa | | Test questions | Second question | Second question | bb | | Test questions | Third question | Third question | cc | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "First question" And I should see "Second question" And I should see "Third question" Scenario: Filter by a single word When I apply question bank filter "Modified by" with value "Aaron" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by any word When I apply question bank filter "Modified by" with value "Aaron, Clare" Then I should see "First question" And I should not see "Second question" And I should see "Third question" Scenario: Filter by all words When I add question bank filter "Modified by" And I set the field "Modified by" to "son, Aar" And I set the field "Match" in the "Filter 3" "fieldset" to "All" And I press "Apply filters" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by additional name fields When I apply question bank filter "Modified by" with value "Aron" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Modified by" with value "sun" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Modified by" with value "drew" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" When I apply question bank filter "Modified by" with value "Andy" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Exclude names by filter When I add question bank filter "Modified by" And I set the field "Modified by" to "Aron, Clare" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should not see "Third question" PK\6 bank/columnsortorder/styles.cssnu[#page-admin-question-bank-columnsortorder-sortcolumns .addcolumn { display: inline-block; } .qbank-sortable-column { background-color: white; position: relative; } .jsenabled .qbank-column-list button.savewidths { display: none; } .qbank-sortable-column .qbank_columnsortorder-action-handle { display: none; } .qbank-sortable-column.show-handles .qbank_columnsortorder-action-handle { display: block; } .qbank-sortable-column .handle-container { pointer-events: none; /* Prevent the handle container blocking clicks to elements in the header */ width: 100%; position: absolute; top: 40%; z-index: 1; } .qbank_columnsortorder-action-handle { pointer-events: auto; /* Ensure the handles themselves receive clicks */ } .qbank_columnsortorder-action-handle.move { margin-left: -21px; width: 16px; } .qbank_columnsortorder-action-handle.resize { cursor: col-resize; position: absolute; top: 0; right: 5px; } .qbank_columnsortorder-action-handle.resize img { /* Prevent the resize handle icon being dragged across the page */ pointer-events: none; } .qbank_columnsortorder-action-handle .icon { margin-right: 0; } PK\f$bank/columnsortorder/db/services.phpnu[. /** * qbank_columnsortorder external functions and service definitions. * * @package qbank_columnsortorder * @category webservice * @copyright 2021 Catalyst IT Australia Pty Ltd * @author 2021, Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'qbank_columnsortorder_set_columnbank_order' => [ 'classname' => 'qbank_columnsortorder\external\set_columnbank_order', 'description' => 'Sets question columns order in database', 'type' => 'write', 'ajax' => true, ], 'qbank_columnsortorder_set_hidden_columns' => [ 'classname' => 'qbank_columnsortorder\external\set_hidden_columns', 'description' => 'Hidden Columns', 'type' => 'write', 'ajax' => true, ], 'qbank_columnsortorder_set_column_size' => [ 'classname' => 'qbank_columnsortorder\external\set_column_size', 'description' => 'Column size', 'type' => 'write', 'ajax' => true, ], ]; PK\%bVV#bank/columnsortorder/db/upgrade.phpnu[. /** * Custom sort order upgrade script. * * @package qbank_columnsortorder * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_question\local\bank\column_base; /** * Upgrade the plugin. * * @param int $oldversion the version of this plugin we are upgrading from. * @return bool success/failure. */ function xmldb_qbank_columnsortorder_upgrade(int $oldversion): bool { global $DB; if ($oldversion < 2024042201) { // Before Moodle 4.3, config_plugins settings for qbank_columnsortorder (disabledcol, enabledcol) had a value like // qbank_statistics\columns\facility_index,qbank_statistics\columns\discriminative_efficiency, ... // In Moodle 4.3, the values are stored as qbank_statistics\columns\discriminative_efficiency-discriminative_efficiency. // So updating the old values to match the new format. // Update the columns records for qbank_columnsortorder plugin. $pluginconfigs = $DB->get_records('config_plugins', ['plugin' => 'qbank_columnsortorder'], 'name'); foreach ($pluginconfigs as $config) { if (!in_array($config->name, ['hiddencols', 'enabledcol', 'disabledcol'])) { continue; } $fields = explode(',', $config->value); $updatedcols = []; foreach ($fields as $columnclass) { // Columns config that are already in the correct format, could be ignored. if (str_contains($columnclass, column_base::ID_SEPARATOR)) { continue; } $classbits = explode('\\', $columnclass); $columnname = end($classbits); // The custom fields are to be in the format e.g., qbank_customfields\custom_field_column-test. if (str_contains($columnclass, 'custom_field_column')) { array_pop($classbits); } $updatedcols[] = implode('\\', $classbits) . column_base::ID_SEPARATOR . $columnname; } $updatedconfig = implode(',', $updatedcols); set_config($config->name, $updatedconfig, 'qbank_columnsortorder'); } // Custom sort order savepoint reached. upgrade_plugin_savepoint(true, 2024042201, 'qbank', 'columnsortorder'); } if ($oldversion < 2024051000) { // Remove plugin entry created by previously incorrect 2024042201 savepoint. $DB->delete_records('config_plugins', ['plugin' => 'qbank_qbank_columnsortorder']); upgrade_plugin_savepoint(true, 2024051000, 'qbank', 'columnsortorder'); } // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. if ($oldversion < 2024100701) { // When upgrading to version 2024042201, if there were any values for colsize in qbank_columnsortorder plugin, // they were getting incorrectly updated, resulting in corrupted colsize value, // e.g., '"width":"30"}-"width":"30"},"width":"180"}-"width":"180"}' and thus breaking the question bank page. $pluginconfig = $DB->get_record('config_plugins', ['plugin' => 'qbank_columnsortorder', 'name' => 'colsize']); $pattern = '/"width":"[^"]*"}-"width":"[^"]*"}/'; if ($pluginconfig && preg_match($pattern, $pluginconfig->value)) { $DB->delete_records('config_plugins', ['plugin' => 'qbank_columnsortorder', 'name' => 'colsize']); } upgrade_plugin_savepoint(true, 2024100701, 'qbank', 'columnsortorder'); } // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\DGBB"bank/columnsortorder/db/events.phpnu[. /** * Event observer registration * * @package qbank_columnsortorder * @copyright 2023 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 */ defined('MOODLE_INTERNAL') || die(); $observers = [ [ 'eventname' => '\core\event\qbank_plugin_enabled', 'callback' => '\qbank_columnsortorder\event\plugin_observer::plugin_enabled', ], [ 'eventname' => '\core\event\qbank_plugin_disabled', 'callback' => '\qbank_columnsortorder\event\plugin_observer::plugin_disabled', ], ]; PK\6 6bank/columnsortorder/lang/en/qbank_columnsortorder.phpnu[. /** * Strings for component qbank_columnsortorder, language 'en'. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['addcolumn'] = 'Add column \'{$a}\''; $string['addcolumns'] = 'Add columns'; $string['auto'] = 'Auto'; $string['columnwidth'] = 'Column width (pixels)'; $string['invalidwidth'] = 'Width must be at least {$a}.'; $string['movecolumn'] = 'Move column \'{$a}\''; $string['pluginname'] = 'Column sort order'; $string['privacy:metadata:preference:enabledcol'] = 'The Column sort order question bank plugin saves user preference of column orders.'; $string['privacy:metadata:preference:hiddencols'] = 'The Column sort order question bank plugin saves user preference of hidden columns.'; $string['privacy:metadata:preference:colsize'] = 'The Column sort order question bank plugin saves user preference of column sizes.'; $string['qbankcolumnsortorder'] = 'Column sort order'; $string['qbankgotocolumnsort'] = 'You can change the order of the columns in the question bank on the page {$a}.'; $string['qbankcolumnsdisabled'] = 'Currently disabled question bank plugins:'; $string['qbankgotomanageqbanks'] = 'You can remove a column by disabling the plugin in Manage question bank plugins.'; $string['qbankcolumnname'] = '{$a}'; $string['qbanksortdescription'] = 'The order in which plugins are listed below determines the order of the columns in the question bank.'; $string['resetcolumns'] = 'Reset columns'; $string['resize'] = 'Resize'; $string['resizecolumn'] = 'Resize column {$a}'; $string['removecolumn'] = 'Remove column \'{$a}\''; $string['showhidecolumn'] = 'Show/hide column'; $string['width'] = 'Width of \'{$a}\' in pixels'; PK\$bank/columnsortorder/sortcolumns.phpnu[. /** * Question bank settings page class. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir.'/adminlib.php'); admin_externalpage_setup('qbank_columnsortorder', '', ['section' => 'columnsortorder'], '/question/bank/columnsortorder/sortcolumns.php'); $preview = optional_param('preview', false, PARAM_BOOL); echo $OUTPUT->header(); echo $OUTPUT->heading(new lang_string('qbankcolumnsortorder', 'qbank_columnsortorder')); if ($preview) { $columnmanager = new \qbank_columnsortorder\column_manager(true); $preview = $columnmanager->get_questionbank()->get_preview(); echo $OUTPUT->render(new \qbank_columnsortorder\output\column_sort_preview($preview)); } else { echo $OUTPUT->render(new \qbank_columnsortorder\output\column_sort_ui()); } echo $OUTPUT->footer(); PK\hyybank/columnsortorder/lib.phpnu[. /** * Standard callback functions for qbank_columnsortorder * * This file only exists for defining fragment callbacks. Do not include any other functions here. * * @package qbank_columnsortorder * @copyright 2023 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 */ /** * Render the column sort UI. {@see \qbank_columnsortorder\output\fragment::column_sort_ui()} * * @param array $args * @return string */ function qbank_columnsortorder_output_fragment_column_sort_ui(array $args): string { return \qbank_columnsortorder\output\fragment::column_sort_ui(); } PK\d0 bank/columnsortorder/version.phpnu[. /** * Version information for qbank_columnsortorder. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_columnsortorder'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\! ! <bank/columnsortorder/classes/external/set_hidden_columns.phpnu[. namespace qbank_columnsortorder\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_value; use qbank_columnsortorder\column_manager; /** * External function for saving the list of hidden columns. * * @package qbank_columnsortorder * @copyright 2023 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 set_hidden_columns extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'columns' => new external_multiple_structure( new external_value(PARAM_TEXT, 'Plugin name for the hidden column', VALUE_REQUIRED), 'List of hidden columns', VALUE_DEFAULT, null, NULL_ALLOWED, ), 'global' => new external_value( PARAM_BOOL, 'Set global config setting, rather than user preference', VALUE_DEFAULT, false ), ]); } /** * Returns description of method result value. * */ public static function execute_returns(): void { } /** * Set hidden columns * Save against user preference if specified * * @param ?array $columns List of hidden columns. Null value clears the setting. * @param bool $global Set global config setting, rather than user preference */ public static function execute(?array $columns, bool $global = false): void { [ 'columns' => $columns, 'global' => $global, ] = self::validate_parameters( self::execute_parameters(), [ 'columns' => $columns, 'global' => $global, ] ); $context = context_system::instance(); self::validate_context($context); if ($global) { require_capability('moodle/site:config', $context); } column_manager::set_hidden_columns($columns, $global); } } PK\6 >bank/columnsortorder/classes/external/set_columnbank_order.phpnu[. namespace qbank_columnsortorder\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_value; use qbank_columnsortorder\column_manager; /** * External qbank_columnsortorder_set_columnbank_order API * * @package qbank_columnsortorder * @category external * @copyright 2021 Catalyst IT Australia Pty Ltd * @author 2021, Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class set_columnbank_order extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'columns' => new external_multiple_structure( new external_value(PARAM_TEXT, 'Plugin name for the column', VALUE_REQUIRED), 'List of column in the desired order', VALUE_DEFAULT, null, NULL_ALLOWED, ), 'global' => new external_value(PARAM_BOOL, 'Set global config setting, rather than user preference', VALUE_DEFAULT, false), ]); } /** * Returns description of method result value. * */ public static function execute_returns(): void { } /** * Set columns order. * * @param ?array $columns List of column names in the desired order. Null value clears the setting. * @param bool $global Set global config setting, rather than user preference */ public static function execute(?array $columns, bool $global = false): void { [ 'columns' => $columns, 'global' => $global, ] = self::validate_parameters(self::execute_parameters(), [ 'columns' => $columns, 'global' => $global, ]); $context = context_system::instance(); self::validate_context($context); if ($global) { require_capability('moodle/site:config', $context); } column_manager::set_column_order($columns, $global); } } PK\y;F 9bank/columnsortorder/classes/external/set_column_size.phpnu[. namespace qbank_columnsortorder\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; use qbank_columnsortorder\column_manager; /** * External function for saving column sizes. * * @package qbank_columnsortorder * @copyright 2023 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 set_column_size extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'sizes' => new external_value( PARAM_TEXT, 'Size for each column, as a JSON string representing [column => size]', VALUE_DEFAULT, null, ), 'global' => new external_value( PARAM_BOOL, 'Set global config setting, rather than user preference', VALUE_DEFAULT, false ), ]); } /** * Returns description of method result value. */ public static function execute_returns(): void { } /** * Set sizes for columns * Save against user preference if component is specified * * @param ?string $sizes json string representing [column => size]. Null value clears the setting. * @param bool $global Set global config setting, rather than user preference */ public static function execute(?string $sizes, bool $global = false): void { [ 'sizes' => $sizes, 'global' => $global, ] = self::validate_parameters( self::execute_parameters(), [ 'sizes' => $sizes, 'global' => $global, ] ); $context = context_system::instance(); self::validate_context($context); if ($global) { require_capability('moodle/site:config', $context); } column_manager::set_column_size($sizes, $global); } } PK\t+J 1bank/columnsortorder/classes/privacy/provider.phpnu[. namespace qbank_columnsortorder\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\writer; /** * Privacy provider for columnsortorder. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // The forms subsystem does not store any data itself, it has no database tables. \core_privacy\local\metadata\provider, // The forms subsystem has user preferences. \core_privacy\local\request\user_preference_provider { public static function get_metadata(collection $collection): collection { $collection->add_user_preference('enabledcol', 'privacy:metadata:preference:enabledcol'); $collection->add_user_preference('hiddencols', 'privacy:metadata:preference:hiddencols'); $collection->add_user_preference('colsize', 'privacy:metadata:preference:colsize'); return $collection; } public static function export_user_preferences(int $userid) { $components = ['core_question', "qbank_history"]; foreach ($components as $component) { $prefnames = ['enabledcol', 'hiddencols', 'colsize']; foreach ($prefnames as $name) { $preference = get_user_preferences("{$component}_{$name}", null, $userid); if ($preference !== null) { $desc = get_string('enabledcol', 'privacy:metadata:preference:enabledcol'); writer::export_user_preference('qbank_columnsortorder', "{$component}_{$name}", $preference, $desc); } } } } } PK\[$  @bank/columnsortorder/classes/local/bank/column_action_remove.phpnu[. namespace qbank_columnsortorder\local\bank; use core_question\local\bank\column_action_base; use core_question\local\bank\column_base; /** * Remove a column * * This action will display a link that will set the current column as hidden, then redirect back the current page. * * @package qbank_columnsortorder * @copyright 2023 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 column_action_remove extends column_action_base { /** @var bool True if we are changing global config, false for user preferences. */ protected bool $global; /** @var string Label for the Remove action. */ protected string $remove; protected function init(): void { $this->global = false; $this->remove = get_string('remove'); } /** * Set the $global property to indicate whether we are changing global config. * * This action is used on both the user and admin screens, so requires this additional method. * * @param bool $global * @return void */ public function set_global(bool $global): void { $this->global = $global; } public function get_action_menu_link(column_base $column): ?\action_menu_link { $actionurl = new \moodle_url('/question/bank/columnsortorder/actions.php', [ 'column' => $column->get_column_id(), 'action' => 'remove', 'sesskey' => sesskey(), 'returnurl' => new \moodle_url($this->qbank->returnurl), ]); if ($this->global) { $actionurl->param('global', $this->global); } return new \action_menu_link_secondary( $actionurl, new \pix_icon('t/delete', ''), $this->remove, [ 'class' => 'action-link', 'title' => get_string('removecolumn', 'qbank_columnsortorder', $column->get_title()), 'data-action' => 'remove', 'data-column' => $column->get_column_id(), ] ); } } PK\O%@bank/columnsortorder/classes/local/bank/column_action_resize.phpnu[. namespace qbank_columnsortorder\local\bank; use core_question\local\bank\column_action_base; use core_question\local\bank\column_base; /** * Resize a column * * This will add an action menu item which will be enhanced by javascript in user_actions.js to show the resize column modal for the * current column when clicked. * * @package qbank_columnsortorder * @copyright 2023 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 column_action_resize extends column_action_base { /** @var string Label for the resize action. */ protected string $resize; protected function init(): void { $this->resize = get_string('resize', 'qbank_columnsortorder'); } public function get_action_menu_link(column_base $column): ?\action_menu_link { return new \action_menu_link_secondary( new \moodle_url('/question/edit.php'), new \pix_icon('i/twoway', ''), $this->resize, [ 'title' => get_string('resizecolumn', 'qbank_columnsortorder', $column->get_title()), 'data-action' => 'resize', 'data-column' => get_class($column), ] ); } } PK\t5 8bank/columnsortorder/classes/local/bank/preview_view.phpnu[. namespace qbank_columnsortorder\local\bank; use core_question\local\bank\view; use qbank_columnsortorder\column_manager; /** * Custom view for displaying a preview of the question bank * * @package qbank_columnsortorder * @copyright 2023 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 preview_view extends view { /** * Use global settings for the column manager. * * @return void */ protected function init_column_manager(): void { $this->columnmanager = new column_manager(true); } /** * Prints the table row with the preview data for each column. * * @param \stdClass $question * @param int $rowcount */ public function print_table_row($question, $rowcount): void { $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount)); $attributes = []; if ($rowclasses) { $attributes['class'] = $rowclasses; } echo \html_writer::start_tag('tr', $attributes); foreach ($this->visiblecolumns as $column) { $column->display_preview($question, $rowclasses); } echo \html_writer::end_tag('tr'); foreach ($this->extrarows as $row) { $row->display_preview($question, $rowclasses); } } /** * Get a dummy question containing valid data for the default question fields. * * @return \stdClass */ protected function get_dummy_question(): \stdClass { return (object)[ 'id' => 1, 'qtype' => 'truefalse', 'questiontext' => 'Is the moon made of cheese?', 'questiontextformat' => FORMAT_PLAIN, 'createdby' => 2, 'categoryid' => 1, 'contextid' => 1, 'status' => 'ready', 'version' => 1, 'versionid' => 1, 'questionbankentryid' => 1, 'name' => 'Lorem ipsum', 'idnumber' => 123, 'creatorfirstname' => 'Admin', 'creatorlastname' => 'User', 'timecreated' => 1691157311, 'modifierfirstname' => 'Admin', 'modifierlastname' => 'User', 'timemodified' => 1691157311, 'isdummy' => true, ]; } /** * Generate a preview of the question bank table with a single dummy question. * * @return string An HTML table containing the column headings and a single question row. */ public function get_preview(): string { ob_start(); $this->display_questions([$this->get_dummy_question()]); return ob_get_clean(); } } PK\~߷>bank/columnsortorder/classes/local/bank/column_action_move.phpnu[. namespace qbank_columnsortorder\local\bank; use core_question\local\bank\column_action_base; use core_question\local\bank\column_base; /** * Move a column * * This will add an action menu item which will be enhanced by javascript in user_actions.js to show the move column modal for the * current column when clicked. * * @package qbank_columnsortorder * @copyright 2023 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 column_action_move extends column_action_base { /** @var string Label for the Move action. */ protected string $move; protected function init(): void { $this->move = get_string('move'); } public function get_action_menu_link(column_base $column): ?\action_menu_link { return new \action_menu_link_secondary( new \moodle_url('/question/edit.php'), new \pix_icon('i/dragdrop', ''), $this->move, [ 'title' => get_string('movecolumn', 'qbank_columnsortorder', $column->get_title()), 'data-action' => 'move', 'data-column' => get_class($column), ] ); } } PK\1==/bank/columnsortorder/classes/column_manager.phpnu[. namespace qbank_columnsortorder; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); use context_system; use core_question\local\bank\column_base; use core_question\local\bank\column_manager_base; use core_question\local\bank\question_edit_contexts; use core_question\local\bank\view; use core_question\local\bank\question_bank_helper; use qbank_columnsortorder\local\bank\column_action_move; use qbank_columnsortorder\local\bank\column_action_remove; use qbank_columnsortorder\local\bank\column_action_resize; use qbank_columnsortorder\local\bank\preview_view; use moodle_url; /** * Class column_manager responsible for loading and saving order to the config setting. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class column_manager extends column_manager_base { /** * @var array Column order as set in config_plugins 'class' => 'position', ie: question_type_column => 3. */ public $columnorder; /** * @var array hidden columns. */ public $hiddencolumns; /** * @var array columns with size. */ public $colsize; /** * @var array Disabled columns in config_plugins table. */ public $disabledcolumns; /** * Constructor for column_manager class. * * @param bool $globalsettings Only use the global default settings, ignoring user preferences? */ public function __construct(bool $globalsettings = false) { $this->columnorder = $this->setup_property('enabledcol', $globalsettings); if (empty($this->columnorder)) { $this->columnorder = [ 'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column', 'qbank_viewquestiontype\question_type_column' . column_base::ID_SEPARATOR . 'question_type_column', 'qbank_viewquestionname\question_name_idnumber_tags_column' . column_base::ID_SEPARATOR . 'question_name_idnumber_tags_column', 'core_question\local\bank\edit_menu_column' . column_base::ID_SEPARATOR . 'edit_menu_column', 'qbank_editquestion\question_status_column' . column_base::ID_SEPARATOR . 'question_status_column', 'qbank_history\version_number_column' . column_base::ID_SEPARATOR . 'version_number_column', 'qbank_viewcreator\creator_name_column' . column_base::ID_SEPARATOR . 'creator_name_column', 'qbank_comment\comment_count_column' . column_base::ID_SEPARATOR . 'comment_count_column', ]; } $this->hiddencolumns = $this->setup_property('hiddencols', $globalsettings); $this->colsize = $this->setup_property('colsize', $globalsettings, 'json'); $this->disabledcolumns = $this->setup_property('disabledcol', true); // No user preference for disabledcol. if ($this->columnorder) { $this->columnorder = array_flip($this->columnorder); } if ($this->disabledcolumns) { $this->disabledcolumns = array_flip($this->disabledcolumns); } } /** * Return the value for the given property, based the saved user preference or config setting. * * If no value is currently stored, returns an empty array. * * @param string $setting The identifier used for the saved config and user preference settings. * @param bool $global Only get the global default, ignoring the user preference? * @param string $encoding The encoding used to store the property - csv or json * @return array */ private function setup_property(string $setting, bool $global = false, $encoding = 'csv'): array { $value = get_config('qbank_columnsortorder', $setting); if (!$global) { $value = get_user_preferences("qbank_columnsortorder_{$setting}", $value); } if (empty($value)) { return []; } return $encoding == 'csv' ? explode(',', $value) : json_decode($value); } /** * Sets column order in the qbank_columnsortorder plugin config. * * @param ?array $columns Column order to set. Null value clears the setting. * @param bool $global save this as a global default, rather than a user preference? */ public static function set_column_order(?array $columns, bool $global = false): void { if (!is_null($columns)) { $columns = implode(',', $columns); } self::save_preference('enabledcol', $columns, $global); } /** * Hidden Columns. * * @param ?array $columns List of hidden columns. Null value clears the setting. * @param bool $global save this as a global default, rather than a user preference? */ public static function set_hidden_columns(?array $columns, bool $global = false): void { if (!is_null($columns)) { $columns = implode(',', $columns); } self::save_preference('hiddencols', $columns, $global); } /** * Column size. * * @param ?string $sizes columns with width. Null value clears the setting. * @param bool $global save this as a global default, rather than a user preference? */ public static function set_column_size(?string $sizes, bool $global = false): void { self::save_preference('colsize', $sizes, $global); } /** * Save Preferences. * * @param string $name name of a configuration * @param ?string $value value of a configuration. Null value clears the setting. * @param bool $global save this as a global default, rather than a user preference? */ private static function save_preference(string $name, ?string $value, bool $global = false): void { if ($global) { require_capability('moodle/site:config', context_system::instance()); set_config($name, $value, 'qbank_columnsortorder'); } else { set_user_preference("qbank_columnsortorder_{$name}", $value); } } /** * Get qbank. * * @return view */ public function get_questionbank(): view { $course = (object) ['id' => 0]; $previewbank = question_bank_helper::get_preview_open_instance_type(true); $cm = $previewbank->get_course_module_record(); $context = \context_module::instance($previewbank->id); $contexts = new question_edit_contexts($context); $category = question_get_default_category($contexts->lowest()->id, true); $params = ['cat' => $category->id . ',' . $context->id]; // Dummy call to get the objects without error. $questionbank = new preview_view( $contexts, new moodle_url('/question/bank/columnsortorder/sortcolumns.php'), $course, $cm, $params ); return $questionbank; } /** * Get enabled columns. * * @return array */ public function get_columns(): array { $columns = []; foreach ($this->get_questionbank()->get_visiblecolumns() as $key => $column) { if ($column->get_name() === 'checkbox') { continue; } $columns[] = (object) [ 'class' => get_class($column), 'name' => $column->get_title(), 'colname' => $column->get_column_name(), 'id' => $column->get_column_id(), ]; } return $columns; } /** * Get disabled columns. * * @return array */ public function get_disabled_columns(): array { $result = $this->create_column_objects(array_keys($this->disabledcolumns)); $disabled = []; foreach ($result as $column => $columnobject) { $disabled[$column] = (object) [ 'disabledname' => $columnobject->get_title(), ]; } return $disabled; } /** * Updates enabled and disabled config for 'qbank_columnsortorder' plugin. * * @param array $enabledcolumns Enabled columns to set. * @param array $disabledcolumns Disabled columns to set. */ protected function update_config($enabledcolumns, $disabledcolumns): void { if (!empty($enabledcolumns)) { $configenabled = implode(',', array_flip($enabledcolumns)); set_config('enabledcol', $configenabled, 'qbank_columnsortorder'); } if (!empty($disabledcolumns)) { $configdisabled = implode(',', array_flip($disabledcolumns)); set_config('disabledcol', $configdisabled, 'qbank_columnsortorder'); } else { set_config('disabledcol', null, 'qbank_columnsortorder'); } } /** * Enables columns. * * @param string $plugin Plugin type and name ie: qbank_viewcreator. */ public function enable_columns(string $plugin): void { $enabledcolumns = []; $disabledcolumns = []; if ($this->columnorder) { $enabledcolumns = $this->columnorder; } if ($this->disabledcolumns) { $disabledcolumns = $this->disabledcolumns; foreach ($disabledcolumns as $class => $column) { if (strpos($class, $plugin) !== false) { $enabledcolumns[$class] = $class; if (isset($disabledcolumns[$class])) { unset($disabledcolumns[$class]); } } } } $this->update_config($enabledcolumns, $disabledcolumns); } /** * Disables columns. * * @param string $plugin Plugin type and name ie: qbank_viewcreator. */ public function disable_columns(string $plugin): void { $disabledcolumns = []; $enabledcolumns = []; $allcolumns = $this->get_columns(); if ($this->disabledcolumns) { $disabledcolumns = $this->disabledcolumns; } if ($this->columnorder) { $enabledcolumns = $this->columnorder; } foreach ($allcolumns as $column) { if (str_contains($column->class, $plugin)) { $disabledcolumns[$column->id] = $column->id; if (isset($enabledcolumns[$column->id])) { unset($enabledcolumns[$column->id]); } } } $this->update_config($enabledcolumns, $disabledcolumns); } /** * Orders columns in the question bank view according to config_plugins table 'qbank_columnsortorder' config. * * @param array $ordertosort Unordered array of columns, [columnname => class] * @return array $properorder|$ordertosort Returns array ordered if 'qbank_columnsortorder' config exists. */ public function get_sorted_columns($ordertosort): array { // Check if db has order set. if (!empty($this->columnorder)) { // Merge new order with old one. $columnsortorder = $this->columnorder; asort($columnsortorder); $columnorder = []; foreach ($columnsortorder as $columnid => $colposition) { if (array_key_exists($columnid, $ordertosort)) { $columnorder[$columnid] = $colposition; } } $properorder = array_merge($columnorder, $ordertosort); // Always have the checkbox at first column position. $checkboxid = 'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column'; if (isset($properorder[$checkboxid])) { $checkboxfirstelement = $properorder[$checkboxid]; unset($properorder[$checkboxid]); $properorder = array_merge([ $checkboxid => $checkboxfirstelement ], $properorder); } return $properorder; } return $ordertosort; } /** * Given an array of columns, set the isvisible attribute according to $this->hiddencolumns and $this->disabledcolumns. * * @param column_base[] $columns * @return array */ public function set_columns_visibility(array $columns): array { foreach ($columns as $column) { if (!is_object($column)) { continue; } $columnid = $column->get_column_id(); $column->isvisible = !in_array($columnid, $this->hiddencolumns) && !array_key_exists($columnid, $this->disabledcolumns); } return $columns; } /** * Return $this->colsize mapped as an array of column name => width, excluding empty sizes. * * @return array */ public function get_colsize_map(): array { $sizes = array_reduce($this->colsize, function($result, $colsize) { $result[$colsize->column] = $colsize->width; return $result; }, []); return array_filter($sizes); } /** * Return an array of hidden columns as an array of class => column name * * @return array */ public function get_hidden_columns(): array { $result = $this->create_column_objects($this->hiddencolumns); $hidden = []; foreach ($result as $column => $columnobject) { $hidden[$column] = $columnobject->get_title(); } return $hidden; } /** * Returns an array of column objects. * * @param array $columnsnames Array of columns. * @return column_base[] Array of $columnsname => $columnobject */ public function create_column_objects(array $columnsnames): array { $result = []; foreach ($columnsnames as $column) { [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $column, 2); if (class_exists($columnclass)) { $columnobject = $columnclass::from_column_name($this->get_questionbank(), $columnname, true); if ($columnobject != null) { $result[$column] = $columnobject; } } } return $result; } public function get_column_width(column_base $column): string { $colsizemap = $this->get_colsize_map(); $columnid = $column->get_column_id(); if (array_key_exists($columnid, $colsizemap)) { return $colsizemap[$columnid] . 'px'; } return parent::get_column_width($column); } public function get_column_actions(view $qbank): array { return [ new column_action_move($qbank), new column_action_remove($qbank), new column_action_resize($qbank), ]; } } PK\ @ @ 2bank/columnsortorder/classes/output/add_column.phpnu[. namespace qbank_columnsortorder\output; use core_reportbuilder\local\models\column; use qbank_columnsortorder\column_manager; use moodle_url; use renderer_base; /** * Renderable for the "add column" dropdown list * * @package qbank_columnsortorder * @copyright 2023 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 add_column implements \renderable, \templatable { /** @var column_manager The column manager for getting the list of hidden columns. */ protected column_manager $columnmanager; /** @var moodle_url The current page URL to redirect back to. */ protected moodle_url $returnurl; /** @var bool True if we are changing global config, false for user preferences. */ protected bool $global; /** * Store arguments for generating template context. * * @param column_manager $columnmanager * @param moodle_url $returnurl * @param bool $global */ public function __construct(column_manager $columnmanager, moodle_url $returnurl, bool $global = false) { $this->columnmanager = $columnmanager; $this->returnurl = $returnurl; $this->global = $global; } public function export_for_template(renderer_base $output): array { $hiddencolumns = []; foreach ($this->columnmanager->get_hidden_columns() as $class => $name) { $addurl = new moodle_url('/question/bank/columnsortorder/actions.php', [ 'action' => 'add', 'global' => $this->global, 'column' => $class, 'sesskey' => sesskey(), 'returnurl' => $this->returnurl, ]); $hiddencolumns[] = [ 'name' => $name, 'addurl' => $addurl->out(false), 'column' => $class, 'addtext' => get_string('addcolumn', 'qbank_columnsortorder', $name), ]; } return [ 'hashiddencolumns' => !empty($hiddencolumns), 'hiddencolumns' => $hiddencolumns, ]; } } PK\'q88;bank/columnsortorder/classes/output/column_sort_preview.phpnu[. namespace qbank_columnsortorder\output; use moodle_url; use templatable; use renderable; use qbank_columnsortorder\column_manager; /** * Renderable for the question bank preview. * * This takes the HTML for a question bank preview, and displays in a page with a link to return to the admin screen. * * @package qbank_columnsortorder * @copyright 2023 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 column_sort_preview implements renderable, templatable { /** @var string Rendered preview HTML. */ protected string $preview; /** * Store rendered preview for template context. * * @param string $preview */ public function __construct(string $preview) { $this->preview = $preview; } public function export_for_template(\renderer_base $output): array { $context = [ 'backurl' => new moodle_url('/question/bank/columnsortorder/sortcolumns.php'), 'preview' => $this->preview, ]; return $context; } } PK\zz5bank/columnsortorder/classes/output/reset_columns.phpnu[. namespace qbank_columnsortorder\output; use renderer_base; /** * Renderable for resetting customised column settings. * * This will display a link that resets all customised column settings and redirects back to the current page. * * @package qbank_columnsortorder * @copyright 2023 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 reset_columns implements \renderable, \templatable { /** @var \moodle_url The current page URL to redirect back to. */ protected \moodle_url $returnurl; /** @var bool True if we are changing global config, false for user preferences. */ protected bool $global; /** * Store data for generating the template context. * * @param \moodle_url $returnurl * @param bool $global */ public function __construct(\moodle_url $returnurl, bool $global = false) { $this->returnurl = $returnurl; $this->global = $global; } public function export_for_template(renderer_base $output): array { $reseturl = new \moodle_url('/question/bank/columnsortorder/actions.php', [ 'action' => 'reset', 'global' => $this->global, 'sesskey' => sesskey(), 'returnurl' => $this->returnurl->out(), ]); return [ 'reseturl' => $reseturl->out(false), ]; } } PK\XF6bank/columnsortorder/classes/output/column_sort_ui.phpnu[. namespace qbank_columnsortorder\output; use core_question\local\bank\column_base; use qbank_columnsortorder\local\bank\column_action_remove; use moodle_url; use qbank_columnsortorder\column_manager; use renderable; use templatable; /** * Renderable for the column sort admin UI. * * Displays a list of the currently enabled columns and allows them to be sorted, hidden, and resized. * * @package qbank_columnsortorder * @copyright 2023 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 column_sort_ui implements renderable, templatable { /** * The minimum custom width for a column. * * This is based on the minimum possible width of the smallest core column (question type). * When viewed, the width will be resized to the minimum width of the column header, if too small. * * @var int */ const MIN_COLUMN_WIDTH = 30; public function export_for_template(\renderer_base $output): array { $columnmanager = new column_manager(true); $enabledcolumns = $columnmanager->get_columns(); $disabledcolumns = $columnmanager->get_disabled_columns(); $columnsizes = $columnmanager->get_colsize_map(); $qbank = $columnmanager->get_questionbank(); $returnurl = new moodle_url('/question/bank/columnsortorder/sortcolumns.php'); $params = []; $params['formaction'] = new moodle_url('/question/bank/columnsortorder/actions.php'); $params['sesskey'] = sesskey(); $params['disabled'] = $disabledcolumns; $params['contextid'] = \context_system::instance()->id; $params['minwidth'] = self::MIN_COLUMN_WIDTH; foreach ($enabledcolumns as $column) { if (in_array($column->id, $columnmanager->hiddencolumns) || array_key_exists($column->id, $disabledcolumns)) { continue; } $name = $column->name; $colname = get_string('qbankcolumnname', 'qbank_columnsortorder', $column->colname); $removeaction = new column_action_remove($qbank); $removeaction->set_global(true); $actionmenu = new \action_menu([ $removeaction->get_action_menu_link($column->class::from_column_name($qbank, $column->colname)), ]); $params['names'][] = [ 'name' => $name, 'movetitle' => get_string('movecontent', 'core', $name), 'colname' => $colname, 'class' => $column->class, 'width' => $columnsizes[$column->id] ?? null, 'widthlabel' => get_string('width', 'qbank_columnsortorder', $name), 'actionmenu' => $actionmenu->export_for_template($output), 'columnid' => $column->id, 'escapedid' => str_replace('\\', '__', $column->id), ]; } $params['disabled'] = array_values($disabledcolumns); $params['columnsdisabled'] = !empty($params['disabled']); $addcolumn = new add_column($columnmanager, $returnurl); $params['addcolumn'] = $addcolumn->export_for_template($output); $resetcolums = new reset_columns($returnurl); $params['resetcolumns'] = $resetcolums->export_for_template($output); $params['extraclasses'] = 'pe-1'; $urltoredirect = new moodle_url('/admin/settings.php', ['section' => 'manageqbanks']); $params['urltomanageqbanks'] = get_string('qbankgotomanageqbanks', 'qbank_columnsortorder', $urltoredirect->out()); $params['previewurl'] = new moodle_url('/question/bank/columnsortorder/sortcolumns.php', [ 'preview' => true, ]); return $params; } } PK\i660bank/columnsortorder/classes/output/fragment.phpnu[. namespace qbank_columnsortorder\output; /** * Output fragments for the column sort order interface * * @package qbank_columnsortorder * @copyright 2023 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 fragment { /** * Render the column sort UI with the current global config. * * @return string */ public static function column_sort_ui(): string { global $OUTPUT; return $OUTPUT->render(new \qbank_columnsortorder\output\column_sort_ui()); } } PK\p6bank/columnsortorder/classes/event/plugin_observer.phpnu[. namespace qbank_columnsortorder\event; use core\event\qbank_plugin_disabled; use core\event\qbank_plugin_enabled; use qbank_columnsortorder\column_manager; /** * Observer for qbank plugin enabled/disabled events * * @package qbank_columnsortorder * @copyright 2023 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 plugin_observer { /** * When a plugin is enabled, enable its columns. * * @param qbank_plugin_enabled $event * @return void */ public static function plugin_enabled(qbank_plugin_enabled $event): void { (new column_manager())->enable_columns($event->other['pluginname']); } /** * When a plugin is disabled, disable its columns. * * @param qbank_plugin_disabled $event * @return void */ public static function plugin_disabled(qbank_plugin_disabled $event): void { (new column_manager())->disable_columns($event->other['pluginname']); } } PK\X l l Abank/columnsortorder/classes/tests/external_function_testcase.phpnu[. /** * Class defining resuable tests methods for external functions * * @package qbank_columnsortorder * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_columnsortorder\tests; /** * Class defining resuable tests methods for external functions */ abstract class external_function_testcase extends \advanced_testcase { /** * @var string Fully-qualified external function class to test. */ protected $testclass; /** * @var string The name of the setting used to store the data. */ protected $setting; /** * @var bool Whether the data is stored as a comma-separated list. */ protected $csv = true; /** * A function that returns the data to be passed to the external function. * * The data returned will depend on the testclass. * * @return mixed */ abstract protected function generate_test_data(): mixed; /** * Test that execute() method sets the correct config setting. */ public function test_execute(): void { $this->resetAfterTest(true); $this->setAdminUser(); $testdata = $this->generate_test_data(); $this->testclass::execute($testdata, true); $currentconfig = get_config('qbank_columnsortorder', $this->setting); if ($this->csv) { $currentconfig = explode(',', $currentconfig); } $this->assertEqualsCanonicalizing($testdata, $currentconfig); } /** * Test that execute() method sets user preference when a component is passed. */ public function test_execute_user(): void { $this->resetAfterTest(true); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $testdata = $this->generate_test_data(); $this->testclass::execute($testdata); $userpreference = get_user_preferences('qbank_columnsortorder_' . $this->setting); if ($this->csv) { $userpreference = explode(',', $userpreference); } $this->assertEqualsCanonicalizing($testdata, $userpreference); } } PK\և| | /bank/columnsortorder/classes/plugin_feature.phpnu[. namespace qbank_columnsortorder; use core\context; use core_question\local\bank\column_manager_base; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; use qbank_columnsortorder\output\add_column; use qbank_columnsortorder\output\reset_columns; /** * Plugin features for qbank_columnsortorder * * @package qbank_columnsortorder * @copyright 2023 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 plugin_feature extends plugin_features_base { /** * Override the default column manager. * * This will set the column order, size and visibility based on the global settings defined on the admin screen, or on the * current user's preference if they have set one. * * @return ?column_manager_base */ public function get_column_manager(): ?column_manager_base { return new column_manager(); } /** * Return add and reset column controls. * * @param view $qbank The question bank view. * @param context $context The current context, for permission checks. * @param int $categoryid The current question category ID. * @return \renderable[] */ public function get_question_bank_controls(view $qbank, context $context, int $categoryid): array { global $PAGE; $PAGE->requires->js_call_amd('qbank_columnsortorder/user_actions', 'init'); $returnurl = new \moodle_url($qbank->returnurl); return [ 200 => new add_column(new column_manager(), $returnurl), 300 => new reset_columns($returnurl), ]; } } PK\jĺ  6bank/columnsortorder/templates/column_sort_ui.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/column_sort_ui Display a list of enable columns with customisation controls, and a list of columns from disabled plugins, with a link to the question bank plugin management screen. Context variables required for this template: * contextid - Current context id. * formaction - The URL of the actions controller to submit the form to. * sesskey - The current sesskey. * previewurl - The URL of the question bank preview page. * addcolumn - The context for the qbank_columnsortorder/add_column template. * resetcolumns - The context for the qbank_columnsortorder/reset_columns template. * names - A list of the currently active columns * name - The display name of the column * tiptitle - The tooltip text for the move handle. * colname - The unique class name for the column from the plugin. * escapedclass - The class name with \ replaced by __. * widthlabel - The label text of for this column's width field. * minwidth - The minimum value for the width field. * width - The current value for the width field. * actionmenu - The context for core/action_menu, a list of actions for the column. * movetitle - The move handle button's accessible name. This is used by the core/drag_handle template. * columnsdisabled - Are the any columns defined by disabled plugins? * disabled - A list of disabled column names. * disabledname - The disabled column's name. * urltomanageqbanks - Link to the page for managing qbank plugins. Example context (json): { "contextid": 1, "formaction": "https://example.com/question/bank/columnsortorder/actions.php", "sesskey": "12345abcde", "previewurl": "https://example.com/question/bank/columnsortorder/sortcolumns.php?preview=1", "addcolumn": { "hashiddencolumns": true, "hiddencolumns": [ { "name": "Column A", "class": "class_name_A", "addurl": "/question/bank/columnsortorder/actions.php?action=add&column=class_name_A" }, { "name": "Column B", "class": "class_name_B", "addurl": "/question/bank/columnsortorder/actions.php?action=add&column=class_name_B" } ] }, "resetcolumns": { "reseturl": "https://example.com/question/bank/columnsortorder/actions.php?action=reset" }, "names": [ { "name": "Column A", "tiptitle": "Move Column A", "columnid": "qbank_example\\col_name_A-col_name_A", "escapedid": "qbank_example__col_name_A-col_name_A", "widthlabel": "Width of Column A", "minwidth": "10", "width": "", "movetitle": "Move Column A" }, { "name": "Column B", "tiptitle": "Move Column B", "columnid": "qbank_example\\col_name_B-col_name_B", "escapedid": "qbank_example__col_name_B-col_name_B", "minwidth": "10", "width": "200", "movetitle": "Move Column B" } ], "columnsdisabled": true, "disabled": [ { "disabledname": "disabled_1" }, { "disabledname": "disabled_2" } ], "urltomanageqbanks": "Manage qbank plugins" } }}

{{#str}}qbanksortdescription, qbank_columnsortorder{{/str}}

{{! }}
{{#str}}preview{{/str}} {{#addcolumn}} {{>qbank_columnsortorder/add_column}} {{/addcolumn}}
{{#resetcolumns}} {{>qbank_columnsortorder/reset_columns}} {{/resetcolumns}}
{{#names}} {{/names}}
{{#str}}move{{/str}} {{#str}}name{{/str}} {{#str}}plugin{{/str}} {{#str}}columnwidth, qbank_columnsortorder{{/str}} {{#str}}action{{/str}}
{{>core/drag_handle}} {{name}} {{colname}}
{{#str}}invalidwidth, qbank_columnsortorder, {{minwidth}}{{/str}}
{{#actionmenu}}{{>core/action_menu}}{{/actionmenu}}
{{#columnsdisabled}}

{{#str}}qbankcolumnsdisabled, qbank_columnsortorder{{/str}}

    {{#disabled}}
  • {{disabledname}}
  • {{/disabled}}
{{/columnsdisabled}}
{{{urltomanageqbanks}}}
{{#js}} require(['qbank_columnsortorder/admin_actions'], function(AdminTable) { AdminTable.init("qbank_columnsortorder-{{uniqid}}"); }); {{/js}}
PK\WF~~;bank/columnsortorder/templates/column_sort_preview.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/column_sort_preview Display a preview of the question bank, with a button to return to the customisation UI. Context variables required for this template: * backurl - URL of customisation UI page. * preview - Rendered HTML for a question bank table with the current settings. Example context (json): { "backurl": "https://example.com/question/bank/columnsortorder/sortcolumns.php", "preview": "
" } }}
{{#str}} back {{/str}}
{{{preview}}}
PK\4bank/columnsortorder/templates/resize_modal.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/resize_modal This template renders the content of the resize modal, a number input to set the column width in pixels. Example context (json): { } }}
{{#str}}invalidwidth, qbank_columnsortorder, {{min}}{{/str}}
PK\J+$$5bank/columnsortorder/templates/action_handle.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/action_handle Render a single action handle for customising a column. Example context (json): { "action": "move", "target": "", "title": "Move column A", "pixicon": "i/dragdrop", "pixcomponent": "core", "popup": true } }} {{#pixicon}} {{#pix}}{{pixicon}}, {{pixcomponent}}{{/pix}} {{/pixicon}} {{#icon}} {{/icon}} {{#text}} {{text}} {{/text}} PK\q[5bank/columnsortorder/templates/reset_columns.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/reset_columns Button to reset question bank column settings to defaults. Context variables required for this template: Example context (json): { "reseturl": "https://example.com/question/bank/columnsortorder/actions.php?action=reset" } }} {{#str}}resetcolumns, qbank_columnsortorder{{/str}} PK\T_Nzz2bank/columnsortorder/templates/add_column.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/add_column Add column widget Displays a list of currently hidden columns, with links to add them back to the question bank. Context variables required for this template: * hiddencolumns - list of currently hidden columns Example context (json): { "hashiddencolumns": true, "hiddencolumns": [ { "name": "Column A", "class": "class_name_A", "addurl": "/question/bank/columnsortorder/actions.php?action=add&column=class_name_A" }, { "name": "Column B", "class": "class_name_B", "addurl": "/question/bank/columnsortorder/actions.php?action=add&column=class_name_B" } ] } }} {{#hashiddencolumns}} {{/hashiddencolumns}} PK\jj8bank/columnsortorder/templates/handle_container.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_columnsortorder/handle_container Container for move/resize handles. Example context (json): { } }}
PK\8 bank/columnsortorder/actions.phpnu[. /** * Actions controller * * Perform a synchronous action to modify the question bank UI and redirect back to the previous page. * These features are mostly progressively enhanced by actions.js and web services, but this remains as a fallback. * * @package qbank_columnsortorder * @copyright 2023 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 */ require_once(__DIR__ . '/../../../config.php'); $action = required_param('action', PARAM_TEXT); $global = optional_param('global', false, PARAM_BOOL); $returnurl = optional_param('returnurl', '/question/bank/columnsortorder/sortcolumns.php', PARAM_LOCALURL); require_login(); if ($global) { require_capability('moodle/site:config', context_system::instance()); } if ($action === 'debugreset' && $CFG->debug === DEBUG_DEVELOPER) { $columnmanager = new \qbank_columnsortorder\column_manager($global); $columnmanager::set_hidden_columns([], $global); $columnmanager::set_column_order([], $global); $columnmanager::set_column_size('', $global); redirect(new moodle_url($returnurl)); } require_sesskey(); $columnmanager = new \qbank_columnsortorder\column_manager($global); switch ($action) { case 'add': case 'remove': $column = required_param('column', PARAM_RAW); [$columnclass, ] = explode(\core_question\local\bank\column_base::ID_SEPARATOR, $column); if (!class_exists($columnclass)) { throw new invalid_parameter_exception("'{$columnclass}' is not a valid column class."); } $hiddencolumns = $columnmanager->hiddencolumns; if ($action === 'add') { $key = array_search($column, $hiddencolumns); if ($key !== false) { unset($hiddencolumns[$key]); } } else { if (!in_array($column, $hiddencolumns)) { $hiddencolumns[] = $column; } } $columnmanager::set_hidden_columns($hiddencolumns, $global); break; case 'savewidths': $rawwidths = optional_param_array('width', [], PARAM_INT); $widths = []; foreach (array_filter($rawwidths) as $escapedclass => $width) { $class = str_replace('__', '\\', $escapedclass); // Validate that the class exists and the width is valid. // Since the browser uses Constraint Validation to prevent the form being submitted with an invalid width, // the only way we'll get one here is if someone is messing around, so don't worry about re-displaying the // form with an error message, just ignore the invalid value. if (class_exists($class) && $width >= 10) { $widths[] = (object)[ 'column' => $class, 'width' => $width, ]; } } $columnmanager::set_column_size(json_encode($widths), $global); break; case 'reset': $columnmanager::set_hidden_columns(null, $global); $columnmanager::set_column_order(null, $global); $columnmanager::set_column_size(null, $global); break; } redirect(new moodle_url($returnurl)); PK\Ki i -bank/columnsortorder/amd/src/admin_actions.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Javascript for handling actions on the admin page * * @module qbank_columnsortorder/admin_actions * @copyright 2023 onwards Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as actions from 'qbank_columnsortorder/actions'; import * as repository from 'qbank_columnsortorder/repository'; import Notification from "core/notification"; import Pending from 'core/pending'; /** * Event handler to save the custom column widths when a field is edited. * * @param {Element} listRoot The root element of the list of columns. */ const setupSaveWidths = listRoot => { listRoot.addEventListener('change', async() => { const pendingPromise = new Pending('saveWidths'); const columns = listRoot.querySelectorAll(actions.SELECTORS.sortableColumn); const widths = []; columns.forEach(column => { const widthInput = column.querySelector('.width-input'); const valid = widthInput.checkValidity(); widthInput.closest('.has-validation').classList.add('was-validated'); if (!valid) { return; } widths.push({ column: column.dataset.columnid, width: widthInput.value, }); }); await repository.setColumnSize(JSON.stringify(widths), true).catch(Notification.exception); pendingPromise.resolve(); }); }; /** * Initialize module * * Set up event handlers for the action buttons, width fields and initialise column sorting. * * @param {String} id ID for the admin UI root element. */ export const init = id => { const uiRoot = document.getElementById(id); const listRoot = uiRoot.querySelector(actions.SELECTORS.columnList); actions.setupSortableLists(listRoot, true, true); actions.setupActionButtons(uiRoot, true); setupSaveWidths(listRoot); }; PK\|%L:L:,bank/columnsortorder/amd/src/user_actions.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Javascript for customising the user's view of the question bank * * @module qbank_columnsortorder/user_actions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as actions from 'qbank_columnsortorder/actions'; import * as repository from 'qbank_columnsortorder/repository'; import {get_string as getString} from 'core/str'; import ModalEvents from 'core/modal_events'; import ModalSaveCancel from 'core/modal_save_cancel'; import Notification from "core/notification"; import SortableList from 'core/sortable_list'; import Templates from "core/templates"; const SELECTORS = { uiRoot: '.questionbankwindow', moveAction: '.menu-action[data-action=move]', resizeAction: '.menu-action[data-action=resize]', resizeHandle: '.qbank_columnsortorder-action-handle.resize', handleContainer: '.handle-container', headerContainer: '.header-container', tableColumn: identifier => `td[data-columnid="${identifier.replace(/["\\]/g, '\\$&')}"]`, }; /** To track mouse event on a table header */ let currentHeader; /** Current mouse x postion, to track mouse event on a table header */ let currentX; /** Minimum size for the column currently being resized. */ let currentMin; /** * Flag to temporarily prevent move and resize handles from being shown or hidden. * * @type {boolean} */ let suspendShowHideHandles = false; /** * Add handle containers for move and resize handles. * * @param {Element} uiRoot The root element of the quesiton bank UI. * @return {Promise} Resolved after the containers have been added to each column header. */ const addHandleContainers = uiRoot => { return new Promise((resolve) => { const headerContainers = uiRoot.querySelectorAll(SELECTORS.headerContainer); Templates.renderForPromise('qbank_columnsortorder/handle_container', {}) .then(({html, js}) => { headerContainers.forEach(container => { Templates.prependNodeContents(container, html, js); }); resolve(); return headerContainers; }).catch(Notification.exception); }); }; /** * Render move handles in each container. * * This takes a list of the move actions rendered in each column header, and creates a corresponding drag handle for each. * * @param {NodeList} moveActions Menu actions for moving columns. */ const setUpMoveHandles = moveActions => { moveActions.forEach(moveAction => { const header = moveAction.closest('th'); header.classList.add('qbank-sortable-column'); const handleContainer = header.querySelector(SELECTORS.handleContainer); const context = { action: "move", dragtype: "move", target: '', title: moveAction.title, pixicon: "i/dragdrop", pixcomponent: "core", popup: true }; return Templates.renderForPromise('qbank_columnsortorder/action_handle', context) .then(({html, js}) => { Templates.prependNodeContents(handleContainer, html, js); return handleContainer; }).catch(Notification.exception); }); }; /** * Serialise the current column sizes. * * This finds the current width set in each column header's style property, and returns them encoded as a JSON string. * * @param {Element} uiRoot The root element of the quesiton bank UI. * @return {String} JSON array containing a list of objects with column and width properties. */ const serialiseColumnSizes = (uiRoot) => { const columnSizes = []; const tableHeaders = uiRoot.querySelectorAll('th'); tableHeaders.forEach(header => { // Only get the width set via style attribute (set by move action). const width = parseInt(header.style.width); if (!width || isNaN(width)) { return; } columnSizes.push({ column: header.dataset.columnid, width: width }); }); return JSON.stringify(columnSizes); }; /** * Find the minimum width for a header, based on the width of its contents. * * This is to simulate `min-width: min-content;`, which doesn't work on Chrome because * min-width is ignored width `table-layout: fixed;`. * * @param {Element} header The table header * @return {Number} The minimum width in pixels */ const getMinWidth = (header) => { const contents = Array.from(header.querySelector('.header-text').children); const contentWidth = contents.reduce((width, contentElement) => width + contentElement.getBoundingClientRect().width, 0); return Math.ceil(contentWidth); }; /** * Render resize handles in each container. * * This takes a list of the resize actions rendered in each column header, and creates a corresponding drag handle for each. * It also initialises the event handlers for the drag handles and resize modal. * * @param {Element} uiRoot Question bank UI root element. */ const setUpResizeHandles = (uiRoot) => { const resizeActions = uiRoot.querySelectorAll(SELECTORS.resizeAction); resizeActions.forEach(resizeAction => { const headerContainer = resizeAction.closest(SELECTORS.headerContainer); const header = resizeAction.closest(actions.SELECTORS.sortableColumn); const minWidth = getMinWidth(header); if (header.offsetWidth < minWidth) { header.style.width = minWidth + 'px'; } const handleContainer = headerContainer.querySelector(SELECTORS.handleContainer); const context = { action: "resize", target: '', title: resizeAction.title, pixicon: 'i/twoway', pixcomponent: 'core', popup: true }; return Templates.renderForPromise('qbank_columnsortorder/action_handle', context) .then(({html, js}) => { Templates.appendNodeContents(handleContainer, html, js); return handleContainer; }).catch(Notification.exception); }); let moveTracker = false; let currentResizeHandle = null; // Start mouse event on headers. uiRoot.addEventListener('mousedown', e => { currentResizeHandle = e.target.closest(SELECTORS.resizeHandle); // Return if it is not ' resize' button. if (!currentResizeHandle) { return; } // Save current position. currentX = e.pageX; // Find the header. currentHeader = e.target.closest(actions.SELECTORS.sortableColumn); currentMin = getMinWidth(currentHeader); moveTracker = false; suspendShowHideHandles = true; }); // Resize column as the mouse move. document.addEventListener('mousemove', e => { if (!currentHeader || !currentResizeHandle || currentX === 0) { return; } // Prevent text selection as the handle is dragged. document.getSelection().removeAllRanges(); // Adjust the column width according the amount the handle was dragged. const offset = e.pageX - currentX; currentX = e.pageX; const newWidth = currentHeader.offsetWidth + offset; if (newWidth >= currentMin) { currentHeader.style.width = newWidth + 'px'; } moveTracker = true; }); // Set new size when mouse is up. document.addEventListener('mouseup', () => { if (!currentHeader || !currentResizeHandle || currentX === 0) { return; } if (moveTracker) { // If the mouse moved, we are changing the size by drag, so save the change. repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception); } else { // If the mouse didn't move, display a modal to change the size using a form. showResizeModal(currentHeader, uiRoot); } currentMin = null; currentHeader = null; currentResizeHandle = null; currentX = 0; moveTracker = false; suspendShowHideHandles = false; }); }; /** * Event handler for resize actions in each column header. * * This will listen for a click on any resize action, and activate the corresponding resize modal. * * @param {Element} uiRoot Question bank UI root element. */ const setUpResizeActions = uiRoot => { uiRoot.addEventListener('click', (e) => { const resizeAction = e.target.closest(SELECTORS.resizeAction); if (resizeAction) { e.preventDefault(); const currentHeader = resizeAction.closest('th'); showResizeModal(currentHeader, uiRoot); } }); }; /** * Show a modal containing a number input for changing a column width without click-and-drag. * * @param {Element} currentHeader The header element that is being resized. * @param {Element} uiRoot The question bank UI root element. * @returns {Promise} */ const showResizeModal = async(currentHeader, uiRoot) => { const initialWidth = currentHeader.offsetWidth; const minWidth = getMinWidth(currentHeader); const modal = await ModalSaveCancel.create({ title: getString('resizecolumn', 'qbank_columnsortorder', currentHeader.dataset.name), body: Templates.render('qbank_columnsortorder/resize_modal', {width: initialWidth, min: minWidth}), show: true, }); const root = modal.getRoot(); root.on(ModalEvents.cancel, () => { currentHeader.style.width = `${initialWidth}px`; }); root.on(ModalEvents.save, () => { repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception); }); const body = await modal.bodyPromise; const input = body.get(0).querySelector('input'); input.addEventListener('change', e => { const valid = e.target.checkValidity(); e.target.closest('.has-validation').classList.add('was-validated'); if (valid) { const newWidth = e.target.value; currentHeader.style.width = `${newWidth}px`; } }); }; /** * Event handler for move actions in each column header. * * This will listen for a click on any move action, pass the click to the corresponding move handle, causing its modal to be shown. * * @param {Element} uiRoot Question bank UI root element. */ const setUpMoveActions = uiRoot => { uiRoot.addEventListener('click', e => { const moveAction = e.target.closest(SELECTORS.moveAction); if (moveAction) { e.preventDefault(); const sortableColumn = moveAction.closest(actions.SELECTORS.sortableColumn); const moveHandle = sortableColumn.querySelector(actions.SELECTORS.moveHandler); moveHandle.click(); } }); }; /** * Event handler for showing and hiding handles when the mouse is over a column header. * * Implementing this behaviour using the :hover CSS pseudoclass is not sufficient, as the mouse may move over the neighbouring * header while dragging, leading to some odd behaviour. This allows us to suspend the show/hide behaviour while a handle is being * dragged, and so keep the active handle visible until the drag is finished. * * @param {Element} uiRoot Question bank UI root element. */ const setupShowHideHandles = uiRoot => { let shownHeader = null; let tableHead = uiRoot.querySelector('thead'); uiRoot.addEventListener('mouseover', e => { if (suspendShowHideHandles) { return; } const header = e.target.closest(actions.SELECTORS.sortableColumn); if (!header && !shownHeader) { return; } if (!header || header !== shownHeader) { tableHead.querySelector('.show-handles')?.classList.remove('show-handles'); shownHeader = header; if (header) { header.classList.add('show-handles'); } } }); }; /** * Event handler for sortable list DROP event. * * Find all table cells corresponding to the column of the dropped header, and move them to the new position. * * @param {Event} event */ const reorderColumns = event => { // Current header. const header = event.target; // Find the previous sibling of the header, which will be used when moving columns. const insertAfter = header.previousElementSibling; // Move columns. const uiRoot = document.querySelector(SELECTORS.uiRoot); const columns = uiRoot.querySelectorAll(SELECTORS.tableColumn(header.dataset.columnid)); columns.forEach(column => { const row = column.parentElement; if (insertAfter) { // Find the column to insert after. const insertAfterColumn = row.querySelector(SELECTORS.tableColumn(insertAfter.dataset.columnid)); // Insert the column. insertAfterColumn.after(column); } else { // Insert as the first child (first column in the table). row.insertBefore(column, row.firstChild); } }); }; /** * Initialize module * * Add containers for the drag handles to each column header, then render handles, enable show/hide behaviour, set up drag/drop * column sorting, then enable the move and resize modals to be triggered from menu actions. */ export const init = async() => { const uiRoot = document.getElementById('questionscontainer'); await addHandleContainers(uiRoot); setUpMoveHandles(uiRoot.querySelectorAll(SELECTORS.moveAction)); setUpResizeHandles(uiRoot); setupShowHideHandles(uiRoot); const sortableColumns = actions.setupSortableLists(uiRoot.querySelector(actions.SELECTORS.columnList)); sortableColumns.on(SortableList.EVENTS.DROP, reorderColumns); sortableColumns.on(SortableList.EVENTS.DRAGSTART, () => { suspendShowHideHandles = true; }); sortableColumns.on(SortableList.EVENTS.DRAGEND, () => { suspendShowHideHandles = false; }); setUpMoveActions(uiRoot); setUpResizeActions(uiRoot); actions.setupActionButtons(uiRoot); }; PK\Y 'bank/columnsortorder/amd/src/actions.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Common javascript for handling actions on the admin page and the user's view of the question bank. * * @module qbank_columnsortorder/actions * @copyright 2023 onwards Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import SortableList from 'core/sortable_list'; import $ from 'jquery'; import * as repository from 'qbank_columnsortorder/repository'; import Notification from "core/notification"; import RefreshUi from 'core_question/refresh_ui'; export const SELECTORS = { columnList: '.qbank-column-list', sortableColumn: '.qbank-sortable-column', removeLink: '[data-action=remove]', moveHandler: '[data-drag-type=move]', addColumn: '.addcolumn', addLink: '[data-action=add]', actionLink: '.action-link', }; /** * Sets up sortable list in the column sort order page. * * @param {Element} listRoot Element containing the sortable list. * @param {Boolean} vertical Is the list in vertical orientation, rather than horizonal? * @param {Boolean} global Should changes be saved to global config, rather than user preferences? * @return {jQuery} sortable column elements, for attaching additional event listeners. */ export const setupSortableLists = (listRoot, vertical = false, global = false) => { const sortableList = new SortableList(listRoot, { moveHandlerSelector: SELECTORS.moveHandler, isHorizontal: !vertical, }); sortableList.getElementName = element => Promise.resolve(element.data('name')); const sortableColumns = $(SELECTORS.sortableColumn); sortableColumns.on(SortableList.EVENTS.DROP, () => { repository.setColumnbankOrder(getColumnOrder(listRoot), global).catch(Notification.exception); listRoot.querySelectorAll(SELECTORS.sortableColumn).forEach(item => item.classList.remove('active')); }); sortableColumns.on(SortableList.EVENTS.DRAGSTART, (event) => { event.currentTarget.classList.add('active'); }); return sortableColumns; }; /** * Set up event handlers for action buttons. * * For each action, call the web service to update the appropriate setting or user preference, then call the fragment to * refresh the view. * * @param {Element} uiRoot The root of the question bank UI. * @param {Boolean} global Should changes be saved to global config, rather than user preferences? */ export const setupActionButtons = (uiRoot, global = false) => { uiRoot.addEventListener('click', async(e) => { const actionLink = e.target.closest(SELECTORS.actionLink); if (!actionLink) { return; } try { e.preventDefault(); const action = actionLink.dataset.action; if (action === 'add' || action === 'remove') { const hiddenColumns = []; const addColumnList = document.querySelector(SELECTORS.addColumn); if (addColumnList) { addColumnList.querySelectorAll(SELECTORS.addLink).forEach(item => { if (action === 'add' && item === actionLink) { return; } hiddenColumns.push(item.dataset.column); }); } if (action === 'remove') { hiddenColumns.push(actionLink.dataset.column); } await repository.setHiddenColumns(hiddenColumns, global); } else if (action === 'reset') { await repository.resetColumns(global); } const actionUrl = new URL(actionLink.href); const returnUrl = new URL(actionUrl.searchParams.get('returnurl').replaceAll('&', '&')); await RefreshUi.refresh(uiRoot, returnUrl); } catch (ex) { await Notification.exception(ex); } }); }; /** * Gets the newly reordered columns to display in the question bank view. * @param {Element} listRoot * @returns {Array} */ export const getColumnOrder = listRoot => { const columns = Array.from(listRoot.querySelectorAll('[data-columnid]')) .map(column => column.dataset.columnid); return columns.filter((value, index) => columns.indexOf(value) === index); }; PK\Ejs s *bank/columnsortorder/amd/src/repository.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * External function calls for qbank_columnsortorder * * @module qbank_columnsortorder/repository * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {call as fetchMany} from 'core/ajax'; /** * Save the list of hidden columns * * @param {String[]} columns List of hidden column names * @param {Boolean} global Set global config setting, rather than user preference * @return {Promise} */ export const setHiddenColumns = (columns, global = false) => fetchMany([{ methodname: 'qbank_columnsortorder_set_hidden_columns', args: { columns, global, }, }])[0]; /** * Save the order of columns * * @param {String[]} columns List of column names in the desired order * @param {Boolean} global Set global config setting, rather than user preference * @return {Promise} */ export const setColumnbankOrder = (columns, global = false) => fetchMany([{ methodname: 'qbank_columnsortorder_set_columnbank_order', args: { columns, global, }, }])[0]; /** * Save the column widths * * @param {String} sizes JSON string encoding an array of objects with "column" and "width" properties. * @param {Boolean} global Set global config setting, rather than user preference * @return {Promise} */ export const setColumnSize = (sizes, global = false) => fetchMany([{ methodname: 'qbank_columnsortorder_set_column_size', args: { sizes, global, }, }])[0]; /** * Reset all settings. * * @param {Boolean} global Reset global config settings, rather than user preference * @return {Promise} */ export const resetColumns = (global = false) => Promise.all( fetchMany([ { methodname: 'qbank_columnsortorder_set_column_size', args: { global, }, }, { methodname: 'qbank_columnsortorder_set_columnbank_order', args: { global, }, }, { methodname: 'qbank_columnsortorder_set_hidden_columns', args: { global, }, }, ]) ); PK\P2-bank/columnsortorder/amd/build/actions.min.jsnu[define("qbank_columnsortorder/actions",["exports","core/sortable_list","jquery","qbank_columnsortorder/repository","core/notification","core_question/refresh_ui"],(function(_exports,_sortable_list,_jquery,repository,_notification,_refresh_ui){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Common javascript for handling actions on the admin page and the user's view of the question bank. * * @module qbank_columnsortorder/actions * @copyright 2023 onwards Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupSortableLists=_exports.setupActionButtons=_exports.getColumnOrder=_exports.SELECTORS=void 0,_sortable_list=_interopRequireDefault(_sortable_list),_jquery=_interopRequireDefault(_jquery),repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(repository),_notification=_interopRequireDefault(_notification),_refresh_ui=_interopRequireDefault(_refresh_ui);const SELECTORS={columnList:".qbank-column-list",sortableColumn:".qbank-sortable-column",removeLink:"[data-action=remove]",moveHandler:"[data-drag-type=move]",addColumn:".addcolumn",addLink:"[data-action=add]",actionLink:".action-link"};_exports.SELECTORS=SELECTORS;_exports.setupSortableLists=function(listRoot){let vertical=arguments.length>1&&void 0!==arguments[1]&&arguments[1],global=arguments.length>2&&void 0!==arguments[2]&&arguments[2];const sortableList=new _sortable_list.default(listRoot,{moveHandlerSelector:SELECTORS.moveHandler,isHorizontal:!vertical});sortableList.getElementName=element=>Promise.resolve(element.data("name"));const sortableColumns=(0,_jquery.default)(SELECTORS.sortableColumn);return sortableColumns.on(_sortable_list.default.EVENTS.DROP,(()=>{repository.setColumnbankOrder(getColumnOrder(listRoot),global).catch(_notification.default.exception),listRoot.querySelectorAll(SELECTORS.sortableColumn).forEach((item=>item.classList.remove("active")))})),sortableColumns.on(_sortable_list.default.EVENTS.DRAGSTART,(event=>{event.currentTarget.classList.add("active")})),sortableColumns};_exports.setupActionButtons=function(uiRoot){let global=arguments.length>1&&void 0!==arguments[1]&&arguments[1];uiRoot.addEventListener("click",(async e=>{const actionLink=e.target.closest(SELECTORS.actionLink);if(actionLink)try{e.preventDefault();const action=actionLink.dataset.action;if("add"===action||"remove"===action){const hiddenColumns=[],addColumnList=document.querySelector(SELECTORS.addColumn);addColumnList&&addColumnList.querySelectorAll(SELECTORS.addLink).forEach((item=>{"add"===action&&item===actionLink||hiddenColumns.push(item.dataset.column)})),"remove"===action&&hiddenColumns.push(actionLink.dataset.column),await repository.setHiddenColumns(hiddenColumns,global)}else"reset"===action&&await repository.resetColumns(global);const actionUrl=new URL(actionLink.href),returnUrl=new URL(actionUrl.searchParams.get("returnurl").replaceAll("&","&"));await _refresh_ui.default.refresh(uiRoot,returnUrl)}catch(ex){await _notification.default.exception(ex)}}))};const getColumnOrder=listRoot=>{const columns=Array.from(listRoot.querySelectorAll("[data-columnid]")).map((column=>column.dataset.columnid));return columns.filter(((value,index)=>columns.indexOf(value)===index))};_exports.getColumnOrder=getColumnOrder})); //# sourceMappingURL=actions.min.js.mapPK\ONN0bank/columnsortorder/amd/build/repository.min.jsnu[define("qbank_columnsortorder/repository",["exports","core/ajax"],(function(_exports,_ajax){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setHiddenColumns=_exports.setColumnbankOrder=_exports.setColumnSize=_exports.resetColumns=void 0;_exports.setHiddenColumns=function(columns){let global=arguments.length>1&&void 0!==arguments[1]&&arguments[1];return(0,_ajax.call)([{methodname:"qbank_columnsortorder_set_hidden_columns",args:{columns:columns,global:global}}])[0]};_exports.setColumnbankOrder=function(columns){let global=arguments.length>1&&void 0!==arguments[1]&&arguments[1];return(0,_ajax.call)([{methodname:"qbank_columnsortorder_set_columnbank_order",args:{columns:columns,global:global}}])[0]};_exports.setColumnSize=function(sizes){let global=arguments.length>1&&void 0!==arguments[1]&&arguments[1];return(0,_ajax.call)([{methodname:"qbank_columnsortorder_set_column_size",args:{sizes:sizes,global:global}}])[0]};_exports.resetColumns=function(){let global=arguments.length>0&&void 0!==arguments[0]&&arguments[0];return Promise.all((0,_ajax.call)([{methodname:"qbank_columnsortorder_set_column_size",args:{global:global}},{methodname:"qbank_columnsortorder_set_columnbank_order",args:{global:global}},{methodname:"qbank_columnsortorder_set_hidden_columns",args:{global:global}}]))}})); //# sourceMappingURL=repository.min.js.mapPK\ݔ 1bank/columnsortorder/amd/build/actions.min.js.mapnu[{"version":3,"file":"actions.min.js","sources":["../src/actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Common javascript for handling actions on the admin page and the user's view of the question bank.\n *\n * @module qbank_columnsortorder/actions\n * @copyright 2023 onwards Catalyst IT Europe Ltd\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport SortableList from 'core/sortable_list';\nimport $ from 'jquery';\nimport * as repository from 'qbank_columnsortorder/repository';\nimport Notification from \"core/notification\";\nimport RefreshUi from 'core_question/refresh_ui';\n\nexport const SELECTORS = {\n columnList: '.qbank-column-list',\n sortableColumn: '.qbank-sortable-column',\n removeLink: '[data-action=remove]',\n moveHandler: '[data-drag-type=move]',\n addColumn: '.addcolumn',\n addLink: '[data-action=add]',\n actionLink: '.action-link',\n};\n\n/**\n * Sets up sortable list in the column sort order page.\n *\n * @param {Element} listRoot Element containing the sortable list.\n * @param {Boolean} vertical Is the list in vertical orientation, rather than horizonal?\n * @param {Boolean} global Should changes be saved to global config, rather than user preferences?\n * @return {jQuery} sortable column elements, for attaching additional event listeners.\n */\nexport const setupSortableLists = (listRoot, vertical = false, global = false) => {\n const sortableList = new SortableList(listRoot, {\n moveHandlerSelector: SELECTORS.moveHandler,\n isHorizontal: !vertical,\n });\n sortableList.getElementName = element => Promise.resolve(element.data('name'));\n\n const sortableColumns = $(SELECTORS.sortableColumn);\n\n sortableColumns.on(SortableList.EVENTS.DROP, () => {\n repository.setColumnbankOrder(getColumnOrder(listRoot), global).catch(Notification.exception);\n listRoot.querySelectorAll(SELECTORS.sortableColumn).forEach(item => item.classList.remove('active'));\n });\n\n sortableColumns.on(SortableList.EVENTS.DRAGSTART, (event) => {\n event.currentTarget.classList.add('active');\n });\n\n return sortableColumns;\n};\n\n/**\n * Set up event handlers for action buttons.\n *\n * For each action, call the web service to update the appropriate setting or user preference, then call the fragment to\n * refresh the view.\n *\n * @param {Element} uiRoot The root of the question bank UI.\n * @param {Boolean} global Should changes be saved to global config, rather than user preferences?\n */\nexport const setupActionButtons = (uiRoot, global = false) => {\n uiRoot.addEventListener('click', async(e) => {\n const actionLink = e.target.closest(SELECTORS.actionLink);\n if (!actionLink) {\n return;\n }\n try {\n e.preventDefault();\n const action = actionLink.dataset.action;\n if (action === 'add' || action === 'remove') {\n const hiddenColumns = [];\n const addColumnList = document.querySelector(SELECTORS.addColumn);\n if (addColumnList) {\n addColumnList.querySelectorAll(SELECTORS.addLink).forEach(item => {\n if (action === 'add' && item === actionLink) {\n return;\n }\n hiddenColumns.push(item.dataset.column);\n });\n }\n if (action === 'remove') {\n hiddenColumns.push(actionLink.dataset.column);\n }\n await repository.setHiddenColumns(hiddenColumns, global);\n } else if (action === 'reset') {\n await repository.resetColumns(global);\n }\n const actionUrl = new URL(actionLink.href);\n const returnUrl = new URL(actionUrl.searchParams.get('returnurl').replaceAll('&', '&'));\n await RefreshUi.refresh(uiRoot, returnUrl);\n } catch (ex) {\n await Notification.exception(ex);\n }\n });\n};\n\n/**\n * Gets the newly reordered columns to display in the question bank view.\n * @param {Element} listRoot\n * @returns {Array}\n */\nexport const getColumnOrder = listRoot => {\n const columns = Array.from(listRoot.querySelectorAll('[data-columnid]'))\n .map(column => column.dataset.columnid);\n\n return columns.filter((value, index) => columns.indexOf(value) === index);\n};\n"],"names":["SELECTORS","columnList","sortableColumn","removeLink","moveHandler","addColumn","addLink","actionLink","listRoot","vertical","global","sortableList","SortableList","moveHandlerSelector","isHorizontal","getElementName","element","Promise","resolve","data","sortableColumns","on","EVENTS","DROP","repository","setColumnbankOrder","getColumnOrder","catch","Notification","exception","querySelectorAll","forEach","item","classList","remove","DRAGSTART","event","currentTarget","add","uiRoot","addEventListener","async","e","target","closest","preventDefault","action","dataset","hiddenColumns","addColumnList","document","querySelector","push","column","setHiddenColumns","resetColumns","actionUrl","URL","href","returnUrl","searchParams","get","replaceAll","RefreshUi","refresh","ex","columns","Array","from","map","columnid","filter","value","index","indexOf"],"mappings":";;;;;;;;mhCA8BaA,UAAY,CACrBC,WAAY,qBACZC,eAAgB,yBAChBC,WAAY,uBACZC,YAAa,wBACbC,UAAW,aACXC,QAAS,oBACTC,WAAY,yEAWkB,SAACC,cAAUC,iEAAkBC,qEACrDC,aAAe,IAAIC,uBAAaJ,SAAU,CAC5CK,oBAAqBb,UAAUI,YAC/BU,cAAeL,WAEnBE,aAAaI,eAAiBC,SAAWC,QAAQC,QAAQF,QAAQG,KAAK,eAEhEC,iBAAkB,mBAAEpB,UAAUE,uBAEpCkB,gBAAgBC,GAAGT,uBAAaU,OAAOC,MAAM,KACzCC,WAAWC,mBAAmBC,eAAelB,UAAWE,QAAQiB,MAAMC,sBAAaC,WACnFrB,SAASsB,iBAAiB9B,UAAUE,gBAAgB6B,SAAQC,MAAQA,KAAKC,UAAUC,OAAO,eAG9Fd,gBAAgBC,GAAGT,uBAAaU,OAAOa,WAAYC,QAC/CA,MAAMC,cAAcJ,UAAUK,IAAI,aAG/BlB,6CAYuB,SAACmB,YAAQ7B,+DACvC6B,OAAOC,iBAAiB,SAASC,MAAAA,UACvBlC,WAAamC,EAAEC,OAAOC,QAAQ5C,UAAUO,eACzCA,eAIDmC,EAAEG,uBACIC,OAASvC,WAAWwC,QAAQD,UACnB,QAAXA,QAA+B,WAAXA,OAAqB,OACnCE,cAAgB,GAChBC,cAAgBC,SAASC,cAAcnD,UAAUK,WACnD4C,eACAA,cAAcnB,iBAAiB9B,UAAUM,SAASyB,SAAQC,OACvC,QAAXc,QAAoBd,OAASzB,YAGjCyC,cAAcI,KAAKpB,KAAKe,QAAQM,WAGzB,WAAXP,QACAE,cAAcI,KAAK7C,WAAWwC,QAAQM,cAEpC7B,WAAW8B,iBAAiBN,cAAetC,YAC/B,UAAXoC,cACDtB,WAAW+B,aAAa7C,cAE5B8C,UAAY,IAAIC,IAAIlD,WAAWmD,MAC/BC,UAAY,IAAIF,IAAID,UAAUI,aAAaC,IAAI,aAAaC,WAAW,QAAS,YAChFC,oBAAUC,QAAQzB,OAAQoB,WAClC,MAAOM,UACCrC,sBAAaC,UAAUoC,eAU5BvC,eAAiBlB,iBACpB0D,QAAUC,MAAMC,KAAK5D,SAASsB,iBAAiB,oBAChDuC,KAAIhB,QAAUA,OAAON,QAAQuB,kBAE3BJ,QAAQK,QAAO,CAACC,MAAOC,QAAUP,QAAQQ,QAAQF,SAAWC"}PK\,j j 3bank/columnsortorder/amd/build/admin_actions.min.jsnu[define("qbank_columnsortorder/admin_actions",["exports","qbank_columnsortorder/actions","qbank_columnsortorder/repository","core/notification","core/pending"],(function(_exports,actions,repository,_notification,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} /** * Javascript for handling actions on the admin page * * @module qbank_columnsortorder/admin_actions * @copyright 2023 onwards Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,actions=_interopRequireWildcard(actions),repository=_interopRequireWildcard(repository),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);_exports.init=id=>{const uiRoot=document.getElementById(id),listRoot=uiRoot.querySelector(actions.SELECTORS.columnList);actions.setupSortableLists(listRoot,!0,!0),actions.setupActionButtons(uiRoot,!0),(listRoot=>{listRoot.addEventListener("change",(async()=>{const pendingPromise=new _pending.default("saveWidths"),columns=listRoot.querySelectorAll(actions.SELECTORS.sortableColumn),widths=[];columns.forEach((column=>{const widthInput=column.querySelector(".width-input"),valid=widthInput.checkValidity();widthInput.closest(".has-validation").classList.add("was-validated"),valid&&widths.push({column:column.dataset.columnid,width:widthInput.value})})),await repository.setColumnSize(JSON.stringify(widths),!0).catch(_notification.default.exception),pendingPromise.resolve()}))})(listRoot)}})); //# sourceMappingURL=admin_actions.min.js.mapPK\#xml;;4bank/columnsortorder/amd/build/repository.min.js.mapnu[{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * External function calls for qbank_columnsortorder\n *\n * @module qbank_columnsortorder/repository\n * @copyright 2023 Catalyst IT Europe Ltd.\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\n\n/**\n * Save the list of hidden columns\n *\n * @param {String[]} columns List of hidden column names\n * @param {Boolean} global Set global config setting, rather than user preference\n * @return {Promise}\n */\nexport const setHiddenColumns = (columns, global = false) => fetchMany([{\n methodname: 'qbank_columnsortorder_set_hidden_columns',\n args: {\n columns,\n global,\n },\n}])[0];\n\n/**\n * Save the order of columns\n *\n * @param {String[]} columns List of column names in the desired order\n * @param {Boolean} global Set global config setting, rather than user preference\n * @return {Promise}\n */\nexport const setColumnbankOrder = (columns, global = false) => fetchMany([{\n methodname: 'qbank_columnsortorder_set_columnbank_order',\n args: {\n columns,\n global,\n },\n}])[0];\n\n/**\n * Save the column widths\n *\n * @param {String} sizes JSON string encoding an array of objects with \"column\" and \"width\" properties.\n * @param {Boolean} global Set global config setting, rather than user preference\n * @return {Promise}\n */\nexport const setColumnSize = (sizes, global = false) => fetchMany([{\n methodname: 'qbank_columnsortorder_set_column_size',\n args: {\n sizes,\n global,\n },\n}])[0];\n\n/**\n * Reset all settings.\n *\n * @param {Boolean} global Reset global config settings, rather than user preference\n * @return {Promise}\n */\nexport const resetColumns = (global = false) => Promise.all(\n fetchMany([\n {\n methodname: 'qbank_columnsortorder_set_column_size',\n args: {\n global,\n },\n },\n {\n methodname: 'qbank_columnsortorder_set_columnbank_order',\n args: {\n global,\n },\n },\n {\n methodname: 'qbank_columnsortorder_set_hidden_columns',\n args: {\n global,\n },\n },\n ])\n);\n"],"names":["columns","global","methodname","args","sizes","Promise","all"],"mappings":"wRAiCgC,SAACA,aAASC,sEAAmB,cAAU,CAAC,CACpEC,WAAY,2CACZC,KAAM,CACFH,QAAAA,QACAC,OAAAA,WAEJ,gCAS8B,SAACD,aAASC,sEAAmB,cAAU,CAAC,CACtEC,WAAY,6CACZC,KAAM,CACFH,QAAAA,QACAC,OAAAA,WAEJ,2BASyB,SAACG,WAAOH,sEAAmB,cAAU,CAAC,CAC/DC,WAAY,wCACZC,KAAM,CACFC,MAAAA,MACAH,OAAAA,WAEJ,0BAQwB,eAACA,sEAAmBI,QAAQC,KACpD,cAAU,CACN,CACIJ,WAAY,wCACZC,KAAM,CACFF,OAAAA,SAGR,CACIC,WAAY,6CACZC,KAAM,CACFF,OAAAA,SAGR,CACIC,WAAY,2CACZC,KAAM,CACFF,OAAAA"}PK\NRR6bank/columnsortorder/amd/build/user_actions.min.js.mapnu[{"version":3,"file":"user_actions.min.js","sources":["../src/user_actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for customising the user's view of the question bank\n *\n * @module qbank_columnsortorder/user_actions\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Ghaly Marc-Alexandre \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as actions from 'qbank_columnsortorder/actions';\nimport * as repository from 'qbank_columnsortorder/repository';\nimport {get_string as getString} from 'core/str';\nimport ModalEvents from 'core/modal_events';\nimport ModalSaveCancel from 'core/modal_save_cancel';\nimport Notification from \"core/notification\";\nimport SortableList from 'core/sortable_list';\nimport Templates from \"core/templates\";\n\n\nconst SELECTORS = {\n uiRoot: '.questionbankwindow',\n moveAction: '.menu-action[data-action=move]',\n resizeAction: '.menu-action[data-action=resize]',\n resizeHandle: '.qbank_columnsortorder-action-handle.resize',\n handleContainer: '.handle-container',\n headerContainer: '.header-container',\n tableColumn: identifier => `td[data-columnid=\"${identifier.replace(/[\"\\\\]/g, '\\\\$&')}\"]`,\n};\n\n/** To track mouse event on a table header */\nlet currentHeader;\n\n/** Current mouse x postion, to track mouse event on a table header */\nlet currentX;\n\n/** Minimum size for the column currently being resized. */\nlet currentMin;\n\n/**\n * Flag to temporarily prevent move and resize handles from being shown or hidden.\n *\n * @type {boolean}\n */\nlet suspendShowHideHandles = false;\n\n/**\n * Add handle containers for move and resize handles.\n *\n * @param {Element} uiRoot The root element of the quesiton bank UI.\n * @return {Promise} Resolved after the containers have been added to each column header.\n */\nconst addHandleContainers = uiRoot => {\n return new Promise((resolve) => {\n const headerContainers = uiRoot.querySelectorAll(SELECTORS.headerContainer);\n Templates.renderForPromise('qbank_columnsortorder/handle_container', {})\n .then(({html, js}) => {\n headerContainers.forEach(container => {\n Templates.prependNodeContents(container, html, js);\n });\n resolve();\n return headerContainers;\n }).catch(Notification.exception);\n });\n};\n\n/**\n * Render move handles in each container.\n *\n * This takes a list of the move actions rendered in each column header, and creates a corresponding drag handle for each.\n *\n * @param {NodeList} moveActions Menu actions for moving columns.\n */\nconst setUpMoveHandles = moveActions => {\n moveActions.forEach(moveAction => {\n const header = moveAction.closest('th');\n header.classList.add('qbank-sortable-column');\n const handleContainer = header.querySelector(SELECTORS.handleContainer);\n const context = {\n action: \"move\",\n dragtype: \"move\",\n target: '',\n title: moveAction.title,\n pixicon: \"i/dragdrop\",\n pixcomponent: \"core\",\n popup: true\n };\n return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)\n .then(({html, js}) => {\n Templates.prependNodeContents(handleContainer, html, js);\n return handleContainer;\n }).catch(Notification.exception);\n });\n};\n\n/**\n * Serialise the current column sizes.\n *\n * This finds the current width set in each column header's style property, and returns them encoded as a JSON string.\n *\n * @param {Element} uiRoot The root element of the quesiton bank UI.\n * @return {String} JSON array containing a list of objects with column and width properties.\n */\nconst serialiseColumnSizes = (uiRoot) => {\n const columnSizes = [];\n const tableHeaders = uiRoot.querySelectorAll('th');\n tableHeaders.forEach(header => {\n // Only get the width set via style attribute (set by move action).\n const width = parseInt(header.style.width);\n if (!width || isNaN(width)) {\n return;\n }\n columnSizes.push({\n column: header.dataset.columnid,\n width: width\n });\n });\n return JSON.stringify(columnSizes);\n};\n\n/**\n * Find the minimum width for a header, based on the width of its contents.\n *\n * This is to simulate `min-width: min-content;`, which doesn't work on Chrome because\n * min-width is ignored width `table-layout: fixed;`.\n *\n * @param {Element} header The table header\n * @return {Number} The minimum width in pixels\n */\nconst getMinWidth = (header) => {\n const contents = Array.from(header.querySelector('.header-text').children);\n const contentWidth = contents.reduce((width, contentElement) => width + contentElement.getBoundingClientRect().width, 0);\n return Math.ceil(contentWidth);\n};\n\n/**\n * Render resize handles in each container.\n *\n * This takes a list of the resize actions rendered in each column header, and creates a corresponding drag handle for each.\n * It also initialises the event handlers for the drag handles and resize modal.\n *\n * @param {Element} uiRoot Question bank UI root element.\n */\nconst setUpResizeHandles = (uiRoot) => {\n const resizeActions = uiRoot.querySelectorAll(SELECTORS.resizeAction);\n resizeActions.forEach(resizeAction => {\n const headerContainer = resizeAction.closest(SELECTORS.headerContainer);\n const header = resizeAction.closest(actions.SELECTORS.sortableColumn);\n const minWidth = getMinWidth(header);\n if (header.offsetWidth < minWidth) {\n header.style.width = minWidth + 'px';\n }\n const handleContainer = headerContainer.querySelector(SELECTORS.handleContainer);\n const context = {\n action: \"resize\",\n target: '',\n title: resizeAction.title,\n pixicon: 'i/twoway',\n pixcomponent: 'core',\n popup: true\n };\n return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)\n .then(({html, js}) => {\n Templates.appendNodeContents(handleContainer, html, js);\n return handleContainer;\n }).catch(Notification.exception);\n });\n\n let moveTracker = false;\n let currentResizeHandle = null;\n // Start mouse event on headers.\n uiRoot.addEventListener('mousedown', e => {\n currentResizeHandle = e.target.closest(SELECTORS.resizeHandle);\n // Return if it is not ' resize' button.\n if (!currentResizeHandle) {\n return;\n }\n // Save current position.\n currentX = e.pageX;\n // Find the header.\n currentHeader = e.target.closest(actions.SELECTORS.sortableColumn);\n currentMin = getMinWidth(currentHeader);\n moveTracker = false;\n suspendShowHideHandles = true;\n });\n\n // Resize column as the mouse move.\n document.addEventListener('mousemove', e => {\n if (!currentHeader || !currentResizeHandle || currentX === 0) {\n return;\n }\n\n // Prevent text selection as the handle is dragged.\n document.getSelection().removeAllRanges();\n\n // Adjust the column width according the amount the handle was dragged.\n const offset = e.pageX - currentX;\n currentX = e.pageX;\n const newWidth = currentHeader.offsetWidth + offset;\n if (newWidth >= currentMin) {\n currentHeader.style.width = newWidth + 'px';\n }\n moveTracker = true;\n });\n\n // Set new size when mouse is up.\n document.addEventListener('mouseup', () => {\n if (!currentHeader || !currentResizeHandle || currentX === 0) {\n return;\n }\n if (moveTracker) {\n // If the mouse moved, we are changing the size by drag, so save the change.\n repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);\n } else {\n // If the mouse didn't move, display a modal to change the size using a form.\n showResizeModal(currentHeader, uiRoot);\n }\n currentMin = null;\n currentHeader = null;\n currentResizeHandle = null;\n currentX = 0;\n moveTracker = false;\n suspendShowHideHandles = false;\n });\n};\n\n/**\n * Event handler for resize actions in each column header.\n *\n * This will listen for a click on any resize action, and activate the corresponding resize modal.\n *\n * @param {Element} uiRoot Question bank UI root element.\n */\nconst setUpResizeActions = uiRoot => {\n uiRoot.addEventListener('click', (e) => {\n const resizeAction = e.target.closest(SELECTORS.resizeAction);\n if (resizeAction) {\n e.preventDefault();\n const currentHeader = resizeAction.closest('th');\n showResizeModal(currentHeader, uiRoot);\n }\n });\n};\n\n/**\n * Show a modal containing a number input for changing a column width without click-and-drag.\n *\n * @param {Element} currentHeader The header element that is being resized.\n * @param {Element} uiRoot The question bank UI root element.\n * @returns {Promise}\n */\nconst showResizeModal = async(currentHeader, uiRoot) => {\n const initialWidth = currentHeader.offsetWidth;\n const minWidth = getMinWidth(currentHeader);\n\n const modal = await ModalSaveCancel.create({\n title: getString('resizecolumn', 'qbank_columnsortorder', currentHeader.dataset.name),\n body: Templates.render('qbank_columnsortorder/resize_modal', {width: initialWidth, min: minWidth}),\n show: true,\n });\n const root = modal.getRoot();\n root.on(ModalEvents.cancel, () => {\n currentHeader.style.width = `${initialWidth}px`;\n });\n root.on(ModalEvents.save, () => {\n repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);\n });\n\n const body = await modal.bodyPromise;\n const input = body.get(0).querySelector('input');\n\n input.addEventListener('change', e => {\n const valid = e.target.checkValidity();\n e.target.closest('.has-validation').classList.add('was-validated');\n if (valid) {\n const newWidth = e.target.value;\n currentHeader.style.width = `${newWidth}px`;\n }\n });\n};\n\n/**\n * Event handler for move actions in each column header.\n *\n * This will listen for a click on any move action, pass the click to the corresponding move handle, causing its modal to be shown.\n *\n * @param {Element} uiRoot Question bank UI root element.\n */\nconst setUpMoveActions = uiRoot => {\n uiRoot.addEventListener('click', e => {\n const moveAction = e.target.closest(SELECTORS.moveAction);\n if (moveAction) {\n e.preventDefault();\n const sortableColumn = moveAction.closest(actions.SELECTORS.sortableColumn);\n const moveHandle = sortableColumn.querySelector(actions.SELECTORS.moveHandler);\n moveHandle.click();\n }\n });\n};\n\n/**\n * Event handler for showing and hiding handles when the mouse is over a column header.\n *\n * Implementing this behaviour using the :hover CSS pseudoclass is not sufficient, as the mouse may move over the neighbouring\n * header while dragging, leading to some odd behaviour. This allows us to suspend the show/hide behaviour while a handle is being\n * dragged, and so keep the active handle visible until the drag is finished.\n *\n * @param {Element} uiRoot Question bank UI root element.\n */\nconst setupShowHideHandles = uiRoot => {\n let shownHeader = null;\n let tableHead = uiRoot.querySelector('thead');\n uiRoot.addEventListener('mouseover', e => {\n if (suspendShowHideHandles) {\n return;\n }\n const header = e.target.closest(actions.SELECTORS.sortableColumn);\n if (!header && !shownHeader) {\n return;\n }\n if (!header || header !== shownHeader) {\n tableHead.querySelector('.show-handles')?.classList.remove('show-handles');\n shownHeader = header;\n if (header) {\n header.classList.add('show-handles');\n }\n }\n });\n};\n\n/**\n * Event handler for sortable list DROP event.\n *\n * Find all table cells corresponding to the column of the dropped header, and move them to the new position.\n *\n * @param {Event} event\n */\nconst reorderColumns = event => {\n // Current header.\n const header = event.target;\n // Find the previous sibling of the header, which will be used when moving columns.\n const insertAfter = header.previousElementSibling;\n // Move columns.\n const uiRoot = document.querySelector(SELECTORS.uiRoot);\n const columns = uiRoot.querySelectorAll(SELECTORS.tableColumn(header.dataset.columnid));\n columns.forEach(column => {\n const row = column.parentElement;\n if (insertAfter) {\n // Find the column to insert after.\n const insertAfterColumn = row.querySelector(SELECTORS.tableColumn(insertAfter.dataset.columnid));\n // Insert the column.\n insertAfterColumn.after(column);\n } else {\n // Insert as the first child (first column in the table).\n row.insertBefore(column, row.firstChild);\n }\n });\n};\n\n/**\n * Initialize module\n *\n * Add containers for the drag handles to each column header, then render handles, enable show/hide behaviour, set up drag/drop\n * column sorting, then enable the move and resize modals to be triggered from menu actions.\n */\nexport const init = async() => {\n const uiRoot = document.getElementById('questionscontainer');\n await addHandleContainers(uiRoot);\n setUpMoveHandles(uiRoot.querySelectorAll(SELECTORS.moveAction));\n setUpResizeHandles(uiRoot);\n setupShowHideHandles(uiRoot);\n const sortableColumns = actions.setupSortableLists(uiRoot.querySelector(actions.SELECTORS.columnList));\n sortableColumns.on(SortableList.EVENTS.DROP, reorderColumns);\n sortableColumns.on(SortableList.EVENTS.DRAGSTART, () => {\n suspendShowHideHandles = true;\n });\n sortableColumns.on(SortableList.EVENTS.DRAGEND, () => {\n suspendShowHideHandles = false;\n });\n setUpMoveActions(uiRoot);\n setUpResizeActions(uiRoot);\n actions.setupActionButtons(uiRoot);\n};\n"],"names":["SELECTORS","identifier","replace","currentHeader","currentX","currentMin","suspendShowHideHandles","serialiseColumnSizes","uiRoot","columnSizes","querySelectorAll","forEach","header","width","parseInt","style","isNaN","push","column","dataset","columnid","JSON","stringify","getMinWidth","contentWidth","Array","from","querySelector","children","reduce","contentElement","getBoundingClientRect","Math","ceil","showResizeModal","async","initialWidth","offsetWidth","minWidth","modal","ModalSaveCancel","create","title","name","body","Templates","render","min","show","root","getRoot","on","ModalEvents","cancel","save","repository","setColumnSize","catch","Notification","exception","bodyPromise","get","addEventListener","e","valid","target","checkValidity","closest","classList","add","newWidth","value","reorderColumns","event","insertAfter","previousElementSibling","document","row","parentElement","after","insertBefore","firstChild","getElementById","Promise","resolve","headerContainers","renderForPromise","then","_ref","html","js","container","prependNodeContents","addHandleContainers","moveAction","handleContainer","context","action","dragtype","pixicon","pixcomponent","popup","_ref2","resizeAction","headerContainer","actions","sortableColumn","_ref3","appendNodeContents","moveTracker","currentResizeHandle","pageX","getSelection","removeAllRanges","offset","setUpResizeHandles","shownHeader","tableHead","remove","setupShowHideHandles","sortableColumns","setupSortableLists","columnList","SortableList","EVENTS","DROP","DRAGSTART","DRAGEND","preventDefault","moveHandler","click","setUpMoveActions","setUpResizeActions","setupActionButtons"],"mappings":"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"}PK\ Fe"e"2bank/columnsortorder/amd/build/user_actions.min.jsnu[define("qbank_columnsortorder/user_actions",["exports","qbank_columnsortorder/actions","qbank_columnsortorder/repository","core/str","core/modal_events","core/modal_save_cancel","core/notification","core/sortable_list","core/templates"],(function(_exports,actions,repository,_str,_modal_events,_modal_save_cancel,_notification,_sortable_list,_templates){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} /** * Javascript for customising the user's view of the question bank * * @module qbank_columnsortorder/user_actions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,actions=_interopRequireWildcard(actions),repository=_interopRequireWildcard(repository),_modal_events=_interopRequireDefault(_modal_events),_modal_save_cancel=_interopRequireDefault(_modal_save_cancel),_notification=_interopRequireDefault(_notification),_sortable_list=_interopRequireDefault(_sortable_list),_templates=_interopRequireDefault(_templates);const SELECTORS_uiRoot=".questionbankwindow",SELECTORS_moveAction=".menu-action[data-action=move]",SELECTORS_resizeAction=".menu-action[data-action=resize]",SELECTORS_resizeHandle=".qbank_columnsortorder-action-handle.resize",SELECTORS_handleContainer=".handle-container",SELECTORS_headerContainer=".header-container",SELECTORS_tableColumn=identifier=>'td[data-columnid="'.concat(identifier.replace(/["\\]/g,"\\$&"),'"]');let currentHeader,currentX,currentMin,suspendShowHideHandles=!1;const serialiseColumnSizes=uiRoot=>{const columnSizes=[];return uiRoot.querySelectorAll("th").forEach((header=>{const width=parseInt(header.style.width);width&&!isNaN(width)&&columnSizes.push({column:header.dataset.columnid,width:width})})),JSON.stringify(columnSizes)},getMinWidth=header=>{const contentWidth=Array.from(header.querySelector(".header-text").children).reduce(((width,contentElement)=>width+contentElement.getBoundingClientRect().width),0);return Math.ceil(contentWidth)},showResizeModal=async(currentHeader,uiRoot)=>{const initialWidth=currentHeader.offsetWidth,minWidth=getMinWidth(currentHeader),modal=await _modal_save_cancel.default.create({title:(0,_str.get_string)("resizecolumn","qbank_columnsortorder",currentHeader.dataset.name),body:_templates.default.render("qbank_columnsortorder/resize_modal",{width:initialWidth,min:minWidth}),show:!0}),root=modal.getRoot();root.on(_modal_events.default.cancel,(()=>{currentHeader.style.width="".concat(initialWidth,"px")})),root.on(_modal_events.default.save,(()=>{repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(_notification.default.exception)}));(await modal.bodyPromise).get(0).querySelector("input").addEventListener("change",(e=>{const valid=e.target.checkValidity();if(e.target.closest(".has-validation").classList.add("was-validated"),valid){const newWidth=e.target.value;currentHeader.style.width="".concat(newWidth,"px")}}))},reorderColumns=event=>{const header=event.target,insertAfter=header.previousElementSibling;document.querySelector(SELECTORS_uiRoot).querySelectorAll(SELECTORS_tableColumn(header.dataset.columnid)).forEach((column=>{const row=column.parentElement;if(insertAfter){row.querySelector(SELECTORS_tableColumn(insertAfter.dataset.columnid)).after(column)}else row.insertBefore(column,row.firstChild)}))};_exports.init=async()=>{const uiRoot=document.getElementById("questionscontainer");await(uiRoot=>new Promise((resolve=>{const headerContainers=uiRoot.querySelectorAll(SELECTORS_headerContainer);_templates.default.renderForPromise("qbank_columnsortorder/handle_container",{}).then((_ref=>{let{html:html,js:js}=_ref;return headerContainers.forEach((container=>{_templates.default.prependNodeContents(container,html,js)})),resolve(),headerContainers})).catch(_notification.default.exception)})))(uiRoot),uiRoot.querySelectorAll(SELECTORS_moveAction).forEach((moveAction=>{const header=moveAction.closest("th");header.classList.add("qbank-sortable-column");const handleContainer=header.querySelector(SELECTORS_handleContainer),context={action:"move",dragtype:"move",target:"",title:moveAction.title,pixicon:"i/dragdrop",pixcomponent:"core",popup:!0};return _templates.default.renderForPromise("qbank_columnsortorder/action_handle",context).then((_ref2=>{let{html:html,js:js}=_ref2;return _templates.default.prependNodeContents(handleContainer,html,js),handleContainer})).catch(_notification.default.exception)})),(uiRoot=>{uiRoot.querySelectorAll(SELECTORS_resizeAction).forEach((resizeAction=>{const headerContainer=resizeAction.closest(SELECTORS_headerContainer),header=resizeAction.closest(actions.SELECTORS.sortableColumn),minWidth=getMinWidth(header);header.offsetWidth{let{html:html,js:js}=_ref3;return _templates.default.appendNodeContents(handleContainer,html,js),handleContainer})).catch(_notification.default.exception)}));let moveTracker=!1,currentResizeHandle=null;uiRoot.addEventListener("mousedown",(e=>{currentResizeHandle=e.target.closest(SELECTORS_resizeHandle),currentResizeHandle&&(currentX=e.pageX,currentHeader=e.target.closest(actions.SELECTORS.sortableColumn),currentMin=getMinWidth(currentHeader),moveTracker=!1,suspendShowHideHandles=!0)})),document.addEventListener("mousemove",(e=>{if(!currentHeader||!currentResizeHandle||0===currentX)return;document.getSelection().removeAllRanges();const offset=e.pageX-currentX;currentX=e.pageX;const newWidth=currentHeader.offsetWidth+offset;newWidth>=currentMin&&(currentHeader.style.width=newWidth+"px"),moveTracker=!0})),document.addEventListener("mouseup",(()=>{currentHeader&¤tResizeHandle&&0!==currentX&&(moveTracker?repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(_notification.default.exception):showResizeModal(currentHeader,uiRoot),currentMin=null,currentHeader=null,currentResizeHandle=null,currentX=0,moveTracker=!1,suspendShowHideHandles=!1)}))})(uiRoot),(uiRoot=>{let shownHeader=null,tableHead=uiRoot.querySelector("thead");uiRoot.addEventListener("mouseover",(e=>{if(suspendShowHideHandles)return;const header=e.target.closest(actions.SELECTORS.sortableColumn);var _tableHead$querySelec;(header||shownHeader)&&(header&&header===shownHeader||(null===(_tableHead$querySelec=tableHead.querySelector(".show-handles"))||void 0===_tableHead$querySelec||_tableHead$querySelec.classList.remove("show-handles"),shownHeader=header,header&&header.classList.add("show-handles")))}))})(uiRoot);const sortableColumns=actions.setupSortableLists(uiRoot.querySelector(actions.SELECTORS.columnList));sortableColumns.on(_sortable_list.default.EVENTS.DROP,reorderColumns),sortableColumns.on(_sortable_list.default.EVENTS.DRAGSTART,(()=>{suspendShowHideHandles=!0})),sortableColumns.on(_sortable_list.default.EVENTS.DRAGEND,(()=>{suspendShowHideHandles=!1})),(uiRoot=>{uiRoot.addEventListener("click",(e=>{const moveAction=e.target.closest(SELECTORS_moveAction);moveAction&&(e.preventDefault(),moveAction.closest(actions.SELECTORS.sortableColumn).querySelector(actions.SELECTORS.moveHandler).click())}))})(uiRoot),(uiRoot=>{uiRoot.addEventListener("click",(e=>{const resizeAction=e.target.closest(SELECTORS_resizeAction);if(resizeAction){e.preventDefault();const currentHeader=resizeAction.closest("th");showResizeModal(currentHeader,uiRoot)}}))})(uiRoot),actions.setupActionButtons(uiRoot)}})); //# sourceMappingURL=user_actions.min.js.mapPK\u7bank/columnsortorder/amd/build/admin_actions.min.js.mapnu[{"version":3,"file":"admin_actions.min.js","sources":["../src/admin_actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for handling actions on the admin page\n *\n * @module qbank_columnsortorder/admin_actions\n * @copyright 2023 onwards Catalyst IT Europe Ltd\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as actions from 'qbank_columnsortorder/actions';\nimport * as repository from 'qbank_columnsortorder/repository';\nimport Notification from \"core/notification\";\nimport Pending from 'core/pending';\n\n/**\n * Event handler to save the custom column widths when a field is edited.\n *\n * @param {Element} listRoot The root element of the list of columns.\n */\nconst setupSaveWidths = listRoot => {\n listRoot.addEventListener('change', async() => {\n const pendingPromise = new Pending('saveWidths');\n const columns = listRoot.querySelectorAll(actions.SELECTORS.sortableColumn);\n const widths = [];\n columns.forEach(column => {\n const widthInput = column.querySelector('.width-input');\n const valid = widthInput.checkValidity();\n widthInput.closest('.has-validation').classList.add('was-validated');\n if (!valid) {\n return;\n }\n widths.push({\n column: column.dataset.columnid,\n width: widthInput.value,\n });\n });\n await repository.setColumnSize(JSON.stringify(widths), true).catch(Notification.exception);\n pendingPromise.resolve();\n });\n};\n\n/**\n * Initialize module\n *\n * Set up event handlers for the action buttons, width fields and initialise column sorting.\n *\n * @param {String} id ID for the admin UI root element.\n */\nexport const init = id => {\n const uiRoot = document.getElementById(id);\n const listRoot = uiRoot.querySelector(actions.SELECTORS.columnList);\n actions.setupSortableLists(listRoot, true, true);\n actions.setupActionButtons(uiRoot, true);\n setupSaveWidths(listRoot);\n};\n"],"names":["id","uiRoot","document","getElementById","listRoot","querySelector","actions","SELECTORS","columnList","setupSortableLists","setupActionButtons","addEventListener","async","pendingPromise","Pending","columns","querySelectorAll","sortableColumn","widths","forEach","column","widthInput","valid","checkValidity","closest","classList","add","push","dataset","columnid","width","value","repository","setColumnSize","JSON","stringify","catch","Notification","exception","resolve","setupSaveWidths"],"mappings":";;;;;;;;sRA+DoBA,WACVC,OAASC,SAASC,eAAeH,IACjCI,SAAWH,OAAOI,cAAcC,QAAQC,UAAUC,YACxDF,QAAQG,mBAAmBL,UAAU,GAAM,GAC3CE,QAAQI,mBAAmBT,QAAQ,GAjCfG,CAAAA,WACpBA,SAASO,iBAAiB,UAAUC,gBAC1BC,eAAiB,IAAIC,iBAAQ,cAC7BC,QAAUX,SAASY,iBAAiBV,QAAQC,UAAUU,gBACtDC,OAAS,GACfH,QAAQI,SAAQC,eACNC,WAAaD,OAAOf,cAAc,gBAClCiB,MAAQD,WAAWE,gBACzBF,WAAWG,QAAQ,mBAAmBC,UAAUC,IAAI,iBAC/CJ,OAGLJ,OAAOS,KAAK,CACRP,OAAQA,OAAOQ,QAAQC,SACvBC,MAAOT,WAAWU,iBAGpBC,WAAWC,cAAcC,KAAKC,UAAUjB,SAAS,GAAMkB,MAAMC,sBAAaC,WAChFzB,eAAe0B,cAgBnBC,CAAgBpC"}PK\*((?bank/columnsortorder/tests/external/set_hidden_columns_test.phpnu[. namespace qbank_columnsortorder\external; use qbank_columnsortorder\column_manager; use qbank_columnsortorder\tests\external_function_testcase; // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing // This class inherits its test methods from the parent class. /** * Unit tests for qbank_columnsortorder external API. * * @package qbank_columnsortorder * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qbank_columnsortorder\external\set_hidden_columns */ final class set_hidden_columns_test extends external_function_testcase { /** * @var string Fully-qualified external function class to test. */ protected $testclass = '\qbank_columnsortorder\external\set_hidden_columns'; /** * @var string The name of the setting used to store the data. */ protected $setting = 'hiddencols'; /** * Return a random list of hidden column names. * * @return array */ protected function generate_test_data(): array { $columnsortorder = new column_manager(); $questionlistcolumns = $columnsortorder->get_columns(); $hiddencolumns = array_slice($questionlistcolumns, rand(0, count($questionlistcolumns) - 1)); return array_map(fn($column) => $column->name, $hiddencolumns); } } PK\"tWWAbank/columnsortorder/tests/external/set_columnbank_order_test.phpnu[. namespace qbank_columnsortorder\external; use qbank_columnsortorder\column_manager; use qbank_columnsortorder\tests\external_function_testcase; // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing // This class inherits its test methods from the parent class. /** * Unit tests for qbank_columnsortorder external API. * * @package qbank_columnsortorder * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qbank_columnsortorder\external\set_columnbank_order */ final class set_columnbank_order_test extends external_function_testcase { /** * @var string Fully-qualified external function class to test. */ protected $testclass = '\qbank_columnsortorder\external\set_columnbank_order'; /** * @var string The name of the setting used to store the data. */ protected $setting = 'enabledcol'; /** * Return a randomly-ordered list of columns. * * @return array */ protected function generate_test_data(): array { $columnsortorder = new column_manager(); $questionlistcolumns = $columnsortorder->get_columns(); $columnclasses = []; foreach ($questionlistcolumns as $columnnobject) { $columnclasses[] = $columnnobject->class; } shuffle($columnclasses); return $columnclasses; } } PK\Юr <bank/columnsortorder/tests/external/set_column_size_test.phpnu[. namespace qbank_columnsortorder\external; use qbank_columnsortorder\column_manager; use qbank_columnsortorder\tests\external_function_testcase; // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing // This class inherits its test methods from the parent class. /** * Unit tests for qbank_columnsortorder external API. * * @package qbank_columnsortorder * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qbank_columnsortorder\external\set_column_size */ final class set_column_size_test extends external_function_testcase { /** * @var string Fully-qualified external function class to test. */ protected $testclass = '\qbank_columnsortorder\external\set_column_size'; /** * @var string The name of the setting used to store the data. */ protected $setting = 'colsize'; /** * @var bool Whether the data is stored as a comma-separated list. */ protected $csv = false; /** * Generate a list of random column sizes. * * @return array */ protected function generate_test_data(): string { $columnsortorder = new column_manager(); $questionlistcolumns = $columnsortorder->get_columns(); $columnsizes = []; foreach ($questionlistcolumns as $columnnobject) { $columnsizes[] = (object)[ 'column' => $columnnobject->name, 'width' => rand(1, 100) . 'px', ]; } return json_encode($columnsizes); } } PK\: F F2bank/columnsortorder/tests/column_manager_test.phpnu[. namespace qbank_columnsortorder; defined('MOODLE_INTERNAL') || die(); use advanced_testcase; use context_course; use core_question\local\bank\column_base; use core_question\local\bank\question_edit_contexts; use core_question\local\bank\view; use moodle_url; global $CFG; require_once($CFG->dirroot . '/question/tests/fixtures/testable_core_question_column.php'); require_once($CFG->dirroot . '/question/classes/external.php'); /** * Test class for columnsortorder feature. * * @package qbank_columnsortorder * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qbank_columnsortorder\column_manager */ final class column_manager_test extends advanced_testcase { /** * Generate a course and return a question bank view for the course context. * * @return view */ protected static function get_question_bank(): view { $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $cm = get_coursemodule_from_id('qbank', $qbank->cmid); $questionbank = new view( new question_edit_contexts(\context_module::instance($qbank->cmid)), new moodle_url('/'), $course, $cm ); return $questionbank; } /** * Return an array of visible columns for the question bank. * * @return array */ protected static function get_columns(): array { $questionbank = self::get_question_bank(); $columns = []; foreach ($questionbank->get_visiblecolumns() as $column) { $columns[] = $column->get_column_id(); } return $columns; } /** * Provide examples for testing each column setting function, with test data and data format. * * @return array[] */ public static function settings_provider(): array { return [ 'Test set_column_order' => [ 'setting' => 'enabledcol', 'function' => 'set_column_order', 'datamethod' => [__CLASS__, 'get_columns'], 'csv' => true, ], 'Test set_hidden_columns' => [ 'setting' => 'hiddencols', 'function' => 'set_hidden_columns', 'datamethod' => [__CLASS__, 'get_columns'], 'csv' => true, ], 'Test set_column_size' => [ 'setting' => 'colsize', 'function' => 'set_column_size', 'datamethod' => 'random_string', 'csv' => false, ], ]; } /** * Retrieve data using the specified method. * This function is used to retrieve data from various data methods defined within this class. * * @param array|string $datamethod This can be either a function name or an array containing the class and method name. * @return array|string The retrieved data as an array or string, depending on the data method used. */ protected function get_data_from_datamethod(array|string $datamethod): array|string { return call_user_func($datamethod); } /** * Test setting config settings * * @dataProvider settings_provider * @param string $setting The name of the setting being saved * @param string $function The name of the function being called * @param array|string $datamethod The property of the test class to pass to the function. * @param bool $csv True of the data is stored as a comma-separated list. * @return void */ public function test_settings( string $setting, string $function, array|string $datamethod, bool $csv, ): void { $data = $this->get_data_from_datamethod($datamethod); $this->setAdminUser(); $this->resetAfterTest(true); $this->assertFalse(get_config('qbank_columnsortorder', $setting)); $this->assertEmpty(get_user_preferences('qbank_columnsortorder_' . $setting)); column_manager::{$function}($data, true); $expected = $csv ? implode(',', $data) : $data; $this->assertEquals($expected, get_config('qbank_columnsortorder', $setting)); $this->assertEmpty(get_user_preferences('qbank_columnsortorder_' . $setting)); } /** * Test passing null clears the corresponding config setting. * * @dataProvider settings_provider * @param string $setting The name of the setting being saved * @param string $function The name of the function being called * @param array|string $datamethod The property of the test class to pass to the function. * @param bool $csv True of the data is stored as a comma-separated list. * @return void */ public function test_reset_settings( string $setting, string $function, array|string $datamethod, bool $csv, ): void { $data = $this->get_data_from_datamethod($datamethod); $this->setAdminUser(); $this->resetAfterTest(true); $initial = $csv ? implode(',', $data) : $data; set_config($setting, $initial, 'qbank_columnsortorder'); $this->assertEquals($initial, get_config('qbank_columnsortorder', $setting)); column_manager::{$function}(null, true); $this->assertFalse(get_config('qbank_columnsortorder', $setting)); } /** * Test setting user preferences * * @dataProvider settings_provider * @param string $setting The name of the setting being saved * @param string $function The name of the function being called * @param array|string $datamethod The property of the test class to pass to the function. * @param bool $csv True of the data is stored as a comma-separated list. * @return void */ public function test_settings_user( string $setting, string $function, array|string $datamethod, bool $csv, ): void { $this->resetAfterTest(true); $data = $this->get_data_from_datamethod($datamethod); $this->assertFalse(get_config('qbank_columnsortorder', $setting)); $this->assertEmpty(get_user_preferences('qbank_columnsortorder_' . $setting)); column_manager::{$function}($data); $expected = $csv ? implode(',', $data) : $data; $this->assertFalse(get_config('qbank_columnsortorder', $setting)); $this->assertEquals($expected, get_user_preferences('qbank_columnsortorder_' . $setting)); } /** * Test passing null clears the corresponding user preference. * * @dataProvider settings_provider * @param string $setting The name of the setting being saved * @param string $function The name of the function being called * @param array|string $datamethod The property of the test class to pass to the function. * @param bool $csv True of the data is stored as a comma-separated list. * @return void */ public function test_reset_user_settings( string $setting, string $function, array|string $datamethod, bool $csv, ): void { $data = $this->get_data_from_datamethod($datamethod); $this->setAdminUser(); $this->resetAfterTest(true); $initial = $csv ? implode(',', $data) : $data; set_user_preference('qbank_columnsortorder_' . $setting, $initial); $this->assertEquals($initial, get_user_preferences('qbank_columnsortorder_' . $setting)); column_manager::{$function}(null); $this->assertEmpty(get_user_preferences('qbank_columnsortorder_' . $setting)); } /** * Test function get_columns in helper class, that proper data is returned. * * @covers ::get_columns */ public function test_getcolumns_function(): void { $this->resetAfterTest(true); $this->setAdminUser(); $columnmanager = new column_manager(true); $questionlistcolumns = $columnmanager->get_columns(); $this->assertIsArray($questionlistcolumns); foreach ($questionlistcolumns as $columnnobject) { $this->assertObjectHasProperty('class', $columnnobject); $this->assertObjectHasProperty('name', $columnnobject); $this->assertObjectHasProperty('colname', $columnnobject); } } /** * The get_sorted_columns method should return the provided columns sorted according to enabledcol setting. * * @return void */ public function test_get_sorted_columns(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); $neworder = $columns; shuffle($neworder); set_config('enabledcol', implode(',', $neworder), 'qbank_columnsortorder'); $columnmanager = new column_manager(true); $columnstosort = []; foreach ($columns as $column) { $columnstosort[$column] = $column; } $sortedcolumns = $columnmanager->get_sorted_columns($columnstosort); $expectedorder = ['core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column' => 0]; foreach ($neworder as $columnid) { $expectedorder[$columnid] = $columnid; } $this->assertSame($expectedorder, $sortedcolumns); } /** * Test disabled columns are removed from enabledcol setting and added to disabledcol setting. * * @return void */ public function test_disable_columns(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); // Set up enabledcol with all plugins. set_config('enabledcol', implode(',', $columns), 'qbank_columnsortorder'); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); $columnmanager = new column_manager(true); $this->assertFalse(get_config('qbank_columnsortorder', 'disabledcol')); // Disable a random plugin. $plugincolumns = array_filter($columns, fn($column) => str_starts_with($column, 'qbank_')); $randomcolumn = $plugincolumns[array_rand($plugincolumns, 1)]; $randomplugin = explode('\\', $randomcolumn)[0]; $columnmanager->disable_columns($randomplugin); // The enabledcol setting should now contain all columns except the disabled plugin. $expectedconfig = array_filter($columns, fn($column) => !str_starts_with($column, $randomplugin)); sort($expectedconfig); $newconfig = explode(',', get_config('qbank_columnsortorder', 'enabledcol')); sort($newconfig); $this->assertEquals($expectedconfig, $newconfig); $this->assertNotContains($randomcolumn, $newconfig); // The disabledcol setting should only contain columns from the disabled plugin. $disabledconfig = explode(',', get_config('qbank_columnsortorder', 'disabledcol')); array_walk($disabledconfig, fn($column) => $this->assertStringStartsWith($randomplugin, $column)); } /** * Test enabling and disabling columns through event observers * * @covers \qbank_columnsortorder\event\plugin_observer */ public function test_plugin_enabled_disabled_observers(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); $columnmanager = new column_manager(true); $neworder = $columnmanager->get_sorted_columns($columns); shuffle($neworder); $columnmanager::set_column_order($neworder, true); // Get the list of enabled columns, excluding core columns (we can't disable those). $currentconfig = get_config('qbank_columnsortorder', 'enabledcol'); $currentconfig = array_filter(explode(',', $currentconfig), fn($class) => !str_starts_with($class, 'core')); // Pick a column at random and get its plugin name. $randomcolumnid = $currentconfig[array_rand($currentconfig, 1)]; [$randomcolumnclass] = explode(column_base::ID_SEPARATOR, $randomcolumnid, 2); [$randomplugintodisable] = explode('\\', $randomcolumnclass); $olddisabledconfig = get_config('qbank_columnsortorder', 'disabledcol'); \core\event\qbank_plugin_disabled::create_for_plugin($randomplugintodisable)->trigger(); $newdisabledconfig = get_config('qbank_columnsortorder', 'disabledcol'); $this->assertNotEquals($olddisabledconfig, $newdisabledconfig); \core\event\qbank_plugin_enabled::create_for_plugin($randomplugintodisable)->trigger(); $newdisabledconfig = get_config('qbank_columnsortorder', 'disabledcol'); $this->assertEmpty($newdisabledconfig); $enabledconfig = get_config('qbank_columnsortorder', 'enabledcol'); $contains = strpos($enabledconfig, $randomplugintodisable); $this->assertNotFalse($contains); $this->assertIsInt($contains); } /** * Test enabled columns are removed from disabledcol setting and added to enabledcol setting. * * @return void */ public function test_enable_columns(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); // Set up disablecol with columns from 2 random plugins, and enabledcol with all other columns. $plugincolumns = array_filter($columns, fn($column) => str_starts_with($column, 'qbank_')); $plugins = array_unique(array_map(fn($column) => explode('\\', $column)[0], $plugincolumns)); $randomplugins = array_rand($plugins, 2); $randomplugin1 = $plugins[$randomplugins[0]]; $randomplugin2 = $plugins[$randomplugins[1]]; $disabledcols = array_filter( $columns, fn($column) => str_starts_with($column, $randomplugin1) || str_starts_with($column, $randomplugin2) ); $enabledcols = array_diff($columns, $disabledcols); set_config('enabledcol', implode(',', $enabledcols), 'qbank_columnsortorder'); set_config('disabledcol', implode(',', $disabledcols), 'qbank_columnsortorder'); // Enable one of the disabled plugins. $columnmanager = new column_manager(true); $columnmanager->enable_columns($randomplugin1); // The enabledcol setting should now contain all columns except the remaining disabled plugin. $expectedenabled = array_filter($columns, fn($column) => !str_starts_with($column, $randomplugin2)); $expecteddisabled = array_filter($disabledcols, fn($column) => str_starts_with($column, $randomplugin2)); sort($expectedenabled); sort($expecteddisabled); $newenabled = explode(',', get_config('qbank_columnsortorder', 'enabledcol')); sort($newenabled); $this->assertEquals($expectedenabled, $newenabled); $this->assertNotContains(reset($expecteddisabled), $newenabled); // The disabledcol setting should only contain columns from the remaining disabled plugin. $newdisabled = explode(',', get_config('qbank_columnsortorder', 'disabledcol')); array_walk($newdisabled, fn($column) => $this->assertStringStartsWith($randomplugin2, $column)); } /** * Test that get_disabled_columns returns names of all the columns in the disabledcol setting * * @return void */ public function test_get_disabled_columns(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questionbank = $this->get_question_bank(); $columns = $this->get_columns($questionbank); // Set up disablecol with columns from 2 random plugins, and enabledcol with all other columns. $plugincolumns = array_filter($columns, fn($column) => str_starts_with($column, 'qbank_')); $randomcolumn = $plugincolumns[array_rand($plugincolumns, 1)]; $randomplugin = explode('\\', $randomcolumn)[0]; $disabledcols = array_filter($columns, fn($column) => str_starts_with($column, $randomplugin)); set_config('disabledcol', implode(',', $disabledcols), 'qbank_columnsortorder'); $columnmanager = new column_manager(true); $expecteddisablednames = []; foreach ($disabledcols as $disabledcolid) { [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $disabledcolid, 2); $columnobject = $columnclass::from_column_name($questionbank, $columnname); $expecteddisablednames[$disabledcolid] = (object) [ 'disabledname' => $columnobject->get_title(), ]; } $disablednames = $columnmanager->get_disabled_columns(); $this->assertEquals($expecteddisablednames, $disablednames); } } PK\QtAA@bank/columnsortorder/tests/behat/behat_qbank_columnsortorder.phpnu[. // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Steps definitions related with the drag and drop header. * @package qbank_columnsortorder * @category test * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qbank_columnsortorder extends behat_base { public static function get_exact_named_selectors(): array { return [ new behat_component_named_selector('column header', [ "//th[contains(@data-name, %locator%)]", ]), new behat_component_named_selector('column move handle', [ "//*[self::th or self::tr][contains(@data-name, %locator%)]//span[contains(@data-drag-type, 'move')]", ]), new behat_component_named_selector('column resize handle', [ "//th[contains(@data-name, %locator%)]//span[contains(@data-action, 'resize')]", ]), ]; } } PK\7bb6bank/columnsortorder/tests/behat/question_bank.featurenu[@qbank @qbank_columnsortorder @javascript Feature: Set question bank column order and size In order customise my view of the question bank As a teacher I want to hide, reorder, and resize columns Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "activity" exists: | activity | quiz | | course | C1 | | name | Test quiz Q001 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "question category" exist: | contextlevel | reference | name | | Activity module | Test quiz Q001 | Question category 1 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | idnumber | | Question category 1 | essay | Test question to be seen | teacher1 | Write about whatever you want | idnumber1 | And the following config values are set as admin: | config | value | plugin | | hiddencols | qbank_usage\\question_last_used_column-question_last_used_column | qbank_columnsortorder | | enabledcol | qbank_comment\\comment_count_column-comment_count_column,qbank_viewquestionname\\question_name_idnumber_tags_column-question_name_idnumber_tags_column,core_question\\local\\bank\\edit_menu_column-edit_menu_column,qbank_editquestion\\question_status_column-question_status_column,qbank_history\\version_number_column-version_number_column,qbank_statistics\\columns\\discrimination_index:discrimination_index,qbank_statistics\\columns\\facility_index:facility_index,qbank_statistics\\columns\\discriminative_efficiency:discriminative_efficiency,qbank_usage\\question_usage_column-question_usage_column,qbank_usage\\question_last_used_column-question_last_used_column,qbank_viewcreator\\creator_name_column-creator_name_column,qbank_viewcreator\\modifier_name_column-modifier_name_column,qbank_viewquestiontype\\question_type_column-question_type_column | qbank_columnsortorder | | colsize | [{"column":"qbank_comment\\\\comment_count_column-comment_count_column","width":"20"},{"column":"qbank_viewquestionname\\\\question_name_idnumber_tags_column-question_name_idnumber_tags_column","width":"300"},{"column":"qbank_editquestion\\\\question_status_column-question_status_column","width":"20"},{"column":"qbank_history\\\\version_number_column-version_number_column","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discrimination_index:discrimination_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\facility_index:facility_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discriminative_efficiency:discriminative_efficiency","width":"20"},{"column":"qbank_usage\\\\question_usage_column-question_usage_column","width":"20"},{"column":"qbank_viewcreator\\\\creator_name_column-creator_name_column","width":"200"},{"column":"qbank_viewcreator\\\\modifier_name_column-modifier_name_column","width":"200"},{"column":"qbank_viewquestiontype\\\\question_type_column-question_type_column","width":"20"},{"column":"core_question\\\\local\\\\bank\\\\edit_menu_column-edit_menu_column","width":"50"}] | qbank_columnsortorder | Scenario: The teacher sees the question bank with global settings initially Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Question category 1" Then I should see "Test question to be seen" And "Last used" "qbank_columnsortorder > column header" should not exist And "Comments" "qbank_columnsortorder > column header" should appear before "Question" "qbank_columnsortorder > column header" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 300px;" Scenario: User preference takes precedence over global defaults Given the following "user preferences" exist: | user | preference | value | | teacher1 | qbank_columnsortorder_hiddencols | qbank_comment\\comment_count_column-comment_count_column | | teacher1 | qbank_columnsortorder_enabledcol | qbank_viewquestionname\\question_name_idnumber_tags_column-question_name_idnumber_tags_column,qbank_usage\\question_last_used_column-question_last_used_column,core_question\\local\\bank\\edit_menu_column-edit_menu_column,qbank_editquestion\\question_status_column-question_status_column,qbank_history\\version_number_column-version_number_column,qbank_statistics\\columns\\discrimination_index:discrimination_index,qbank_statistics\\columns\\facility_index:facility_index,qbank_statistics\\columns\\discriminative_efficiency:discriminative_efficiency,qbank_usage\\question_usage_column-question_usage_column,qbank_usage\\question_last_used_column-question_last_used_column,qbank_viewcreator\\creator_name_column-creator_name_column,qbank_viewcreator\\modifier_name_column-modifier_name_column,qbank_viewquestiontype\\question_type_column-question_type_column | | teacher1 | qbank_columnsortorder_colsize | [{"column":"qbank_comment\\\\comment_count_column-comment_count_column","width":"20"},{"column":"qbank_viewquestionname\\\\question_name_idnumber_tags_column-question_name_idnumber_tags_column","width":"400"},{"column":"qbank_editquestion\\\\question_status_column-question_status_column","width":"20"},{"column":"qbank_history\\\\version_number_column-version_number_column","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discrimination_index:discrimination_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\facility_index:facility_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discriminative_efficiency:discriminative_efficiency","width":"20"},{"column":"qbank_usage\\\\question_usage_column-question_usage_column","width":"20"},{"column":"qbank_viewcreator\\\\creator_name_column-creator_name_column","width":"200"},{"column":"qbank_viewcreator\\\\modifier_name_column-modifier_name_column","width":"200"},{"column":"qbank_viewquestiontype\\\\question_type_column-question_type_column","width":"20"},{"column":"core_question\\\\local\\\\bank\\\\edit_menu_column-edit_menu_column","width":"50"}] | And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Question category 1" Then "Comments" "qbank_columnsortorder > column header" should not exist And "Question" "qbank_columnsortorder > column header" should appear before "Last used" "qbank_columnsortorder > column header" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 400px;" Scenario: Resetting user view goes back to global defaults Given the following "user preferences" exist: | user | preference | value | | teacher1 | qbank_columnsortorder_hiddencols | | | teacher1 | qbank_columnsortorder_enabledcol | qbank_viewquestionname\\question_name_idnumber_tags_column-question_name_idnumber_tags_column,qbank_usage\\question_last_used_column-question_last_used_column,core_question\\local\\bank\\edit_menu_column-edit_menu_column,qbank_editquestion\\question_status_column-question_status_column,qbank_history\\version_number_column-version_number_column,qbank_statistics\\columns\\discrimination_index:discrimination_index,qbank_statistics\\columns\\facility_index:facility_index,qbank_statistics\\columns\\discriminative_efficiency:discriminative_efficiency,qbank_usage\\question_usage_column-question_usage_column,qbank_usage\\question_last_used_column-question_last_used_column,qbank_viewcreator\\creator_name_column-creator_name_column,qbank_viewcreator\\modifier_name_column-modifier_name_column,qbank_viewquestiontype\\question_type_column-question_type_column | | teacher1 | qbank_columnsortorder_colsize | [{"column":"qbank_comment\\\\comment_count_column-comment_count_column","width":"20"},{"column":"qbank_viewquestionname\\\\question_name_idnumber_tags_column-question_name_idnumber_tags_column","width":"400"},{"column":"qbank_editquestion\\\\question_status_column-question_status_column","width":"20"},{"column":"qbank_history\\\\version_number_column-version_number_column","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discrimination_index:discrimination_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\facility_index:facility_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discriminative_efficiency:discriminative_efficiency","width":"20"},{"column":"qbank_usage\\\\question_usage_column-question_usage_column","width":"20"},{"column":"qbank_viewcreator\\\\creator_name_column-creator_name_column","width":"200"},{"column":"qbank_viewcreator\\\\modifier_name_column-modifier_name_column","width":"200"},{"column":"qbank_viewquestiontype\\\\question_type_column-question_type_column","width":"20"},{"column":"core_question\\\\local\\\\bank\\\\edit_menu_column-edit_menu_column","width":"50"}] | And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And "Last used" "qbank_columnsortorder > column header" should exist And "Question" "qbank_columnsortorder > column header" should appear before "Last used" "qbank_columnsortorder > column header" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 400px;" When I follow "Reset columns" Then "Last used" "qbank_columnsortorder > column header" should not exist And "Comments" "qbank_columnsortorder > column header" should appear before "Question" "qbank_columnsortorder > column header" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 300px;" Scenario: User can remove a column in the question bank Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And "Comments" "qbank_columnsortorder > column header" should exist And I choose the "Remove" item in the "Actions menu" action menu of the "Comments" "qbank_columnsortorder > column header" Then "Comments" "qbank_columnsortorder > column header" should not exist And I reload the page And "Comments" "qbank_columnsortorder > column header" should not exist Scenario: User can add a column in the question bank Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And "Last used" "qbank_columnsortorder > column header" should not exist When I press "Add columns" And I follow "Last used" Then "Last used" "qbank_columnsortorder > column header" should exist And I reload the page And "Last used" "qbank_columnsortorder > column header" should exist Scenario: User can resize a column in the question bank using modal dialog Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 300px" When I choose the "Resize" item in the "Actions menu" action menu of the "Question" "qbank_columnsortorder > column header" And I set the field "Column width (pixels)" to "400" And I press "Save changes" Then the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 400px" And I reload the page And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 400px" Scenario: User can resize a column in the question bank by dragging Given I change the window size to "large" And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 300px" When I hover "Question" "qbank_columnsortorder > column header" And I drag "Question" "qbank_columnsortorder > column resize handle" and I drop it in "Status" "qbank_columnsortorder > column header" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should not contain "width: 300px" And I reload the page And the "style" attribute of "Question" "qbank_columnsortorder > column header" should not contain "width: 300px" Scenario: User can move a column in the question bank using modal dialog Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And "Comments" "qbank_columnsortorder > column header" should appear before "Question" "qbank_columnsortorder > column header" When I choose the "Move" item in the "Actions menu" action menu of the "Comments" "qbank_columnsortorder > column header" And I follow "After \"Question\"" Then "Comments" "qbank_columnsortorder > column header" should appear after "Question" "qbank_columnsortorder > column header" And I reload the page And "Comments" "qbank_columnsortorder > column header" should appear after "Question" "qbank_columnsortorder > column header" Scenario: User can move a column in the question bank by dragging Given I change the window size to "large" And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And "Comments" "qbank_columnsortorder > column header" should appear before "Question" "qbank_columnsortorder > column header" When I hover "Comments" "qbank_columnsortorder > column header" And I drag "Move column 'Comments'" "button" and I drop it in "Status" "qbank_columnsortorder > column header" Then "Comments" "qbank_columnsortorder > column header" should appear after "Question" "qbank_columnsortorder > column header" And I reload the page And "Comments" "qbank_columnsortorder > column header" should appear after "Question" "qbank_columnsortorder > column header" Scenario: Reordering with disabled columns When I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Disable" "link" in the "Question statistics" "table_row" And I click on "Enable" "link" in the "Question statistics" "table_row" And I click on "Disable" "link" in the "Question statistics" "table_row" And I am on the "Test quiz Q001" "mod_quiz > question bank" page Then I should see "Question bank" And "Create a new question" "button" should exist # Really, we are just checking the question bank displayed without errors. @core_customfield Scenario: Deleting a custom field which a user had removed from his preferences Given the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | configdata | | Field 1 | Category for test | text | f1 | {"visibility":"2"} | | Field 2 | Category for test | text | f2 | {"visibility":"2"} | And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And I choose the "Remove" item in the "Actions menu" action menu of the "Field 1" "qbank_columnsortorder > column header" And I choose the "Remove" item in the "Actions menu" action menu of the "Field 2" "qbank_columnsortorder > column header" And "Field 2" "qbank_columnsortorder > column header" should not exist # Delete a question custom field. And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I press "Delete custom field: Field 1" And I click on "Yes" "button" in the "Confirm" "dialogue" And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" Then I should see "Question bank" # Delete the question custom category. And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I click on "[data-role='deletecategory']" "css_element" And I click on "Yes" "button" in the "Confirm" "dialogue" And I should not see "Category for test" in the "#customfield_catlist" "css_element" And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Question category 1" And I should see "Question bank" PK\Z?G337bank/columnsortorder/tests/behat/admin_settings.featurenu[@qbank @qbank_columnsortorder @javascript Feature: Set default question bank column order and size In order to set sensible defaults for the question bank interface As an admin I want to hide, reorder, and resize columns Scenario: Admin can reorder question bank columns Given I change the window size to "large" And I log in as "admin" When I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And I drag "Move Created by" "button" and I drop it in "Move T" "button" Then "Created by" "table_row" should appear before "T" "table_row" And I reload the page And "Created by" "table_row" should appear before "T" "table_row" And I follow "Preview" And "Created by" "qbank_columnsortorder > column header" should appear before "T" "qbank_columnsortorder > column header" Scenario: Disabling a question bank plugin removes its columns Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And I should see "Created by" And I click on "Manage question bank plugins" "link" And I click on "Disable" "link" in the "View creator" "table_row" And I click on "Column sort order" "link" Then "Currently disabled question bank plugins:" "text" should appear before "Created by" "text" And I click on "Manage question bank plugins" "link" And I click on "Enable" "link" in the "View creator" "table_row" And I click on "Column sort order" "link" Then I should not see "Currently disabled question bank plugins:" And I should see "Created by" Scenario: Admin can hide a column in site administration page Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And "Created by" "table_row" should exist When I choose the "Remove" item in the "Actions menu" action menu of the "Created by" "table_row" Then "Created by" "table_row" should not exist And I reload the page And "Created by" "table_row" should not exist And I follow "Preview" And "Created by" "qbank_columnsortorder > column header" should not exist Scenario: Admin can show a column in site administration page Given the following config values are set as admin: | config | value | plugin | | hiddencols | qbank_viewcreator\creator_name_column-creator_name_column | qbank_columnsortorder | And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And "Created by" "table_row" should not exist When I press "Add columns" And I follow "Created by" Then "Created by" "table_row" should exist And I reload the page And "Created by" "table_row" should exist And I follow "Preview" And "Created by" "qbank_columnsortorder > column header" should exist Scenario: Admin can resize a column in site administration page Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And the field "Width of 'Question' in pixels" matches value "" When I set the field "Width of 'Question' in pixels" to "400" And I reload the page Then the field "Width of 'Question' in pixels" matches value "400" And I follow "Preview" And the "style" attribute of "Question" "qbank_columnsortorder > column header" should contain "width: 400px" Scenario: Custom fields can be reordered, resized, hidden and shown Given I change the window size to "large" And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I press "Add a new category" And I click on "Add a new custom field" "link" And I follow "Checkbox" And I set the following fields to these values: | Name | checkboxcustomcolumn | | Short name | chckcust | And I press "Save changes" And I should see "checkboxcustomcolumn" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And "checkboxcustomcolumn" "table_row" should appear after "Comments" "table_row" When I drag "Move checkboxcustomcolumn" "button" and I drop it in "Move Comments" "button" And I set the field "Width of 'checkboxcustomcolumn' in pixels" to "200" And I follow "Preview" And "checkboxcustomcolumn" "qbank_columnsortorder > column header" should appear before "Comments" "qbank_columnsortorder > column header" And the "style" attribute of "checkboxcustomcolumn" "qbank_columnsortorder > column header" should contain "width: 200px" And I follow "Back" And "checkboxcustomcolumn" "table_row" should appear before "Comments" "table_row" And the field "Width of 'checkboxcustomcolumn' in pixels" matches value "200" And I choose the "Remove" item in the "Actions menu" action menu of the "checkboxcustomcolumn" "table_row" And "checkboxcustomcolumn" "table_row" should not exist And I follow "Preview" And "checkboxcustomcolumn" "qbank_columnsortorder > column header" should not exist And I follow "Back" And I press "Add columns" And I follow "checkboxcustomcolumn" And "checkboxcustomcolumn" "table_row" should exist And I follow "Preview" And "checkboxcustomcolumn" "qbank_columnsortorder > column header" should exist Scenario: Resetting columns on the admin page clears global settings Given the following config values are set as admin: | config | value | plugin | | hiddencols | qbank_usage\\question_last_used_column-question_last_used_column | qbank_columnsortorder | | enabledcol | qbank_comment\\comment_count_column-comment_count_column,qbank_viewquestionname\\question_name_idnumber_tags_column-question_name_idnumber_tags_column,core_question\\local\\bank\\edit_menu_column-edit_menu_column,qbank_editquestion\\question_status_column-question_status_column,qbank_history\\version_number_column-version_number_column,qbank_statistics\\columns\\discrimination_index:discrimination_index,qbank_statistics\\columns\\facility_index:facility_index,qbank_statistics\\columns\\discriminative_efficiency:discriminative_efficiency,qbank_usage\\question_usage_column-question_usage_column,qbank_usage\\question_last_used_column-question_last_used_column,qbank_viewcreator\\creator_name_column-creator_name_column,qbank_viewcreator\\modifier_name_column-modifier_name_column,qbank_viewquestiontype\\question_type_column-question_type_column | qbank_columnsortorder | | colsize | [{"column":"qbank_comment\\\\comment_count_column-comment_count_column","width":"20"},{"column":"qbank_viewquestionname\\\\question_name_idnumber_tags_column-question_name_idnumber_tags_column","width":"300"},{"column":"qbank_editquestion\\\\question_status_column-question_status_column","width":"20"},{"column":"qbank_history\\\\version_number_column-version_number_column","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discrimination_index:discrimination_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\facility_index:facility_index","width":"20"},{"column":"qbank_statistics\\\\columns\\\\discriminative_efficiency:discriminative_efficiency","width":"20"},{"column":"qbank_usage\\\\question_usage_column-question_usage_column","width":"20"},{"column":"qbank_viewcreator\\\\creator_name_column-creator_name_column","width":"200"},{"column":"qbank_viewcreator\\\\modifier_name_column-modifier_name_column","width":"200"},{"column":"qbank_viewquestiontype\\\\question_type_column-question_type_column","width":"20"},{"column":"core_question\\\\local\\\\bank\\\\edit_menu_column-edit_menu_column","width":"50"}] | qbank_columnsortorder | And I change the window size to "large" And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And "Last used" "table_row" should not exist And "Comments" "table_row" should appear before "Question" "table_row" And the field "Width of 'Question' in pixels" matches value "300" When I follow "Reset columns" Then "Last used" "table_row" should exist And "Question" "table_row" should appear before "Comments" "table_row" And the field "Width of 'Question' in pixels" matches value "" @core_customfield Scenario: Deleting a custom field which is removed from the Column sort order Given the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | configdata | | Field 1 | Category for test | text | f1 | {"visibility":"2"} | | Field 2 | Category for test | text | f2 | {"visibility":"2"} | And I change the window size to "large" When I log in as "admin" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And "Field 1" "table_row" should exist And "Field 2" "table_row" should exist And I choose the "Remove" item in the "Actions menu" action menu of the "Field 1" "table_row" # Delete a question custom field. And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I press "Delete custom field: Field 1" And I click on "Yes" "button" in the "Confirm" "dialogue" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration Then I should see "Column sort order" And "Field 2" "table_row" should exist And I choose the "Remove" item in the "Actions menu" action menu of the "Field 2" "table_row" # Delete the question custom category. And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I click on "[data-role='deletecategory']" "css_element" And I click on "Yes" "button" in the "Confirm" "dialogue" And I should not see "Category for test" in the "#customfield_catlist" "css_element" And I navigate to "Plugins > Question bank plugins > Column sort order" in site administration And I should see "Column sort order" @accessibility Scenario: The plugin administration page meets accessibility standards Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Column sort order" in site administration Then the "region-main" "region" should meet accessibility standards with "best-practice" extra tests PK\cܬ!bank/columnsortorder/settings.phpnu[. /** * Adds admin settings for the plugin. * * @package qbank_columnsortorder * @category admin * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); // Column sort order link in manageqbanks page. $url = new moodle_url('/question/bank/columnsortorder/sortcolumns.php', ['section' => 'columnsortorder']); if ($ADMIN->fulltree) { $page = $adminroot->locate('manageqbanks'); if (isset($page)) { $page->add(new admin_setting_description( 'manageqbanksgotocolumnsort', '', new lang_string('qbankgotocolumnsort', 'qbank_columnsortorder', html_writer::link($url, get_string('qbankcolumnsortorder', 'qbank_columnsortorder'))) )); } } // Column sort order link in admin page. $settings = new admin_externalpage('qbank_columnsortorder', get_string('qbankcolumnsortorder', 'qbank_columnsortorder'), $url); PK\f bank/tagquestion/db/services.phpnu[. /** * External services definition for qbank_tagquestion. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'qbank_tagquestion_submit_tags_form' => [ 'classname' => 'qbank_tagquestion\external\submit_tags', 'description' => 'Update the question tags.', 'type' => 'write', 'ajax' => true, ], ]; PK\tbank/tagquestion/db/upgrade.phpnu[. /** * Install function for qbank_tagquestion * * @package qbank_tagquestion * @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 */ /** * Upgrade steps for qbank_tagquestion * * @param int $oldversion The version we are upgrading from */ function xmldb_qbank_tagquestion_upgrade(int $oldversion): bool { global $DB; if ($oldversion < 2025041401) { // Delete orphaned question tags. $orphanedtags = $DB->get_records_sql(" SELECT ti.id FROM {tag_instance} ti LEFT JOIN {question} q ON q.id = ti.itemid WHERE ti.component = 'core_question' AND ti.itemtype = 'question' AND q.id IS NULL "); if ($orphanedtags) { $DB->delete_records_list('tag_instance', 'id', array_keys($orphanedtags)); } upgrade_plugin_savepoint(true, 2025041401, 'qbank', 'tagquestion'); } return true; } PK\Pbank/tagquestion/db/events.phpnu[. /** * Question tag events * * @package qbank_tagquestion * @copyright 2023 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 */ defined('MOODLE_INTERNAL') || die(); $observers = [ [ 'eventname' => '\core\event\question_deleted', 'callback' => '\qbank_tagquestion\event\question_deleted_observer::delete_question_tags' ] ]; PK\`__.bank/tagquestion/lang/en/qbank_tagquestion.phpnu[. /** * Strings for component qbank_tagquestion, language 'en'. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Tag question'; $string['privacy:metadata'] = 'The Tag question question bank plugin does not store any personal data.'; PK\|FFbank/tagquestion/lib.phpnu[. /** * Question related functions. * * This file was created just because Fragment API expects callbacks to be defined on lib.php. * * @package qbank_tagquestion * @copyright 2018 Simey Lameze * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); use qbank_tagquestion\form\tags_form; /** * Question tags fragment callback. * * @param array $args Arguments to the form. * @return null|string The rendered form. */ function qbank_tagquestion_output_fragment_tags_form($args) { if (!empty($args['id'])) { global $CFG, $DB; require_once($CFG->libdir . '/questionlib.php'); $id = clean_param($args['id'], PARAM_INT); $editingcontext = $args['context']; // Load the question and some related information. $question = $DB->get_record('question', ['id' => $id]); $sql = "SELECT qc.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = :id"; $category = $DB->get_record_sql($sql, ['id' => $question->id]); $questioncontext = \context::instance_by_id($category->contextid); $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); // Load the question tags and filter the course tags by the current course. if (core_tag_tag::is_enabled('core_question', 'question')) { $tagobjectsbyquestion = core_tag_tag::get_items_tags('core_question', 'question', [$question->id]); if (!empty($tagobjectsbyquestion[$question->id])) { $tagobjects = $tagobjectsbyquestion[$question->id]; $sortedtagobjects = question_sort_tags($tagobjects, context::instance_by_id($category->contextid)); } } $formoptions = [ 'editingcontext' => $editingcontext, 'questioncontext' => $questioncontext, 'contexts' => $contexts->all() ]; $data = [ 'id' => $question->id, 'questioncategory' => $category->name, 'questionname' => $question->name, 'categoryid' => $category->id, 'contextid' => $category->contextid, 'context' => $questioncontext->get_context_name(), 'tags' => $sortedtagobjects->tags ?? [], 'coursetags' => $sortedtagobjects->coursetags ?? [], ]; $cantag = question_has_capability_on($question, 'tag'); $mform = new tags_form(null, $formoptions, 'post', '', null, $cantag, $data); $mform->set_data($data); return $mform->render(); } } PK\u yybank/tagquestion/version.phpnu[. /** * Version information for qbank_tagquestion. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_tagquestion'; $plugin->version = 2025041401; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\ +bank/tagquestion/classes/form/tags_form.phpnu[. namespace qbank_tagquestion\form; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/formslib.php'); require_once($CFG->dirroot . '/lib/questionlib.php'); /** * The mform class for manage question tags. * * @package qbank_tagquestion * @copyright 2018 Simey Lameze * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class tags_form extends \moodleform { public function definition() { $mform = $this->_form; $customdata = $this->_customdata; $mform->disable_form_change_checker(); $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'categoryid'); $mform->setType('categoryid', PARAM_INT); $mform->addElement('hidden', 'contextid'); $mform->setType('contextid', PARAM_INT); $mform->addElement('static', 'questionname', get_string('questionname', 'question')); $mform->addElement('static', 'questioncategory', get_string('categorycurrent', 'question')); $mform->addElement('static', 'context', ''); if (\core_tag_tag::is_enabled('core_question', 'question')) { $tags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $customdata['contexts']); $tagstrings = []; foreach ($tags as $tag) { $tagstrings[$tag->name] = $tag->name; } $options = [ 'tags' => true, 'multiple' => true, 'noselectionstring' => get_string('anytags', 'quiz'), ]; $mform->addElement('autocomplete', 'tags', get_string('tags'), $tagstrings, $options); } } } PK\^1bank/tagquestion/classes/external/submit_tags.phpnu[. namespace qbank_tagquestion\external; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); require_once($CFG->dirroot . '/question/engine/datalib.php'); require_once($CFG->libdir . '/questionlib.php'); use core_tag_tag; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; use qbank_tagquestion\form\tags_form; /** * External qbank_tagquestion API. * * @package qbank_tagquestion * @copyright 2016 Pau Ferrer * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class submit_tags extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters. */ public static function execute_parameters() { return new external_function_parameters([ 'questionid' => new external_value(PARAM_INT, 'The question id'), 'contextid' => new external_value(PARAM_INT, 'The editing context id'), 'formdata' => new external_value(PARAM_RAW, 'The data from the tag form'), ]); } /** * Handles the tags form submission. * * @param int $questionid The question id. * @param int $contextid The editing context id. * @param string $formdata The question tag form data in a URI encoded param string * @return array The created or modified question tag */ public static function execute($questionid, $contextid, $formdata) { global $DB, $CFG; $data = []; $result = ['status' => false]; // Parameter validation. $params = self::validate_parameters(self::execute_parameters(), [ 'questionid' => $questionid, 'contextid' => $contextid, 'formdata' => $formdata ]); $editingcontext = \context::instance_by_id($params['contextid']); self::validate_context($editingcontext); parse_str($params['formdata'], $data); if (!$question = $DB->get_record_sql(' SELECT q.*, qc.contextid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = ?', [$questionid])) { throw new \moodle_exception('questiondoesnotexist', 'question'); } $cantag = question_has_capability_on($question, 'tag'); $questioncontext = \context::instance_by_id($question->contextid); $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); $formoptions = [ 'editingcontext' => $editingcontext, 'questioncontext' => $questioncontext, 'contexts' => $contexts->all() ]; $mform = new tags_form(null, $formoptions, 'post', '', null, $cantag, $data); if ($validateddata = $mform->get_data()) { if ($cantag && isset($validateddata->tags)) { // Due to a mform bug, if there's no tags set on the tag element, it submits the name as the value. // The only way to discover is checking if the tag element is an array. $tags = is_array($validateddata->tags) ? $validateddata->tags : []; core_tag_tag::set_item_tags('core_question', 'question', $validateddata->id, $questioncontext, $tags); $result['status'] = true; } } return $result; } /** * Returns description of method result value. */ public static function execute_returns() { return new external_single_structure([ 'status' => new external_value(PARAM_BOOL, 'status: true if success') ]); } } PK\qHj-bank/tagquestion/classes/privacy/provider.phpnu[. namespace qbank_tagquestion\privacy; /** * Privacy Subsystem for qbank_tagquestion implementing null_provider. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\**bank/tagquestion/classes/tag_condition.phpnu[. namespace qbank_tagquestion; use core\output\datafilter; use core_question\local\bank\condition; /** * Question bank search class to allow searching/filtering by tags on a question. * * @package qbank_tagquestion * @copyright 2018 Ryan Wyllie * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class tag_condition extends condition { /** @var int The default filter type */ const JOINTYPE_DEFAULT = datafilter::JOINTYPE_ALL; /** @var array Contexts to be used. */ protected $contexts = []; /** @var array List of IDs for tags that have been selected in the form. */ protected $selectedtagids = []; /** * Tag condition constructor. It uses the qbank object and initialises all the its required information * to be passed as a part of condition to get the questions. * * @param null $qbank qbank view */ public function __construct($qbank = null) { if (is_null($qbank)) { return; } parent::__construct($qbank); $this->contexts = $qbank->contexts->all(); $this->selectedtagids = $this->filter->values ?? []; } public static function get_condition_key() { return 'qtagids'; } /** * Print HTML to display the list of tags to filter by. */ public function display_options() { global $PAGE; $tags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $this->contexts); $tagoptions = array_map(function($tag) { return [ 'id' => $tag->id, 'name' => $tag->name, 'selected' => in_array($tag->id, $this->selectedtagids) ]; }, array_values($tags)); $context = [ 'tagoptions' => $tagoptions ]; return $PAGE->get_renderer('qbank_tagquestion')->render_tag_condition($context); } /** * Build query from filter value * * @param array $filter filter properties * @return array where sql and params */ public static function build_query_from_filter(array $filter): array { global $DB; // Remove empty string. $filter['values'] = array_filter($filter['values']); $selectedtagids = $filter['values'] ?? []; $params = []; $where = ''; $jointype = $filter['jointype'] ?? self::JOINTYPE_DEFAULT; // If some tags have been selected then we need to filter // the question list by the selected tags. if ($selectedtagids) { // We treat each additional tag as an AND condition rather than // an OR condition. // // For example, if the user filters by the tags "foo" and "bar" then // we reduce the question list to questions that are tagged with both // "foo" AND "bar". Any question that does not have ALL of the specified // tags will be omitted. $tagparams['tagcount'] = count($selectedtagids); $tagparams['questionitemtype'] = 'question'; $tagparams['questioncomponent'] = 'core_question'; [$tagsql, $tagparamsinner] = $DB->get_in_or_equal($selectedtagids, SQL_PARAMS_NAMED, 'param', true); $tagparams = array_merge($tagparams, $tagparamsinner); // Determine the operator and additional clauses based on join type. $groupclause = ''; $operator = ($jointype === datafilter::JOINTYPE_NONE) ? 'NOT ' : ''; if ($operator === '') { $groupclause = 'GROUP BY ti.itemid '; $groupclause .= ($jointype === datafilter::JOINTYPE_ALL) ? 'HAVING COUNT(itemid) = :tagcount ' : ''; } $where = "q.id {$operator}IN ( SELECT ti.itemid FROM {tag_instance} ti WHERE ti.itemtype = :questionitemtype AND ti.component = :questioncomponent AND ti.tagid {$tagsql} {$groupclause})"; $params = $tagparams; } return [$where, $params]; } public function get_title() { return get_string('tag', 'tag'); } public function get_filter_class() { return null; } public function allow_custom() { return false; } public function get_initial_values() { $tags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $this->contexts); $values = []; foreach ($tags as $tag) { $values[] = [ 'value' => $tag->id, 'title' => html_entity_decode($tag->name), 'selected' => in_array($tag->id, $this->selectedtagids) ]; } return $values; } } PK\1R~;;,bank/tagquestion/classes/output/renderer.phpnu[. namespace qbank_tagquestion\output; use plugin_renderer_base; /** * Class renderer for qbank_tagquestion. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends plugin_renderer_base { /** * Render tag condition. * * @param array $context * @return bool|string */ public function render_tag_condition($context) { return $this->render_from_template('qbank_tagquestion/tag_condition', $context); } } PK\_F<bank/tagquestion/classes/event/question_deleted_observer.phpnu[. namespace qbank_tagquestion\event; use core\context; use core\event\question_deleted; /** * Event observer for question deletion * * @package qbank_tagquestion * @copyright 2023 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 question_deleted_observer { /** * Delete any tags defined for the deleted question. * * This uses {@see \core_tag_tag::set_item_tags} rather than {@see \core_tag_tag::remove_all_item_tags} since the latter * will always pass the system context, not the question context that the tag was set in. * * @param question_deleted $event * @return void */ public static function delete_question_tags(question_deleted $event): void { $questioncontext = context::instance_by_id($event->contextid); \core_tag_tag::set_item_tags('core_question', 'question', $event->objectid, $questioncontext, null); } } PK\&hK (bank/tagquestion/classes/tags_action.phpnu[. namespace qbank_tagquestion; use core_question\local\bank\question_action_base; /** * Action to add and remove tags to questions. * * @package qbank_tagquestion * @copyright 2018 Simey Lameze * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class tags_action extends question_action_base { /** * @var string store this lang string for performance. */ protected $managetags; /** * @var bool tags enabled or not from config. */ protected $tagsenabled = true; public function init(): void { parent::init(); $this->check_tags_status(); if ($this->tagsenabled) { global $PAGE; $PAGE->requires->js_call_amd('qbank_tagquestion/edit_tags', 'init', ['#questionscontainer']); } $this->managetags = get_string('managetags', 'tag'); } public function get_menu_position(): int { return 300; } protected function check_tags_status(): void { global $CFG; if (!$CFG->usetags) { $this->tagsenabled = false; } } protected function display_content($question, $rowclasses): void { global $OUTPUT; if (\core_tag_tag::is_enabled('core_question', 'question') && question_has_capability_on($question, 'view') && $this->tagsenabled) { [$url, $attributes] = $this->get_link_url_and_attributes($question); echo \html_writer::link($url, $OUTPUT->pix_icon('t/tags', $this->managetags), $attributes); } } protected function get_link_url_and_attributes($question): array { $url = new \moodle_url($this->qbank->returnurl); $attributes = [ 'data-action' => 'edittags', 'data-cantag' => question_has_capability_on($question, 'tag'), 'data-contextid' => $this->qbank->get_most_specific_context()->id, 'data-questionid' => $question->id ]; return [$url, $attributes]; } public function get_action_menu_link(\stdClass $question): ?\action_menu_link { if (!\core_tag_tag::is_enabled('core_question', 'question') || !question_has_capability_on($question, 'view') || !$this->tagsenabled) { return null; } [$url, $attributes] = $this->get_link_url_and_attributes($question); return new \action_menu_link_secondary($url, new \pix_icon('t/tags', ''), $this->managetags, $attributes); } } PK\Z+bank/tagquestion/classes/plugin_feature.phpnu[. namespace qbank_tagquestion; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class columns is the entrypoint for the columns. * * @package qbank_tagquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base{ public function get_question_actions($qbank): array { return [ new tags_action($qbank), ]; } public function get_question_filters(?view $qbank = null): array { global $CFG; if ($CFG->usetags) { return [ new tag_condition($qbank), ]; } return []; } } PK\!~ ~ 1bank/tagquestion/templates/tag_condition.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_tagquestion/tag_condition An auto-complete select box containing a list of available tags to filter the quesiton bank questions by. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * tagoptions A list of available tags Example context (json): { "tagoptions": [ { "id": 1, "name": "foo", "selected": true }, { "id": 2, "name": "bar", "selected": false } ] } }}
{{< core/overlay_loading }} {{$hiddenclass}}{{/hiddenclass}} {{/ core/overlay_loading }}
{{#js}} require( [ 'jquery', 'core/form-autocomplete' ], function( $, AutoComplete ) { var root = $('[data-region="tag-condition-container-{{uniqid}}"]'); var selectElement = root.find('[data-region="tag-select"]'); var loadingContainer = root.find('[data-region="overlay-icon-container"]'); var placeholderText = {{#quote}}{{#str}} filterbytags, core_question {{/str}}{{/quote}}; var noSelectionText = {{#quote}}{{#str}} notagfiltersapplied, core_question {{/str}}{{/quote}}; AutoComplete.enhance( selectElement, // Element to enhance. false, // Don't allow support for creating new tags. false, // Don't allow AMD module to handle loading new tags. placeholderText, // Placeholder text. false, // Make search case insensitive. true, // Show suggestions for tags. noSelectionText // Text when no tags are selected. ).always(function() { // Hide the loading icon once the autocomplete has initialised. loadingContainer.addClass('hidden'); }); // We need to trigger a form submission because of how the question bank // page handles reloading the questions when an option changes. selectElement.on('change', function() { selectElement.closest('form').submit(); }); }); {{/js}} PK\v##%bank/tagquestion/amd/src/edit_tags.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * A javascript module to handle question tags editing. * * @module qbank_tagquestion/edit_tags * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([ 'jquery', 'core/fragment', 'core/str', 'core/modal_events', 'core/modal_save_cancel', 'core/notification', 'core/custom_interaction_events', 'qbank_tagquestion/repository', 'qbank_tagquestion/selectors', ], function( $, Fragment, Str, ModalEvents, ModalSaveCancel, Notification, CustomEvents, Repository, QuestionSelectors ) { /** * Enable the save button in the footer. * * @param {object} root The container element. * @method enableSaveButton */ var enableSaveButton = function(root) { root.find(QuestionSelectors.actions.save).prop('disabled', false); }; /** * Disable the save button in the footer. * * @param {object} root The container element. * @method disableSaveButton */ var disableSaveButton = function(root) { root.find(QuestionSelectors.actions.save).prop('disabled', true); }; /** * Get the serialised form data. * * @method getFormData * @param {object} modal The modal object. * @return {string} serialised form data */ var getFormData = function(modal) { return modal.getBody().find('form').serialize(); }; /** * Set the element state to loading. * * @param {object} root The container element * @method startLoading */ var startLoading = function(root) { var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon); loadingIconContainer.removeClass('hidden'); }; /** * Remove the loading state from the element. * * @param {object} root The container element * @method stopLoading */ var stopLoading = function(root) { var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon); loadingIconContainer.addClass('hidden'); }; /** * Set the context Id data attribute on the modal. * * @param {Promise} modal The modal promise. * @param {int} contextId The context id. */ var setContextId = function(modal, contextId) { modal.getBody().attr('data-contextid', contextId); }; /** * Get the context Id data attribute value from the modal body. * * @param {Promise} modal The modal promise. * @return {int} The context id. */ var getContextId = function(modal) { return modal.getBody().data('contextid'); }; /** * Set the question Id data attribute on the modal. * * @param {Promise} modal The modal promise. * @param {int} questionId The question Id. */ var setQuestionId = function(modal, questionId) { modal.getBody().attr('data-questionid', questionId); }; /** * Get the question Id data attribute value from the modal body. * * @param {Promise} modal The modal promise. * @return {int} The question Id. */ var getQuestionId = function(modal) { return modal.getBody().data('questionid'); }; /** * Register event listeners for the module. * * @param {object} root The calendar root element */ var registerEventListeners = function(root) { var modalPromise = ModalSaveCancel.create({ large: false, }).then(function(modal) { // All of this code only executes once, when the modal is // first created. This allows us to add any code that should // only be run once, such as adding event handlers to the modal. Str.get_string('questiontags', 'question') .then(function(string) { modal.setTitle(string); return string; }) .catch(Notification.exception); modal.getRoot().on(ModalEvents.save, function(e) { var form = modal.getBody().find('form'); form.submit(); e.preventDefault(); }); modal.getRoot().on('submit', 'form', function(e) { save(modal, root).then(function() { modal.hide(); location.reload(); return; }).catch(Notification.exception); // Stop the form from actually submitting and prevent it's // propagation because we have already handled the event. e.preventDefault(); e.stopPropagation(); }); return modal; }); root.on('click', QuestionSelectors.actions.edittags, function(e) { e.preventDefault(); // eslint-disable-next-line promise/catch-or-return modalPromise.then((modal) => modal.show()); }); // We need to add an event handler to the tags link because there are // multiple links on the page and without adding a listener we don't know // which one the user clicked on the show the modal. root.on(CustomEvents.events.activate, QuestionSelectors.actions.edittags, function(e) { var currentTarget = $(e.currentTarget); var questionId = currentTarget.data('questionid'), canTag = !!currentTarget.data('cantag'), contextId = currentTarget.data('contextid'); // This code gets called each time the user clicks the tag link // so we can use it to reload the contents of the tag modal. modalPromise.then(function(modal) { // Display spinner and disable save button. disableSaveButton(root); startLoading(root); var args = { id: questionId }; var tagsFragment = Fragment.loadFragment('qbank_tagquestion', 'tags_form', contextId, args); modal.setBody(tagsFragment); tagsFragment.then(function() { enableSaveButton(root); return; }) .always(function() { // Always hide the loading spinner when the request // has completed. stopLoading(root); return; }) .catch(Notification.exception); // Show or hide the save button depending on whether the user // has the capability to edit the tags. if (canTag) { modal.getRoot().find(QuestionSelectors.actions.save).show(); } else { modal.getRoot().find(QuestionSelectors.actions.save).hide(); } setQuestionId(modal, questionId); setContextId(modal, contextId); return modal; }).catch(Notification.exception); e.preventDefault(); }); }; /** * Send the form data to the server to save question tags. * * @method save * @param {object} modal The modal object. * @param {object} root The container element. * @return {object} A promise */ var save = function(modal, root) { // Display spinner and disable save button. disableSaveButton(root); startLoading(root); var formData = getFormData(modal); var questionId = getQuestionId(modal); var contextId = getContextId(modal); // Send the form data to the server for processing. return Repository.submitTagCreateUpdateForm(questionId, contextId, formData) .always(function() { // Regardless of success or error we should always stop // the loading icon and re-enable the buttons. stopLoading(root); enableSaveButton(root); return; }) .catch(Notification.exception); }; return { init: function(root) { root = $(root); registerEventListeners(root); } }; }); PK\.vv&bank/tagquestion/amd/src/repository.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * A javascript module to handle question ajax actions. * * @module qbank_tagquestion/repository * @class repository * @copyright 2017 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/ajax'], function($, Ajax) { /** * Submit the form data for the question tags form. * * @method submitTagCreateUpdateForm * @param {Number} questionId * @param {Number} contextId * @param {string} formdata The URL encoded values from the form * @return {promise} */ var submitTagCreateUpdateForm = function(questionId, contextId, formdata) { var request = { methodname: 'qbank_tagquestion_submit_tags_form', args: { questionid: questionId, contextid: contextId, formdata: formdata } }; return Ajax.call([request])[0]; }; return { submitTagCreateUpdateForm: submitTagCreateUpdateForm }; }); PK\!8_%bank/tagquestion/amd/src/selectors.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The purpose of this module is to centralize selectors related to question. * * @module qbank_tagquestion/selectors * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([], function() { return { actions: { save: '[data-action="save"]', edittags: '[data-action="edittags"]', }, containers: { loadingIcon: '[data-region="overlay-icon-container"]', }, }; }); PK\Pcoo,bank/tagquestion/amd/build/repository.min.jsnu[/** * A javascript module to handle question ajax actions. * * @module qbank_tagquestion/repository * @class repository * @copyright 2017 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("qbank_tagquestion/repository",["jquery","core/ajax"],(function($,Ajax){return{submitTagCreateUpdateForm:function(questionId,contextId,formdata){var request={methodname:"qbank_tagquestion_submit_tags_form",args:{questionid:questionId,contextid:contextId,formdata:formdata}};return Ajax.call([request])[0]}}})); //# sourceMappingURL=repository.min.js.mapPK\w0bank/tagquestion/amd/build/repository.min.js.mapnu[{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle question ajax actions.\n *\n * @module qbank_tagquestion/repository\n * @class repository\n * @copyright 2017 Simey Lameze \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/ajax'], function($, Ajax) {\n\n /**\n * Submit the form data for the question tags form.\n *\n * @method submitTagCreateUpdateForm\n * @param {Number} questionId\n * @param {Number} contextId\n * @param {string} formdata The URL encoded values from the form\n * @return {promise}\n */\n var submitTagCreateUpdateForm = function(questionId, contextId, formdata) {\n var request = {\n methodname: 'qbank_tagquestion_submit_tags_form',\n args: {\n questionid: questionId,\n contextid: contextId,\n formdata: formdata\n }\n };\n\n return Ajax.call([request])[0];\n };\n\n return {\n submitTagCreateUpdateForm: submitTagCreateUpdateForm\n };\n});\n"],"names":["define","$","Ajax","submitTagCreateUpdateForm","questionId","contextId","formdata","request","methodname","args","questionid","contextid","call"],"mappings":";;;;;;;;AAuBAA,sCAAO,CAAC,SAAU,cAAc,SAASC,EAAGC,YAwBjC,CACHC,0BAd4B,SAASC,WAAYC,UAAWC,cACxDC,QAAU,CACVC,WAAY,qCACZC,KAAM,CACFC,WAAYN,WACZO,UAAWN,UACXC,SAAUA,kBAIXJ,KAAKU,KAAK,CAACL,UAAU"}PK\؝D/bank/tagquestion/amd/build/selectors.min.js.mapnu[{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The purpose of this module is to centralize selectors related to question.\n *\n * @module qbank_tagquestion/selectors\n * @copyright 2018 Simey Lameze \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n return {\n actions: {\n save: '[data-action=\"save\"]',\n edittags: '[data-action=\"edittags\"]',\n },\n containers: {\n loadingIcon: '[data-region=\"overlay-icon-container\"]',\n },\n };\n});\n"],"names":["define","actions","save","edittags","containers","loadingIcon"],"mappings":";;;;;;;AAsBAA,qCAAO,IAAI,iBACA,CACHC,QAAS,CACLC,KAAM,uBACNC,SAAU,4BAEdC,WAAY,CACRC,YAAa"}PK\]RY0Y0/bank/tagquestion/amd/build/edit_tags.min.js.mapnu[{"version":3,"file":"edit_tags.min.js","sources":["../src/edit_tags.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle question tags editing.\n *\n * @module qbank_tagquestion/edit_tags\n * @copyright 2018 Simey Lameze \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'jquery',\n 'core/fragment',\n 'core/str',\n 'core/modal_events',\n 'core/modal_save_cancel',\n 'core/notification',\n 'core/custom_interaction_events',\n 'qbank_tagquestion/repository',\n 'qbank_tagquestion/selectors',\n ],\n function(\n $,\n Fragment,\n Str,\n ModalEvents,\n ModalSaveCancel,\n Notification,\n CustomEvents,\n Repository,\n QuestionSelectors\n ) {\n\n /**\n * Enable the save button in the footer.\n *\n * @param {object} root The container element.\n * @method enableSaveButton\n */\n var enableSaveButton = function(root) {\n root.find(QuestionSelectors.actions.save).prop('disabled', false);\n };\n\n /**\n * Disable the save button in the footer.\n *\n * @param {object} root The container element.\n * @method disableSaveButton\n */\n var disableSaveButton = function(root) {\n root.find(QuestionSelectors.actions.save).prop('disabled', true);\n };\n\n /**\n * Get the serialised form data.\n *\n * @method getFormData\n * @param {object} modal The modal object.\n * @return {string} serialised form data\n */\n var getFormData = function(modal) {\n return modal.getBody().find('form').serialize();\n };\n\n /**\n * Set the element state to loading.\n *\n * @param {object} root The container element\n * @method startLoading\n */\n var startLoading = function(root) {\n var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon);\n\n loadingIconContainer.removeClass('hidden');\n };\n\n /**\n * Remove the loading state from the element.\n *\n * @param {object} root The container element\n * @method stopLoading\n */\n var stopLoading = function(root) {\n var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon);\n\n loadingIconContainer.addClass('hidden');\n };\n\n /**\n * Set the context Id data attribute on the modal.\n *\n * @param {Promise} modal The modal promise.\n * @param {int} contextId The context id.\n */\n var setContextId = function(modal, contextId) {\n modal.getBody().attr('data-contextid', contextId);\n };\n\n /**\n * Get the context Id data attribute value from the modal body.\n *\n * @param {Promise} modal The modal promise.\n * @return {int} The context id.\n */\n var getContextId = function(modal) {\n return modal.getBody().data('contextid');\n };\n\n /**\n * Set the question Id data attribute on the modal.\n *\n * @param {Promise} modal The modal promise.\n * @param {int} questionId The question Id.\n */\n var setQuestionId = function(modal, questionId) {\n modal.getBody().attr('data-questionid', questionId);\n };\n\n /**\n * Get the question Id data attribute value from the modal body.\n *\n * @param {Promise} modal The modal promise.\n * @return {int} The question Id.\n */\n var getQuestionId = function(modal) {\n return modal.getBody().data('questionid');\n };\n\n /**\n * Register event listeners for the module.\n *\n * @param {object} root The calendar root element\n */\n var registerEventListeners = function(root) {\n var modalPromise = ModalSaveCancel.create({\n large: false,\n }).then(function(modal) {\n // All of this code only executes once, when the modal is\n // first created. This allows us to add any code that should\n // only be run once, such as adding event handlers to the modal.\n Str.get_string('questiontags', 'question')\n .then(function(string) {\n modal.setTitle(string);\n return string;\n })\n .catch(Notification.exception);\n\n modal.getRoot().on(ModalEvents.save, function(e) {\n var form = modal.getBody().find('form');\n form.submit();\n e.preventDefault();\n });\n\n modal.getRoot().on('submit', 'form', function(e) {\n save(modal, root).then(function() {\n modal.hide();\n location.reload();\n return;\n }).catch(Notification.exception);\n\n // Stop the form from actually submitting and prevent it's\n // propagation because we have already handled the event.\n e.preventDefault();\n e.stopPropagation();\n });\n\n return modal;\n });\n\n root.on('click', QuestionSelectors.actions.edittags, function(e) {\n e.preventDefault();\n // eslint-disable-next-line promise/catch-or-return\n modalPromise.then((modal) => modal.show());\n });\n\n // We need to add an event handler to the tags link because there are\n // multiple links on the page and without adding a listener we don't know\n // which one the user clicked on the show the modal.\n root.on(CustomEvents.events.activate, QuestionSelectors.actions.edittags, function(e) {\n var currentTarget = $(e.currentTarget);\n\n var questionId = currentTarget.data('questionid'),\n canTag = !!currentTarget.data('cantag'),\n contextId = currentTarget.data('contextid');\n\n // This code gets called each time the user clicks the tag link\n // so we can use it to reload the contents of the tag modal.\n modalPromise.then(function(modal) {\n // Display spinner and disable save button.\n disableSaveButton(root);\n startLoading(root);\n\n var args = {\n id: questionId\n };\n\n var tagsFragment = Fragment.loadFragment('qbank_tagquestion', 'tags_form', contextId, args);\n modal.setBody(tagsFragment);\n\n tagsFragment.then(function() {\n enableSaveButton(root);\n return;\n })\n .always(function() {\n // Always hide the loading spinner when the request\n // has completed.\n stopLoading(root);\n return;\n })\n .catch(Notification.exception);\n\n // Show or hide the save button depending on whether the user\n // has the capability to edit the tags.\n if (canTag) {\n modal.getRoot().find(QuestionSelectors.actions.save).show();\n } else {\n modal.getRoot().find(QuestionSelectors.actions.save).hide();\n }\n\n setQuestionId(modal, questionId);\n setContextId(modal, contextId);\n\n return modal;\n }).catch(Notification.exception);\n\n e.preventDefault();\n });\n };\n\n /**\n * Send the form data to the server to save question tags.\n *\n * @method save\n * @param {object} modal The modal object.\n * @param {object} root The container element.\n * @return {object} A promise\n */\n var save = function(modal, root) {\n // Display spinner and disable save button.\n disableSaveButton(root);\n startLoading(root);\n\n var formData = getFormData(modal);\n var questionId = getQuestionId(modal);\n var contextId = getContextId(modal);\n\n // Send the form data to the server for processing.\n return Repository.submitTagCreateUpdateForm(questionId, contextId, formData)\n .always(function() {\n // Regardless of success or error we should always stop\n // the loading icon and re-enable the buttons.\n stopLoading(root);\n enableSaveButton(root);\n return;\n })\n .catch(Notification.exception);\n };\n\n return {\n init: function(root) {\n root = $(root);\n registerEventListeners(root);\n }\n };\n});\n"],"names":["define","$","Fragment","Str","ModalEvents","ModalSaveCancel","Notification","CustomEvents","Repository","QuestionSelectors","enableSaveButton","root","find","actions","save","prop","disableSaveButton","startLoading","containers","loadingIcon","removeClass","stopLoading","addClass","modal","formData","getBody","serialize","getFormData","questionId","data","getQuestionId","contextId","getContextId","submitTagCreateUpdateForm","always","catch","exception","init","modalPromise","create","large","then","get_string","string","setTitle","getRoot","on","e","submit","preventDefault","hide","location","reload","stopPropagation","edittags","show","events","activate","currentTarget","canTag","args","id","tagsFragment","loadFragment","setBody","attr","setQuestionId","setContextId","registerEventListeners"],"mappings":";;;;;;;AAsBAA,qCAAO,CACK,SACA,gBACA,WACA,oBACA,yBACA,oBACA,iCACA,+BACA,gCAEJ,SACIC,EACAC,SACAC,IACAC,YACAC,gBACAC,aACAC,aACAC,WACAC,uBASJC,iBAAmB,SAASC,MAC5BA,KAAKC,KAAKH,kBAAkBI,QAAQC,MAAMC,KAAK,YAAY,IAS3DC,kBAAoB,SAASL,MAC7BA,KAAKC,KAAKH,kBAAkBI,QAAQC,MAAMC,KAAK,YAAY,IAoB3DE,aAAe,SAASN,MACGA,KAAKC,KAAKH,kBAAkBS,WAAWC,aAE7CC,YAAY,WASjCC,YAAc,SAASV,MACIA,KAAKC,KAAKH,kBAAkBS,WAAWC,aAE7CG,SAAS,WAwJ9BR,KAAO,SAASS,MAAOZ,MAEvBK,kBAAkBL,MAClBM,aAAaN,UAETa,SAtLU,SAASD,cAChBA,MAAME,UAAUb,KAAK,QAAQc,YAqLrBC,CAAYJ,OACvBK,WAvHY,SAASL,cAClBA,MAAME,UAAUI,KAAK,cAsHXC,CAAcP,OAC3BQ,UA5IW,SAASR,cACjBA,MAAME,UAAUI,KAAK,aA2IZG,CAAaT,cAGtBf,WAAWyB,0BAA0BL,WAAYG,UAAWP,UAC9DU,QAAO,WAGJb,YAAYV,MACZD,iBAAiBC,SAGpBwB,MAAM7B,aAAa8B,kBAGrB,CACHC,KAAM,SAAS1B,OA9HU,SAASA,UAC9B2B,aAAejC,gBAAgBkC,OAAO,CACtCC,OAAO,IACRC,MAAK,SAASlB,cAIbpB,IAAIuC,WAAW,eAAgB,YAC1BD,MAAK,SAASE,eACXpB,MAAMqB,SAASD,QACRA,UAEVR,MAAM7B,aAAa8B,WAExBb,MAAMsB,UAAUC,GAAG1C,YAAYU,MAAM,SAASiC,GAC/BxB,MAAME,UAAUb,KAAK,QAC3BoC,SACLD,EAAEE,oBAGN1B,MAAMsB,UAAUC,GAAG,SAAU,QAAQ,SAASC,GAC1CjC,KAAKS,MAAOZ,MAAM8B,MAAK,WACnBlB,MAAM2B,OACNC,SAASC,YAEVjB,MAAM7B,aAAa8B,WAItBW,EAAEE,iBACFF,EAAEM,qBAGC9B,SAGXZ,KAAKmC,GAAG,QAASrC,kBAAkBI,QAAQyC,UAAU,SAASP,GAC1DA,EAAEE,iBAEFX,aAAaG,MAAMlB,OAAUA,MAAMgC,YAMvC5C,KAAKmC,GAAGvC,aAAaiD,OAAOC,SAAUhD,kBAAkBI,QAAQyC,UAAU,SAASP,OAC3EW,cAAgBzD,EAAE8C,EAAEW,eAEpB9B,WAAa8B,cAAc7B,KAAK,cAChC8B,SAAWD,cAAc7B,KAAK,UAC9BE,UAAY2B,cAAc7B,KAAK,aAInCS,aAAaG,MAAK,SAASlB,OAEvBP,kBAAkBL,MAClBM,aAAaN,UAETiD,KAAO,CACPC,GAAIjC,YAGJkC,aAAe5D,SAAS6D,aAAa,oBAAqB,YAAahC,UAAW6B,aACtFrC,MAAMyC,QAAQF,cAEdA,aAAarB,MAAK,WACV/B,iBAAiBC,SAGpBuB,QAAO,WAGJb,YAAYV,SAGnBwB,MAAM7B,aAAa8B,WAIhBuB,OACApC,MAAMsB,UAAUjC,KAAKH,kBAAkBI,QAAQC,MAAMyC,OAErDhC,MAAMsB,UAAUjC,KAAKH,kBAAkBI,QAAQC,MAAMoC,OAtGjD,SAAS3B,MAAOK,YAChCL,MAAME,UAAUwC,KAAK,kBAAmBrC,YAwGhCsC,CAAc3C,MAAOK,YA7Hd,SAASL,MAAOQ,WAC/BR,MAAME,UAAUwC,KAAK,iBAAkBlC,WA6H/BoC,CAAa5C,MAAOQ,WAEbR,SACRY,MAAM7B,aAAa8B,WAEtBW,EAAEE,oBAoCFmB,CADAzD,KAAOV,EAAEU"}PK\U+bank/tagquestion/amd/build/selectors.min.jsnu[/** * The purpose of this module is to centralize selectors related to question. * * @module qbank_tagquestion/selectors * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("qbank_tagquestion/selectors",[],(function(){return{actions:{save:'[data-action="save"]',edittags:'[data-action="edittags"]'},containers:{loadingIcon:'[data-region="overlay-icon-container"]'}}})); //# sourceMappingURL=selectors.min.js.mapPK\|wg g +bank/tagquestion/amd/build/edit_tags.min.jsnu[/** * A javascript module to handle question tags editing. * * @module qbank_tagquestion/edit_tags * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("qbank_tagquestion/edit_tags",["jquery","core/fragment","core/str","core/modal_events","core/modal_save_cancel","core/notification","core/custom_interaction_events","qbank_tagquestion/repository","qbank_tagquestion/selectors"],(function($,Fragment,Str,ModalEvents,ModalSaveCancel,Notification,CustomEvents,Repository,QuestionSelectors){var enableSaveButton=function(root){root.find(QuestionSelectors.actions.save).prop("disabled",!1)},disableSaveButton=function(root){root.find(QuestionSelectors.actions.save).prop("disabled",!0)},startLoading=function(root){root.find(QuestionSelectors.containers.loadingIcon).removeClass("hidden")},stopLoading=function(root){root.find(QuestionSelectors.containers.loadingIcon).addClass("hidden")},save=function(modal,root){disableSaveButton(root),startLoading(root);var formData=function(modal){return modal.getBody().find("form").serialize()}(modal),questionId=function(modal){return modal.getBody().data("questionid")}(modal),contextId=function(modal){return modal.getBody().data("contextid")}(modal);return Repository.submitTagCreateUpdateForm(questionId,contextId,formData).always((function(){stopLoading(root),enableSaveButton(root)})).catch(Notification.exception)};return{init:function(root){!function(root){var modalPromise=ModalSaveCancel.create({large:!1}).then((function(modal){return Str.get_string("questiontags","question").then((function(string){return modal.setTitle(string),string})).catch(Notification.exception),modal.getRoot().on(ModalEvents.save,(function(e){modal.getBody().find("form").submit(),e.preventDefault()})),modal.getRoot().on("submit","form",(function(e){save(modal,root).then((function(){modal.hide(),location.reload()})).catch(Notification.exception),e.preventDefault(),e.stopPropagation()})),modal}));root.on("click",QuestionSelectors.actions.edittags,(function(e){e.preventDefault(),modalPromise.then((modal=>modal.show()))})),root.on(CustomEvents.events.activate,QuestionSelectors.actions.edittags,(function(e){var currentTarget=$(e.currentTarget),questionId=currentTarget.data("questionid"),canTag=!!currentTarget.data("cantag"),contextId=currentTarget.data("contextid");modalPromise.then((function(modal){disableSaveButton(root),startLoading(root);var args={id:questionId},tagsFragment=Fragment.loadFragment("qbank_tagquestion","tags_form",contextId,args);return modal.setBody(tagsFragment),tagsFragment.then((function(){enableSaveButton(root)})).always((function(){stopLoading(root)})).catch(Notification.exception),canTag?modal.getRoot().find(QuestionSelectors.actions.save).show():modal.getRoot().find(QuestionSelectors.actions.save).hide(),function(modal,questionId){modal.getBody().attr("data-questionid",questionId)}(modal,questionId),function(modal,contextId){modal.getBody().attr("data-contextid",contextId)}(modal,contextId),modal})).catch(Notification.exception),e.preventDefault()}))}(root=$(root))}}})); //# sourceMappingURL=edit_tags.min.js.mapPK\ 11-bank/tagquestion/tests/tag_condition_test.phpnu[. namespace qbank_tagquestion; use core\output\datafilter; use core_question\local\bank\question_edit_contexts; use context_module; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; /** * Unit tests for tag_condition class. * * @package qbank_tagquestion * @copyright 2025 Catalyst IT Canada Pty Ltd * @author Niko Hoogeveen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ #[CoversClass(\qbank_tagquestion\tag_condition::class)] #[CoversMethod(\qbank_tagquestion\tag_condition::class, 'build_query_from_filter')] #[CoversMethod(\qbank_tagquestion\tag_condition::class, 'get_condition_key')] final class tag_condition_test extends \advanced_testcase { /** * Create test environment with questions and tags. * * @return array Contains 'questions', 'tags', and 'questioncategory' */ private function create_test_data(): array { $this->resetAfterTest(); $this->setAdminUser(); // Create course and qbank. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $qbank = $generator->create_module('qbank', ['course' => $course->id]); $context = context_module::instance($qbank->cmid); // Set up question generator. $questiongenerator = $generator->get_plugin_generator('core_question'); // Create question category. $questioncategory = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Test Category', ]); // Create tags. $tags = \core_tag_tag::create_if_missing( \core_tag_area::get_collection('core', 'question'), ['math', 'algebra', 'geometry', 'advanced'], ); // Create questions with different tag combinations. $questions = []; // Question 1: tagged with 'math' only. $questions['q1'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 1 - Math only', ]); \core_tag_tag::set_item_tags( 'core_question', 'question', $questions['q1']->id, $context, ['math'], ); // Question 2: tagged with 'math' and 'algebra'. $questions['q2'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 2 - Math and Algebra', ]); \core_tag_tag::set_item_tags( 'core_question', 'question', $questions['q2']->id, $context, ['math', 'algebra'], ); // Question 3: tagged with 'math', 'algebra', and 'advanced'. $questions['q3'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 3 - Math, Algebra, Advanced', ]); \core_tag_tag::set_item_tags( 'core_question', 'question', $questions['q3']->id, $context, ['math', 'algebra', 'advanced'], ); // Question 4: tagged with 'geometry' only. $questions['q4'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 4 - Geometry only', ]); \core_tag_tag::set_item_tags( 'core_question', 'question', $questions['q4']->id, $context, ['geometry'], ); // Question 5: tagged with 'math' and 'geometry'. $questions['q5'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 5 - Math and Geometry', ]); \core_tag_tag::set_item_tags( 'core_question', 'question', $questions['q5']->id, $context, ['math', 'geometry'], ); // Question 6: no tags. $questions['q6'] = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncategory->id, 'name' => 'Question 6 - No tags', ]); return [ 'questions' => $questions, 'tags' => $tags, 'questioncategory' => $questioncategory, ]; } /** * Helper method to get question IDs from filter results. * * @param array $filter Filter configuration * @param int $categoryid The question category ID * @return array Array of question IDs that match the filter */ private function get_questions_from_filter(array $filter, int $categoryid): array { global $DB; [$where, $params] = tag_condition::build_query_from_filter($filter); $sql = "SELECT q.id FROM {question} q JOIN {question_versions} qv ON q.id = qv.questionid JOIN {question_bank_entries} qbe ON qv.questionbankentryid = qbe.id WHERE qbe.questioncategoryid = :categoryid"; $params['categoryid'] = $categoryid; if (!empty($where)) { $sql .= " AND $where"; } $results = $DB->get_records_sql($sql, $params); return array_keys($results); } /** * Data provider for tag filter tests. * * @return array Test cases with filter configuration and expected results */ public static function filter_provider(): array { return [ 'no tags returns all questions' => [ 'filtertags' => [], 'jointype' => datafilter::JOINTYPE_ALL, 'expectedcount' => 6, 'expectedmatches' => ['q1', 'q2', 'q3', 'q4', 'q5', 'q6'], 'expectednomatches' => [], ], 'single tag math with ANY jointype' => [ 'filtertags' => ['math'], 'jointype' => datafilter::JOINTYPE_ANY, 'expectedcount' => 4, 'expectedmatches' => ['q1', 'q2', 'q3', 'q5'], 'expectednomatches' => ['q4', 'q6'], ], 'multiple tags math and geometry with ANY jointype' => [ 'filtertags' => ['math', 'geometry'], 'jointype' => datafilter::JOINTYPE_ANY, 'expectedcount' => 5, 'expectedmatches' => ['q1', 'q2', 'q3', 'q4', 'q5'], 'expectednomatches' => ['q6'], ], 'multiple tags math and algebra with ALL jointype' => [ 'filtertags' => ['math', 'algebra'], 'jointype' => datafilter::JOINTYPE_ALL, 'expectedcount' => 2, 'expectedmatches' => ['q2', 'q3'], 'expectednomatches' => ['q1', 'q4', 'q5', 'q6'], ], 'three tags math algebra advanced with ALL jointype' => [ 'filtertags' => ['math', 'algebra', 'advanced'], 'jointype' => datafilter::JOINTYPE_ALL, 'expectedcount' => 1, 'expectedmatches' => ['q3'], 'expectednomatches' => ['q1', 'q2', 'q4', 'q5', 'q6'], ], 'single tag math with NONE jointype' => [ 'filtertags' => ['math'], 'jointype' => datafilter::JOINTYPE_NONE, 'expectedcount' => 2, 'expectedmatches' => ['q4', 'q6'], 'expectednomatches' => ['q1', 'q2', 'q3', 'q5'], ], 'multiple tags math and geometry with NONE jointype' => [ 'filtertags' => ['math', 'geometry'], 'jointype' => datafilter::JOINTYPE_NONE, 'expectedcount' => 1, 'expectedmatches' => ['q6'], 'expectednomatches' => ['q1', 'q2', 'q3', 'q4', 'q5'], ], ]; } /** * Test tag filtering with various configurations. * * @param array $filtertags Tag names to filter by * @param int $jointype The join type for the filter * @param int $expectedcount Expected number of matching questions * @param array $expectedmatches Question keys that should match * @param array $expectednomatches Question keys that should not match */ #[DataProvider('filter_provider')] public function test_filter( array $filtertags, int $jointype, int $expectedcount, array $expectedmatches, array $expectednomatches, ): void { $testdata = $this->create_test_data(); $questions = $testdata['questions']; $tags = $testdata['tags']; $questioncategory = $testdata['questioncategory']; // Convert tag names to tag IDs. $tagids = []; foreach ($filtertags as $tagname) { $tagids[] = $tags[$tagname]->id; } $filter = [ 'values' => $tagids, 'jointype' => $jointype, ]; $questionids = $this->get_questions_from_filter($filter, $questioncategory->id); $this->assertCount($expectedcount, $questionids); foreach ($expectedmatches as $key) { $this->assertContains( $questions[$key]->id, $questionids, "Question $key should be in the results", ); } foreach ($expectednomatches as $key) { $this->assertNotContains( $questions[$key]->id, $questionids, "Question $key should not be in the results", ); } } /** * Test filtering with non-existent tag ID. */ public function test_filter_nonexistent_tag(): void { $testdata = $this->create_test_data(); $filter = [ 'values' => [99999], 'jointype' => datafilter::JOINTYPE_ANY, ]; $questionids = $this->get_questions_from_filter($filter, $testdata['questioncategory']->id); // Should return no questions. $this->assertCount(0, $questionids); } /** * Test get_condition_key returns expected value. */ public function test_get_condition_key(): void { $this->assertSame('qtagids', tag_condition::get_condition_key()); } /** * Test the JOINTYPE_DEFAULT constant. */ public function test_jointype_default_constant(): void { $this->assertSame(datafilter::JOINTYPE_ALL, tag_condition::JOINTYPE_DEFAULT); } /** * Test filtering with default jointype (should be ALL). */ public function test_filter_default_jointype(): void { $testdata = $this->create_test_data(); $questions = $testdata['questions']; $tags = $testdata['tags']; $filter = [ 'values' => [$tags['math']->id, $tags['algebra']->id], ]; $questionids = $this->get_questions_from_filter($filter, $testdata['questioncategory']->id); // Should behave like ALL jointype: return questions with both tags. $this->assertCount(2, $questionids); $this->assertContains($questions['q2']->id, $questionids); $this->assertContains($questions['q3']->id, $questionids); } /** * Test build_query_from_filter returns empty when no tags provided. */ public function test_build_query_empty_filter(): void { $filter = [ 'values' => [], 'jointype' => datafilter::JOINTYPE_ALL, ]; [$where, $params] = tag_condition::build_query_from_filter($filter); $this->assertSame('', $where); $this->assertSame([], $params); } } PK\S((4bank/tagquestion/tests/external/submit_tags_test.phpnu[. namespace qbank_tagquestion\external; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); /** * Question external functions tests. * * @package qbank_tagquestion * @copyright 2016 Pau Ferrer * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class submit_tags_test extends \externallib_advanced_testcase { /** @var \stdClass course record. */ protected $course; /** @var \stdClass user record. */ protected $student; /** @var \stdClass user role record. */ protected $studentrole; /** * Set up for every test */ public function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); // Setup test data. $this->course = $this->getDataGenerator()->create_course(); // Create users. $this->student = self::getDataGenerator()->create_user(); // Users enrolments. $this->studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual'); } /** * submit_tags_form should throw an exception when the question id doesn't match * a question. */ public function test_submit_tags_form_incorrect_question_id(): void { $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $editingcontext = $questioncontext; $question = $questions[0]; // Generate an id for a question that doesn't exist. $missingquestionid = $questions[1]->id * 2; $question->id = $missingquestionid; $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []); // We should receive an exception if the question doesn't exist. $this->expectException('moodle_exception'); submit_tags::execute($missingquestionid, $editingcontext->id, $formdata); } /** * submit_tags_form should throw an exception when the context id doesn't match * a context. */ public function test_submit_tags_form_incorrect_context_id(): void { $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $editingcontext = $questioncontext; $question = $questions[0]; // Generate an id for a context that doesn't exist. $missingcontextid = $editingcontext->id * 200; $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []); // We should receive an exception if the question doesn't exist. $this->expectException('moodle_exception'); submit_tags::execute($question->id, $missingcontextid, $formdata); } /** * submit_tags_form should return false when tags are disabled. */ public function test_submit_tags_form_tags_disabled(): void { global $CFG; $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $editingcontext = $questioncontext; $question = $questions[0]; $user = $this->create_user_can_tag($course); $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []); $this->setUser($user); $CFG->usetags = false; $result = submit_tags::execute($question->id, $editingcontext->id, $formdata); $CFG->usetags = true; $this->assertFalse($result['status']); } /** * submit_tags_form should return false if the user does not have any capability * to tag the question. */ public function test_submit_tags_form_no_tag_permissions(): void { global $DB; $generator = $this->getDataGenerator(); $user = $generator->create_user(); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $questiongenerator = $generator->get_plugin_generator('core_question'); list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $editingcontext = $questioncontext; $question = $questions[0]; $formdata = $this->generate_encoded_submit_tags_form_string( $question, $qcat, $questioncontext, ['foo'], ['bar'] ); // Prohibit all of the tag capabilities. assign_capability('moodle/question:tagmine', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id); assign_capability('moodle/question:tagall', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id); $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual'); $user->ignoresesskey = true; $this->setUser($user); $result = submit_tags::execute($question->id, $editingcontext->id, $formdata); $this->assertFalse($result['status']); } /** * submit_tags_form should return false if the user only has the capability to * tag their own questions and the question is not theirs. */ public function test_submit_tags_form_tagmine_permission_non_owner_question(): void { global $DB; $generator = $this->getDataGenerator(); $user = $generator->create_user(); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $questiongenerator = $generator->get_plugin_generator('core_question'); list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $editingcontext = $questioncontext; $question = $questions[0]; $formdata = $this->generate_encoded_submit_tags_form_string( $question, $qcat, $questioncontext, ['foo'], ['bar'] ); // Make sure the question isn't created by the user. $question->createdby = $user->id + 1; // Prohibit all of the tag capabilities. assign_capability('moodle/question:tagmine', CAP_ALLOW, $teacherrole->id, $questioncontext->id); assign_capability('moodle/question:tagall', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id); $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual'); $user->ignoresesskey = true; $this->setUser($user); $result = submit_tags::execute($question->id, $editingcontext->id, $formdata); $this->assertFalse($result['status']); } /** * Build the encoded form data expected by the submit_tags_form external function. * * @param \stdClass $question The question record * @param \stdClass $questioncategory The question category record * @param \context $questioncontext Context for the question category * @param array $tags A list of tag names for the question * @param array $coursetags A list of course tag names for the question * @return string HTML encoded string of the data */ protected function generate_encoded_submit_tags_form_string($question, $questioncategory, $questioncontext, $tags = [], $coursetags = []) { $data = [ 'id' => $question->id, 'categoryid' => $questioncategory->id, 'contextid' => $questioncontext->id, 'questionname' => $question->name, 'questioncategory' => $questioncategory->name, 'context' => $questioncontext->get_context_name(false), 'tags' => $tags, 'coursetags' => $coursetags ]; $data = \qbank_tagquestion\form\tags_form::mock_generate_submit_keys($data); return http_build_query($data, '', '&'); } /** * Create a user, enrol them in the course, and give them the capability to * tag all questions in the system context. * * @param \stdClass $course The course record to enrol in * @return \stdClass The user record */ protected function create_user_can_tag($course) { global $DB; $generator = $this->getDataGenerator(); $user = $generator->create_user(); $roleid = $generator->create_role(); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $systemcontext = \context_system::instance(); $generator->role_assign($roleid, $user->id, $systemcontext->id); $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual'); // Give the user global ability to tag questions. assign_capability('moodle/question:tagall', CAP_ALLOW, $roleid, $systemcontext, true); // Allow the user to submit form data. $user->ignoresesskey = true; return $user; } } PK\Z ?bank/tagquestion/tests/event/question_deleted_observer_test.phpnu[. namespace qbank_tagquestion\event; /** * Tests for question_deleted_observer * * @package qbank_tagquestion * @copyright 2023 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 \qbank_tagquestion\event\question_deleted_observer */ final class question_deleted_observer_test extends \advanced_testcase { /** * Deleting a question with tags should also delete the tags. * * @return void */ public function test_delete_question_with_tags(): void { $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); [, , $qcat, $questions] = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $question = reset($questions); $tag = random_string(); \core_tag_tag::add_item_tag('core_question', 'question', $question->id, $questioncontext, $tag); $this->assertCount(1, \core_tag_tag::get_item_tags('core_question', 'question', $question->id)); question_delete_question($question->id); $this->assertEmpty(\core_tag_tag::get_item_tags('core_question', 'question', $question->id)); } /** * Tag a question as one user, delete it as another user, confirm the tag was deleted. */ public function test_delete_question_with_user_tags(): void { $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $user = $this->getDataGenerator()->create_user(); [, , $qcat, $questions] = $questiongenerator->setup_course_and_questions(); $questioncontext = \context::instance_by_id($qcat->contextid); $question = reset($questions); $tag = random_string(); \core_tag_tag::add_item_tag('core_question', 'question', $question->id, $questioncontext, $tag, $user->id); $this->assertCount(1, \core_tag_tag::get_item_tags('core_question', 'question', $question->id)); $this->setAdminUser(); question_delete_question($question->id); $this->assertEmpty(\core_tag_tag::get_item_tags('core_question', 'question', $question->id)); } } PK\:1gZ Z 8bank/tagquestion/tests/behat/tag_question_action.featurenu[@qbank @qbank_tagquestion Feature: Use the qbank plugin manager page for tagquestion In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | Scenario: Enable/disable tagquestion column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Tag question" And I click on "Disable" "link" in the "Tag question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And the "Manage tags" action should not exist for the "First question" question in the question bank When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Tag question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then the "Manage tags" action should exist for the "First question" question in the question bank Scenario: Enable/disable tagquestion section from question edit form Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Tag question" And I click on "Disable" "link" in the "Tag question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I choose "Edit question" action for "First question" in the question bank Then I should not see "Tags" in the "region-main" "region" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Tag question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I choose "Edit question" action for "First question" in the question bank And I should see "Tags" in the "region-main" "region" PK\ӥu u bank/managecategories/styles.cssnu[/* Card-style border on category lists */ .qbank_managecategories-categorylist { padding: 1rem; border: 1px solid #dee2e6; border-radius: 1rem; } /* Category item layout */ .qbank_managecategories-handlecontainer { width: 32px; height: 32px; display: inline-flex; justify-content: center; align-items: center; } .qbank_managecategories-item .container { padding: 0.75rem; } /* Separators between each item become drop indicators */ .qbank_managecategories-item { width: 100%; list-style: none; border-top: 1px solid #dee2e6; } .qbank_managecategories-item:first-child { border-top-color: transparent; } .qbank_managecategories-item:last-child { border-bottom: 1px solid transparent; } .qbank_managecategories-item .qbank_managecategories-item:first-child { border-top-color: #dee2e6; } .qbank_managecategories-details p:last-child { margin-bottom: 0; } .qbank_managecategories-childlistcontainer:empty { display: none; } /* Fade effect on drag handle */ .qbank_managecategories-draghandle { display: none; opacity: 0.45; } .qbank_managecategories-item.draghandle .qbank_managecategories-draghandle { display: inline; } /* Drag indicator */ .qbank_managecategories-item.draghandle > .container { border: 2px solid transparent; border-radius: 1rem; } .qbank_managecategories-item.draghandle > .container:hover { cursor: move; border-color: #0f6cbf; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); } /* No move/delete buttons on non-draggable items */ .dropdown-item.show-when-movable { display: none; } .qbank_managecategories-item.draghandle .dropdown-item.show-when-movable { display: block; } /* Don't show card border on nested lists. */ .qbank_managecategories-categorylist .qbank_managecategories-categorylist { padding: 0 0 .5rem 0; border: 0; border-radius: 0; } /* Don't show hover indication on parent when a child is hovered */ .editing .qbank_managecategories-item .container:hover:has(.container:hover) { border-color: transparent; } /* New child drop target */ .qbank_managecategories-newchild { display: none; border: 1px solid #dee2e6; background-color: #fff; padding: 0.25rem 1rem; color: #dee2e6; font-weight: bold; } /* stylelint-disable max-line-length */ .qbank_managecategories-newchild.qbank_managecategories-droptarget, /* Show the drop target if it's directly inside a highlighted category, but not if its inside a child of that category */ .qbank_managecategories-droptarget-before > .container > div > .qbank_managecategories-contentcontainer > :not(.qbank_managecategories-childlistcontainer) .qbank_managecategories-newchild { display: unset; } /* stylelint-enable max-line-length */ /* Hidden descriptions */ .qbank_managecategories-description { display: none; } .showdescriptions .qbank_managecategories-description { display: unset; } PK\L%bank/managecategories/db/services.phpnu[. /** * qbank_managecategories external functions and service definitions. * @package qbank_managecategories * @category webservice * @copyright 2021 Catalyst IT Australia Pty Ltd * @author 2021, Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'qbank_managecategories_move_category' => [ 'classname' => 'qbank_managecategories\external\move_category', 'description' => 'Move a question category', 'type' => 'write', 'capabilities' => 'moodle/question:managecategory', 'ajax' => true, ], ]; PK\hc 8bank/managecategories/lang/en/qbank_managecategories.phpnu[. /** * Strings for component 'qbank_managecategories', language 'en' * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['after'] = 'After {$a}'; $string['before'] = 'Before {$a}'; $string['categoryincategory'] = 'Cannot move category in same category.'; $string['categorymoved'] = 'The category has been moved successfully.'; $string['confirmdelete'] = 'Delete category "{$a}"?. Any child categories will be moved to this category\'s parent.'; $string['descriptionnotdisplayed'] = 'Categories description not displayed'; $string['displaydescription'] = 'Categories description displayed'; $string['error:category'] = 'You must select a valid category'; $string['idnumberexists'] = 'ID number already in use. Change it to move or update category.'; $string['lastcategoryinthiscontext'] = 'This category is the only one in this context. You need to add another category if this category is to be moved.'; $string['movecategory'] = 'Move {$a}'; $string['newchild'] = 'As new child of {$a}'; $string['pluginname'] = 'Manage categories'; $string['privacy:preference:includesubcategories_filter_default'] = 'Indicates whether or not the "Also show questions from subcategories" filter option is enabled by default when the user views the question bank.'; $string['privacy:preference:showdescriptions'] = 'A flag to indicate whether or not category descriptions are shown.'; $string['questionsubcategoriesdisplayed'] = 'When selecting categories in the question bank, the "Also show questions from subcategories" option is used, so questions from subcategories are included in the filter results.'; $string['questionsubcategoriesnotdisplayed'] = 'When selecting categories in the question bank, the "Also show questions from subcategories" option is not used, so questions from subcategories are not included in the filter results.'; $string['showcategorydescription'] = 'Show descriptions'; PK\|4''"bank/managecategories/category.phpnu[. /** * This script allows a teacher to create, edit and delete question categories. * * @package qbank_managecategories * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @author 2021, Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot . '/question/editlib.php'); use core_question\output\qbank_actionbar; use core_question\category_manager; use qbank_managecategories\form\question_move_form; use qbank_managecategories\helper; use qbank_managecategories\output\categories; use qbank_managecategories\output\categories_header; use qbank_managecategories\question_categories; require_login(); core_question\local\bank\helper::require_plugin_enabled(helper::PLUGINNAME); // Since Moodle 5.0 any request with the courseid parameter is deprecated and will redirect to the banks management page. if ($courseid = optional_param('courseid', 0, PARAM_INT)) { redirect(new moodle_url('/question/banks.php', ['courseid' => $courseid])); } list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) = question_edit_setup('categories', '/question/bank/managecategories/category.php'); $thiscontext = context_module::instance($cmid)->id; $todelete = optional_param('delete', 0, PARAM_INT); // The ID of a category to delete. $PAGE->set_url($thispageurl); $PAGE->add_body_class('limitedwidth'); $manager = new category_manager($thispageurl); if ($todelete) { if (!$category = $DB->get_record("question_categories", ["id" => $todelete])) { throw new moodle_exception('nocate', 'question', $thispageurl->out(), $todelete); } helper::question_remove_stale_questions_from_category($todelete); $questionstomove = count($manager->get_real_question_ids_in_category($todelete)); // Second pass, if we still have questions to move, setup the form. if ($questionstomove) { $categorycontext = context::instance_by_id($category->contextid); $moveform = new question_move_form($thispageurl, ['contexts' => [$categorycontext], 'currentcat' => "$todelete"]); if ($moveform->is_cancelled()) { $thispageurl->remove_all_params(); if (!is_null($cmid)) { $thispageurl->param('cmid', $cmid); } else { $thispageurl->param('courseid', $courseid); } redirect($thispageurl); } else if ($formdata = $moveform->get_data()) { list($tocategoryid, $tocontextid) = explode(',', $formdata->category); $manager->move_questions_and_delete_category($formdata->delete, $tocategoryid); $thispageurl->remove_params('cat', 'category'); redirect($thispageurl); } } } else { $questionstomove = 0; } if ((!empty($todelete) && (!$questionstomove) && confirm_sesskey())) { $manager->delete_category($todelete);// Delete the category now no questions to move. $thispageurl->remove_params('cat', 'category'); redirect($thispageurl); } $PAGE->set_title(get_string('editcategories', 'question')); $PAGE->set_heading($COURSE->fullname); $PAGE->activityheader->disable(); // Print horizontal nav if needed. $renderer = $PAGE->get_renderer('core_question', 'bank'); echo $OUTPUT->header(); $qbankaction = new \core_question\output\qbank_action_menu($thispageurl); echo $renderer->render($qbankaction); if ($questionstomove) { $vars = new stdClass(); $vars->name = $category->name; $vars->count = $questionstomove; echo $OUTPUT->box(get_string('categorymove', 'question', $vars), 'generalbox boxaligncenter'); $moveform->display(); } else { // Get module contexts we have capabilities to manage. $contextswithcaps = $contexts->having_one_edit_tab_cap('categories'); $modcontexts = array_filter($contextswithcaps, static fn ($context) => $context->contextlevel === CONTEXT_MODULE); // Display the user interface. $questioncategories = new question_categories( $thispageurl, $modcontexts, $cmid, $courseid, $thiscontext, ); $PAGE->requires->js_call_amd('qbank_managecategories/categorymanager', 'init'); // Load reactive module. echo $OUTPUT->render(new categories_header($questioncategories)); echo $OUTPUT->render(new categories($questioncategories)); } echo $OUTPUT->footer(); PK\ÃvVVbank/managecategories/lib.phpnu[. /** * Callbacks for qbank_manageacategories * * @package qbank_managecategories * @copyright 2024 Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Allow update of user preferences via AJAX. * * @return array[] */ function qbank_managecategories_user_preferences(): array { return [ 'qbank_managecategories_showdescriptions' => [ 'type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED, 'default' => false, 'permissioncallback' => [core_user::class, 'is_current_user'], ], 'qbank_managecategories_includesubcategories_filter_default' => [ 'type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED, 'default' => false, 'permissioncallback' => [core_user::class, 'is_current_user'], ], ]; } PK\\!bank/managecategories/version.phpnu[. /** * Plugin version and other meta-data are defined here. * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_managecategories'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\00Bbank/managecategories/classes/form/question_category_edit_form.phpnu[. namespace qbank_managecategories\form; use context; use context_module; use context_course; use qbank_managecategories\helper; use moodle_exception; use moodle_url; use core_question\local\bank\question_edit_contexts; use qbank_managecategories\output\category; use core_question\category_manager; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); require_once($CFG->libdir . '/questionlib.php'); /** * Defines the form for editing question categories. * * Form for editing questions categories (name, description, etc.) * * @package qbank_managecategories * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_category_edit_form extends \core_form\dynamic_form { /** @var ?category_manager $manager */ protected ?category_manager $manager = null; /** * Return the category manager. * * Since we cannot override the constructor, using this method ensures the manager has always been initialised * before access. * * @return category_manager */ protected function get_manager(): category_manager { if (is_null($this->manager)) { $this->manager = new category_manager(); } return $this->manager; } /** * Get the category, contexts, course and cmid based on the data provided via AJAX. * * @return array * @throws \coding_exception */ protected function get_current_data() { // If categoryid is set, we are editing an existing category. $currentcat = isset($this->_ajaxformdata['categoryid']) ? (int)$this->_ajaxformdata['categoryid'] : 0; // Determine the context based on the provided IDs. $cmid = isset($this->_ajaxformdata['cmid']) ? (int)$this->_ajaxformdata['cmid'] : 0; $courseid = isset($this->_ajaxformdata['courseid']) ? (int)$this->_ajaxformdata['courseid'] : 0; if ($cmid !== 0) { $thiscontext = context_module::instance($cmid); } if ($courseid !== 0) { $thiscontext = context_course::instance($courseid); } if ($courseid === 0 && $cmid === 0) { $parentcontext = (int)explode(',', $this->_ajaxformdata['parent'])[1]; $contextid = $parentcontext === 0 ? $this->_ajaxformdata['contextid'] : (int)$parentcontext; $thiscontext = context::instance_by_id($contextid); } if ($thiscontext) { $contexts = new question_edit_contexts($thiscontext); $contexts = $contexts->all(); } return [$currentcat, $cmid, $courseid, $thiscontext, $contexts]; } /** * Build the form definition. * * This adds all the form fields that the manage categories feature needs. * @throws \coding_exception */ protected function definition() { $mform = $this->_form; [$currentcat, $cmid, $courseid, $thiscontext, $contexts] = $this->get_current_data(); $mform->addElement('hidden', 'contextid', $thiscontext->id); $mform->setType('contextid', PARAM_INT); $mform->addElement('hidden', 'courseid', $courseid); $mform->setType('courseid', PARAM_INT); $mform->addElement('hidden', 'cmid', $cmid); $mform->setType('cmid', PARAM_INT); $mform->addElement('hidden', 'currentparent'); $mform->setDefault('currentparent', $this->_ajaxformdata['parent'] ?? ''); $mform->setType('currentparent', PARAM_SEQUENCE); $mform->addElement( 'questioncategory', 'parent', get_string('parentcategory', 'question'), [ 'contexts' => $contexts, 'top' => true, 'currentcat' => $currentcat, 'nochildrenof' => $currentcat, ], ); $mform->setType('parent', PARAM_SEQUENCE); if ($this->get_manager()->is_only_child_of_top_category_in_context($currentcat)) { $mform->hardFreeze('parent'); } $mform->addHelpButton('parent', 'parentcategory', 'question'); $mform->addElement('text', 'name', get_string('name'), 'maxlength="254" size="50"'); $mform->setDefault('name', ''); $mform->addRule('name', get_string('categorynamecantbeblank', 'question'), 'required', null, 'client'); $mform->setType('name', PARAM_TEXT); $mform->addElement('editor', 'info', get_string('categoryinfo', 'question'), ['rows' => 10], ['noclean' => 1]); $mform->setDefault('info', ''); $mform->setType('info', PARAM_RAW); $mform->addElement('text', 'idnumber', get_string('idnumber', 'question'), 'maxlength="100" size="10"'); $mform->addHelpButton('idnumber', 'idnumber', 'question'); $mform->setType('idnumber', PARAM_RAW); $mform->addElement('hidden', 'id', 0); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'questioncount', $this->_ajaxformdata['questioncount'] ?? 0); $mform->setType('questioncount', PARAM_INT); $mform->addElement('hidden', 'sortorder', $this->_ajaxformdata['sortorder'] ?? 0); $mform->setType('sortorder', PARAM_INT); } /** * Validation. * * Ensure that we aren't trying to move the only child of a top category to a different context. * Ensure that the ID Number is unique within the target context. * * @param array $data * @param array $files * @return array the errors that were found * @throws \dml_exception|\coding_exception */ public function validation($data, $files) { global $DB; $errors = parent::validation($data, $files); $currentrec = $DB->get_record('question_categories', ['id' => $data['id']]); $currentparent = $data['currentparent']; $data['parent'] ??= $currentparent; // If no parent was submitted, use the current parent. [$parentid, $contextid] = explode(',', $data['parent']); if ($currentrec) { $currentparent = $currentrec->parent . ',' . $currentrec->contextid; // Cannot move the last category in a context to another parent. $lastcategoryinthiscontext = $this->get_manager()->is_only_child_of_top_category_in_context($data['id']); if ($lastcategoryinthiscontext && $currentparent !== $data['parent']) { if ($parentid !== $this->_ajaxformdata['id']) { $errors['parent'] = get_string('lastcategoryinthiscontext', 'qbank_managecategories'); } } // Cannot move category in same category. if ($currentrec->id === $parentid && $currentrec->contextid === $contextid) { $errors['parent'] = get_string('categoryincategory', 'qbank_managecategories'); } } // Add field validation check for duplicate idnumber. $id = $data['id'] ?? null; if (!empty($data['idnumber']) && !$this->get_manager()->idnumber_is_unique_in_context($data['idnumber'], $contextid, $id)) { $errors['idnumber'] = get_string('idnumbertaken', 'error'); } return $errors; } #[\Override] protected function get_context_for_dynamic_submission(): context { $contextid = $this->optional_param('contextid', 0, PARAM_INT); if ($contextid === 0) { $contextid = $this->_ajaxformdata['contextid']; } return context::instance_by_id($contextid); } #[\Override] protected function check_access_for_dynamic_submission(): void { $this->get_manager()->require_manage_category($this->get_context_for_dynamic_submission()); } /** * Process the category form * * Either add or update a question category, then return a reactive state update for the category. * * @return array[] Reactive state update. * {@link https://moodledev.io/docs/4.2/guides/javascript/reactive#controlling-the-state-from-the-backend} * @throws \coding_exception * @throws \dml_exception * @throws moodle_exception */ public function process_dynamic_submission(): array { global $DB, $OUTPUT; $values = $this->get_data(); $newinfo = format_text($values->info['text'], (int)$values->info['format'], ['noclean' => false]); $idnumber = $values->idnumber; if ((string)$idnumber === '') { $idnumber = null; } if (empty($values->id)) { $values->id = $this->get_manager()->add_category( $values->parent, $values->name, $newinfo, (int)$values->info['format'], $idnumber, ); } else { $this->get_manager()->update_category( $values->id, $values->parent ?? '', // If we can't change the parent, it is not passed through he form. $values->name, $newinfo, (int)$values->info['format'], $idnumber, ); } $record = $DB->get_record('question_categories', ['id' => $values->id]); // The question count will never change, we just need it passed through to re-render the category. $record->questioncount = $values->questioncount; $category = new category( $record, context::instance_by_id($record->contextid), $values->cmid ?? 0, $values->courseid ?? 0, ); return [ [ 'name' => 'categories', 'action' => 'put', 'fields' => [ 'id' => $record->id, 'name' => $record->name, 'parent' => $record->parent, 'sortorder' => $record->sortorder, 'draghandle' => $category->get_canreorder(), 'templatecontext' => $category->export_for_template($OUTPUT), ], ], ]; } #[\Override] public function set_data_for_dynamic_submission(): void { $categoryid = isset($this->_ajaxformdata['categoryid']) ? (int)$this->_ajaxformdata['categoryid'] : 0; if ($categoryid !== 0) { global $DB; $cattoset = $DB->get_record('question_categories', ['id' => $categoryid]); $this->set_data((object)[ 'id' => (int)$cattoset->id, 'name' => $cattoset->name, 'contextid' => (int)$cattoset->contextid, 'info' => [ 'format' => FORMAT_HTML, 'text' => $cattoset->info, ], 'infoformat' => (int)$cattoset->infoformat, 'parent' => (int)$cattoset->parent . ',' . (int)$cattoset->contextid, 'idnumber' => $cattoset->idnumber, ]); } } #[\Override] protected function get_page_url_for_dynamic_submission(): moodle_url { $params = []; $cmid = isset($this->_ajaxformdata['cmid']) ? (int)$this->_ajaxformdata['cmid'] : 0; $courseid = isset($this->_ajaxformdata['courseid']) ? (int)$this->_ajaxformdata['courseid'] : 0; if ($cmid !== 0) { $params['cmid'] = $cmid; } if ($courseid !== 0) { $params['courseid'] = $courseid; } return new moodle_url('/question/bank/managecategories/category.php', $params); } } PK\#]9bank/managecategories/classes/form/question_move_form.phpnu[. namespace qbank_managecategories\form; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Form for moving questions between categories. * * @package qbank_managecategories * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_move_form extends moodleform { /** * Build the form definition. * * This adds all the form fields that the question move feature needs. * @throws \coding_exception */ protected function definition() { $mform = $this->_form; $currentcat = $this->_customdata['currentcat']; $contexts = $this->_customdata['contexts']; $mform->addElement('questioncategory', 'category', get_string('category', 'question'), compact('contexts', 'currentcat')); $this->add_action_buttons(true, get_string('categorymoveto', 'question')); $mform->addElement('hidden', 'delete', $currentcat); $mform->setType('delete', PARAM_INT); } } PK\o*++8bank/managecategories/classes/external/move_category.phpnu[. namespace qbank_managecategories\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_question\category_manager; use moodle_exception; use context; use qbank_managecategories\helper; /** * External class used for category reordering. * * @package qbank_managecategories * @category external * @copyright 2024 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class move_category extends external_api { /** * Describes the parameters for update_category_order webservice. * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'pagecontextid' => new external_value(PARAM_INT, 'The context of the current page'), 'categoryid' => new external_value(PARAM_INT, 'Category being moved'), 'targetparentid' => new external_value(PARAM_INT, 'The ID of the parent category to move to.'), 'precedingsiblingid' => new external_value( PARAM_INT, 'The ID of the preceding category. Null if this is being moved to top of its parent', allownull: NULL_ALLOWED, ), ]); } /** * Returns description of method result value. * * This function will always return a set of state updates for the core/reactive state. * {@link https://moodledev.io/docs/4.2/guides/javascript/reactive#controlling-the-state-from-the-backend} * * @return external_multiple_structure */ public static function execute_returns(): external_multiple_structure { return new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHA, 'State object name (always "categories" from this function).'), 'action' => new external_value(PARAM_ALPHA, 'State update type (always "put" from this function).'), 'fields' => new external_single_structure( [ 'id' => new external_value(PARAM_INT, 'The ID of the category that was updated.'), 'sortorder' => new external_value(PARAM_INT, 'The new sortorder', VALUE_OPTIONAL), 'parent' => new external_value(PARAM_INT, 'The ID of the new parent category.', VALUE_OPTIONAL), 'context' => new external_value(PARAM_INT, 'The ID of the new context.', VALUE_OPTIONAL), 'draghandle' => new external_value( PARAM_BOOL, 'Should this category have a drag handle?', VALUE_OPTIONAL ), ] ), ], 'An individual state update', ), 'Category state updates', ); } /** * Move category to new location. * * @param int $pagecontextid ID of the context of the current page. * @param int $categoryid ID of the category to move. * @param int $targetparentid The ID of the parent category to move to. * @param ?int $precedingsiblingid The ID of the preceding category. Null if this is being moved to top of its parent. * @return array Reactive state updates representing the changes made to the categories. */ public static function execute( int $pagecontextid, int $categoryid, int $targetparentid, ?int $precedingsiblingid = null ): array { // Update category location. global $DB, $CFG; require_once($CFG->libdir . '/questionlib.php'); $context = context::instance_by_id($pagecontextid); self::validate_context($context); $manager = new category_manager(); $origincategory = $DB->get_record('question_categories', ['id' => $categoryid], '*', MUST_EXIST); $targetparent = $DB->get_record('question_categories', ['id' => $targetparentid], '*', MUST_EXIST); if ($precedingsiblingid) { $precedingsibling = $DB->get_record('question_categories', ['id' => $precedingsiblingid], '*', MUST_EXIST); } // Check permission for original and destination contexts. $manager->require_manage_category(context::instance_by_id($origincategory->contextid)); if ($origincategory->contextid != $targetparent->contextid) { $manager->require_manage_category(context::instance_by_id($targetparent->contextid)); } $originstateupdate = self::make_state_update($origincategory->id); $stateupdates = []; $transaction = $DB->start_delegated_transaction(); // Set new parent. if ($origincategory->parent !== $targetparent->id) { $newsiblings = $DB->get_fieldset('question_categories', 'id', ['parent' => $targetparent->id]); if ( count($newsiblings) == 1 && $manager->is_only_child_of_top_category_in_context(reset($newsiblings)) ) { // If we are moving to a top-level parent that only had 1 category before, allow re-ordering of that category. $stateupdates[] = self::make_state_update(reset($newsiblings), draghandle: true); } $originstateupdate->fields->parent = $targetparent->id; } // Change to the same context. if ($origincategory->contextid !== $targetparent->contextid) { // Check for duplicate idnumber. if ( !is_null($origincategory->idnumber) && !$manager->idnumber_is_unique_in_context($origincategory->idnumber, $targetparent->contextid) ) { $transaction->rollback(new moodle_exception('idnumberexists', 'qbank_managecategories')); } $originstateupdate->fields->context = $targetparent->contextid; } // Update sort order. if ($precedingsiblingid) { $sortorder = $precedingsibling->sortorder + 1; } else { $sortorder = 1; } $originstateupdate->fields->sortorder = $sortorder; // Save the updated parent, context and sortorder. $manager->update_category( $categoryid, helper::combine_id_context($targetparent), $origincategory->name, $origincategory->info, $origincategory->infoformat, $origincategory->idnumber, $sortorder, ); // Get other categories which are after the new position, and update their sortorder. $params = [ 'parent' => $targetparent->id, 'sortorder' => $sortorder, 'origincategoryid' => $origincategory->id, ]; $select = " parent = :parent AND id <> :origincategoryid AND sortorder >= :sortorder"; $sort = "sortorder ASC"; $toupdatesortorder = $DB->get_records_select('question_categories', $select, $params, $sort); foreach ($toupdatesortorder as $category) { $DB->set_field('question_categories', 'sortorder', ++$sortorder, ['id' => $category->id]); $stateupdates[] = self::make_state_update($category->id, sortorder: $sortorder); } if (isset($originstateupdate->fields->parent)) { // If the category has moved parent, re-order the original siblings to fill the gap. $originsortorder = 1; $params = [ 'parent' => $origincategory->parent, ]; $select = "parent = :parent"; $sort = "sortorder ASC"; $originsiblings = $DB->get_records_select('question_categories', $select, $params, $sort); if ( count($originsiblings) == 1 && $manager->is_only_child_of_top_category_in_context(reset($originsiblings)->id) ) { // If this is now the only category in the context, don't allow re-ordering. $stateupdates[] = self::make_state_update( reset($originsiblings)->id, sortorder: $originsortorder, draghandle: false, ); } else { foreach ($originsiblings as $category) { if ($category->sortorder !== $originsortorder) { $DB->set_field('question_categories', 'sortorder', $originsortorder, ['id' => $category->id]); $stateupdates[] = self::make_state_update($category->id, sortorder: $originsortorder); } $originsortorder++; } } } $transaction->allow_commit(); // Return the updated for the moved category, followed by any additional updates that happened as a result. array_unshift($stateupdates, $originstateupdate); return $stateupdates; } /** * Generate a category state update based on the provided fields. * * @param int $id Category ID, required. * @param int|null $sortorder New sortorder, optional. * @param int|null $parent Category ID of new parent, optional. * @param bool|null $draghandle Set display of the drag handle. Optional. * @return \stdClass The update object. */ protected static function make_state_update( int $id, ?int $sortorder = null, ?int $parent = null, ?bool $draghandle = null, ): \stdClass { $update = (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $id, ], ]; if (!is_null($sortorder)) { $update->fields->sortorder = $sortorder; } if (!is_null($parent)) { $update->fields->parent = $parent; } if (!is_null($draghandle)) { $update->fields->draghandle = $draghandle; } return $update; } } PK\h+v 2bank/managecategories/classes/privacy/provider.phpnu[. namespace qbank_managecategories\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\writer; /** * Privacy Subsystem for qbank_managecategories implementing null_provider. * * @package qbank_managecategories * @category privacy * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qbank_managecategories_showdescriptions', 'privacy:preference:showdescriptions'); $collection->add_user_preference('qbank_managecategories_includesubcategories_filter_default', 'privacy:preference:includesubcategories_filter_default'); return $collection; } /** * Export all user preferences for the plugin. * * @param int $userid The userid of the user whose data is to be exported. */ public static function export_user_preferences(int $userid) { $showdescription = get_user_preferences('qbank_managecategories_showdescriptions', null, $userid); if ($showdescription !== null) { $displaydescription = $showdescription ? 'displaydescription' : 'descriptionnotdisplayed'; writer::export_user_preference( 'qbank_managecategories', 'showdescr', $showdescription, get_string($displaydescription, 'qbank_managecategories') ); } $includesubcategories = get_user_preferences('qbank_managecategories_includesubcategories_filter_default', null, $userid); if (isset($includesubcategories)) { $displayquestions = $includesubcategories ? 'questionsubcategoriesdisplayed' : 'questionsubcategoriesnotdisplayed'; writer::export_user_preference( 'qbank_managecategories', 'includesubcategories', $includesubcategories, get_string($displayquestions, 'qbank_managecategories') ); } } } PK\H 0=bank/managecategories/classes/question_category_list_item.phpnu[. namespace qbank_managecategories; use moodle_url; /** * An item in a list of question categories. * * @package qbank_managecategories * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated(null, since: '4.5', mdl: 'MDL-72397', reason: 'Output is now generated by templates.')] class question_category_list_item extends \list_item { /** * Override set_icon_html function. * * @param bool $first Is the first on the list. * @param bool $last Is the last on the list. * @param \list_item $lastitem Last item. * @deprecated MDL-72397 Since Moodle 4.5 */ public function set_icon_html($first, $last, $lastitem): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $CFG; $category = $this->item; $url = new moodle_url('/question/bank/managecategories/category.php', ($this->parentlist->pageurl->params() + ['edit' => $category->id])); $this->icons['edit'] = $this->image_icon(get_string('editthiscategory', 'question'), $url, 'edit'); parent::set_icon_html($first, $last, $lastitem); $toplevel = ($this->parentlist->parentitem === null);// This is a top level item. if (($this->parentlist->nextlist !== null) && $last && $toplevel && (count($this->parentlist->items) > 1)) { $url = new moodle_url($this->parentlist->pageurl, [ 'movedowncontext' => $this->id, 'tocontext' => $this->parentlist->nextlist->context->id, 'sesskey' => sesskey() ] ); $this->icons['down'] = $this->image_icon( get_string('shareincontext', 'question', $this->parentlist->nextlist->context->get_context_name()), $url, 'down'); } if (($this->parentlist->lastlist !== null) && $first && $toplevel && (count($this->parentlist->items) > 1)) { $url = new moodle_url($this->parentlist->pageurl, [ 'moveupcontext' => $this->id, 'tocontext' => $this->parentlist->lastlist->context->id, 'sesskey' => sesskey() ] ); $this->icons['up'] = $this->image_icon( get_string('shareincontext', 'question', $this->parentlist->lastlist->context->get_context_name()), $url, 'up'); } } /** * Override item_html function. * * @param array $extraargs * @return string Item html. * @throws \moodle_exception * @deprecated MDL-72397 Since Moodle 4.5 */ public function item_html($extraargs = []): string { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $PAGE, $OUTPUT; $str = $extraargs['str']; $category = $this->item; $questionbankurl = new moodle_url('/question/edit.php', $this->parentlist->pageurl->params()); $questionbankurl->param('cat', $category->id . ',' . $category->contextid); $categoryname = format_string($category->name, true, ['context' => $this->parentlist->context]); $idnumber = null; if ($category->idnumber !== null && $category->idnumber !== '') { $idnumber = $category->idnumber; } $questioncount = ' (' . $category->questioncount . ')'; $categorydesc = format_text($category->info, $category->infoformat, ['context' => $this->parentlist->context, 'noclean' => true]); // Don't allow delete if this is the top category, or the last editable category in this context. $deleteurl = null; if ($category->parent && !helper::question_is_only_child_of_top_category_in_context($category->id)) { $deleteurl = new moodle_url($this->parentlist->pageurl, ['delete' => $this->id, 'sesskey' => sesskey()]); } // Render each question category. $data = [ 'questionbankurl' => $questionbankurl, 'categoryname' => $categoryname, 'idnumber' => $idnumber, 'questioncount' => $questioncount, 'categorydesc' => $categorydesc, 'deleteurl' => $deleteurl, 'deletetitle' => $str->delete ]; return $OUTPUT->render_from_template(helper::PLUGINNAME . '/listitem', $data); } } PK\sTT8bank/managecategories/classes/question_category_list.phpnu[. namespace qbank_managecategories; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir. '/listlib.php'); require_once($CFG->dirroot . '/question/editlib.php'); use stdClass; use moodle_list; /** * Class representing a list of question categories. * * @package qbank_managecategories * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated(null, since: '4.5', mdl: 'MDL-72397', reason: 'Output is now generated by templates.')] class question_category_list extends moodle_list { /** * Table name. * @var $table */ public $table = "question_categories"; /** * List item class name. * @var $listitemclassname */ public $listitemclassname = '\qbank_managecategories\question_category_list_item'; /** * Reference to list displayed below this one. * @var $nextlist */ public $nextlist = null; /** * Reference to list displayed above this one. * @var $lastlist */ public $lastlist = null; /** * Context. * @var $context */ public $context = null; /** * Sort by string. * @var $sortby */ public $sortby = 'parent, sortorder, name'; /** * Constructor. * * @param string $type * @param string $attributes * @param boolean $editable * @param \moodle_url $pageurl url for this page * @param integer $page if 0 no pagination. (These three params only used in top level list.) * @param string $pageparamname name of url param that is used for passing page no * @param integer $itemsperpage no of top level items. * @param \context $context * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ public function __construct($type='ul', $attributes='', $editable = false, $pageurl=null, $page = 0, $pageparamname = 'page', $itemsperpage = DEFAULT_QUESTIONS_PER_PAGE, $context = null) { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); parent::__construct('ul', '', $editable, $pageurl, $page, 'cpage', $itemsperpage); $this->context = $context; } /** * Set the array of records of list items. * * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ public function get_records(): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $this->records = helper::get_categories_for_contexts($this->context->id, $this->sortby); } /** * Returns the highest category id that the $item can have as its parent. * Note: question categories cannot go higher than the TOP category. * * @param \list_item $item The item which its top level parent is going to be returned. * @return int * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ public function get_top_level_parent_id($item): int { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); // Put the item at the highest level it can go. $topcategory = question_get_top_category($item->item->contextid, true); return $topcategory->id; } /** * Process any actions. * * @param integer $left id of item to move left * @param integer $right id of item to move right * @param integer $moveup id of item to move up * @param integer $movedown id of item to move down * @return void * @deprecated MDL-72397 Since Moodle 4.5 * @todo Final removal in Moodle 6.0 MDL-80804 */ public function process_actions($left, $right, $moveup, $movedown): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $category = new stdClass(); if (!empty($left)) { // Moved Left (In to another category). $category->id = $left; $category->contextid = $this->context->id; $event = \core\event\question_category_moved::create_from_question_category_instance($category); $event->trigger(); } else if (!empty($right)) { // Moved Right (Out of the current category). $category->id = $right; $category->contextid = $this->context->id; $event = \core\event\question_category_moved::create_from_question_category_instance($category); $event->trigger(); } parent::process_actions($left, $right, $moveup, $movedown); } } PK\>))4bank/managecategories/classes/category_condition.phpnu[. namespace qbank_managecategories; use core\output\datafilter; use core_question\local\bank\condition; use core_question\local\bank\view; /** * This class controls from which category questions are listed. * * @package qbank_managecategories * @copyright 2013 Ray Morris * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class category_condition extends condition { /** @var \stdClass The course record. */ protected $course; /** @var \stdClass The course_modules record. */ protected \stdClass $cm; /** @var \stdClass The category record. */ protected $category; /** @var array of contexts. */ protected $contexts; /** @var string categoryID,contextID as used with question_bank_view->display(). */ protected $cat; /** @var int The maximum displayed length of the category info. */ public $maxinfolength; /** @var bool Include questions in subcategories of the specified category? */ public $includesubcategories; /** * Constructor to initialize the category filter condition. * * @param view $qbank qbank view */ public function __construct(?view $qbank = null) { if (is_null($qbank)) { return; } $this->cat = $qbank->get_pagevars('cat'); $this->contexts = array_filter($qbank->contexts->having_one_edit_tab_cap($qbank->get_pagevars('tabname')), static fn($context) => $context->contextlevel === CONTEXT_MODULE ); $this->course = $qbank->course; $this->cm = $qbank->cm; [$categoryid, $contextid] = self::validate_category_param($this->cat); if (is_null($categoryid)) { return; } $this->category = self::get_category_record($categoryid, $contextid); parent::__construct($qbank); if (isset($this->filter['filteroptions']['includesubcategories'])) { set_user_preference('qbank_managecategories_includesubcategories_filter_default', $this->filter['filteroptions']['includesubcategories']); } $this->includesubcategories = $this->filter['filteroptions']['includesubcategories'] ?? get_user_preferences('qbank_managecategories_includesubcategories_filter_default', false); } /** * Return default category * * @return \stdClass default category */ public function get_default_category(): \stdClass { if (empty($this->category)) { return question_get_default_category(\context_module::instance($this->cm->id)->id, true); } return $this->category; } public static function get_condition_key() { return 'category'; } /** * Returns course id. * * @return string Course id. */ public function get_course_id() { return $this->course->id; } /** * @deprecated since Moodle 4.3 MDL-72321 - please do not use this function any more. */ #[\core\attribute\deprecated('filtering objects', since: '4.3', mdl: 'MDL-72321', final: true)] public function display_options() { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 - please do not use this function any more. */ #[\core\attribute\deprecated('foobar::blah()', since: '4.3', mdl: 'MDL-72321', final: true)] public function display_options_adv() { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated('core_question renderer', since: '4.3', mdl: 'MDL-72321', final: true)] protected function display_category_form($contexts, $pageurl, $current) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated('qbank_managecategories/choose_category template', since: '4.3', mdl: 'MDL-72321', final: true)] public static function print_choose_category_message(): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 */ #[\core\attribute\deprecated(null, since: '4.3', mdl: 'MDL-72321', final: true)] public static function get_current_category($categoryandcontext) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Return category and context ID from compound parameter. * * @param string $categoryandcontext Comma-separated list of category and context IDs. * @return int[]|null[] */ public static function validate_category_param(string $categoryandcontext): array { [$categoryid, $contextid] = explode(',', $categoryandcontext); if (!$categoryid) { return [null, null]; } return [clean_param($categoryid, PARAM_INT), clean_param($contextid, PARAM_INT)]; } /** * Fetch the question category record matching the provided category and context IDs. * * @param int $categoryid * @param int $contextid * @return \stdClass * @throws \dml_exception */ public static function get_category_record($categoryid, $contextid): \stdClass { global $DB; return $DB->get_record('question_categories', ['id' => $categoryid, 'contextid' => $contextid], '*', MUST_EXIST); } /** * @deprecated since Moodle 4.3 MDL-72321 - please do not use this function any more. */ #[\core\attribute\deprecated(null, since: '4.3', mdl: 'MDL-72321', final: true)] protected function print_category_info($category): string { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } public static function build_query_from_filter(array $filter): array { global $DB; $recursive = false; if (isset($filter['filteroptions']['includesubcategories'])) { $recursive = (bool)$filter['filteroptions']['includesubcategories']; } // Sub categories. if ($recursive) { $categories = $filter['values']; $categoriesandsubcategories = []; foreach ($categories as $categoryid) { $categoriesandsubcategories += question_categorylist($categoryid); } } else { $categoriesandsubcategories = $filter['values']; } $jointype = $filter['jointype'] ?? self::JOINTYPE_DEFAULT; $equal = !($jointype === datafilter::JOINTYPE_NONE); [$insql, $params] = $DB->get_in_or_equal($categoriesandsubcategories, SQL_PARAMS_NAMED, 'cat', $equal); $where = 'qbe.questioncategoryid ' . $insql; return [$where, $params]; } public function get_title() { return get_string('category', 'core_question'); } public function get_filter_class() { return 'qbank_managecategories/datafilter/filtertypes/categories'; } public function allow_custom() { return false; } public function allow_multiple() { return false; } public function allow_empty() { return false; } public function get_join_list(): array { return [ datafilter::JOINTYPE_ANY, ]; } public function get_initial_values() { $catmenu = helper::question_category_options($this->contexts, true, 0, true, -1, false); $values = []; foreach ($catmenu as $menu) { foreach ($menu as $heading => $catlist) { $values[] = (object) [ // Add a list item for each question category context. This will serve as a "heading" within the list // and we will use CSS to disable pointer events so it cannot be selected. 'value' => '', 'title' => $heading, 'disabled' => true, 'classes' => 'suggestions-heading', ]; foreach ($catlist as $key => $value) { $values[] = (object) [ // Remove contextid from value. 'value' => str_contains($key, ',') ? substr($key, 0, strpos($key, ',')) : $key, 'title' => $value, 'selected' => ($key === $this->cat), ]; } } } return $values; } public function get_filteroptions(): \stdClass { return (object)[ 'includesubcategories' => $this->includesubcategories, ]; } public function is_required(): bool { return true; } #[\Override] public function filter_invalid_values(array $filterconditions): array { global $DB; $defaultcatid = explode(',', $filterconditions['cat'])[0]; [$insql, $inparams] = $DB->get_in_or_equal($filterconditions['filter']['category']['values']); $categories = $DB->get_records_select('question_categories', "id {$insql}", $inparams, null, 'id'); $categoryids = array_keys($categories); foreach ($filterconditions['filter']['category']['values'] as $key => $catid) { // Check that the category still exists, and if not, remove it from the conditions. if (!in_array($catid, $categoryids)) { unset($filterconditions['filter']['category']['values'][$key]); } } // If we now don't have any valid categories, use the default loaded from the page. if (count($filterconditions['filter']['category']['values']) === 0) { $filterconditions['filter']['category']['values'] = [$defaultcatid]; } return $filterconditions; } } PK\CDZ77(bank/managecategories/classes/helper.phpnu[. namespace qbank_managecategories; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . "/questionlib.php"); use context; use core_question\category_manager; use core_question\local\bank\question_version_status; use core_question\output\question_category_selector; use moodle_exception; use html_writer; /** * Class helper contains all the library functions. * * Library functions used by qbank_managecategories. * This code is based on lib/questionlib.php by Martin Dougiamas. * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Name of this plugin. */ const PLUGINNAME = 'qbank_managecategories'; /** * Remove stale questions from a category. * * This finds and removes any old-style random questions (qtype = random), * or any questions that were deleted while they were in use by a quiz (status = hidden), * but those usages have since been removed. * * If a category only contains stale questions, the users are unable to delete the question * category unless they move those stale questions to another one category, but to them the * category may appear empty. The purpose of this function is to detect the questions that * may have gone stale and remove them. * * You will typically use this prior to checking if the category contains questions. * * @param int $categoryid The category ID. * @throws \dml_exception */ public static function question_remove_stale_questions_from_category(int $categoryid): void { global $DB; $sql = "SELECT q.id 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 = :categoryid AND (q.qtype = :qtype OR qv.status = :status)"; $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN]; $questions = $DB->get_records_sql($sql, $params); foreach ($questions as $question) { // The function question_delete_question does not delete questions in use. question_delete_question($question->id); } } /** * Checks whether this is the only child of a top category in a context. * * @param int $categoryid a category id. * @return bool * @throws \dml_exception * @deprecated Since Moodle 4.5. Use core_question\category_manager::is_only_child_of_top_category_in_context instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( 'core_question\category_manager::is_only_child_of_top_category_in_context', since: 4.5, reason: 'Moved to core namespace', mdl: 'MDL-72397' )] public static function question_is_only_child_of_top_category_in_context(int $categoryid): bool { \core\deprecation::emit_deprecation([__CLASS__, __FUNCTION__]); $manager = new category_manager(); return $manager->is_only_child_of_top_category_in_context($categoryid); } /** * Checks whether the category is a "Top" category (with no parent). * * @param int $categoryid a category id. * @return bool * @throws \dml_exception * @deprecated Since Moodle 4.5. Use core_question\category_manager::is_top_category instead. * @todo Final removal in Moodle 6.0 MDL-80804. */ #[\core\attribute\deprecated( 'core_question\category_manager::is_top_category', since: 4.5, reason: 'Moved to core namespace', mdl: 'MDL-72397' )] public static function question_is_top_category(int $categoryid): bool { \core\deprecation::emit_deprecation([__CLASS__, __FUNCTION__]); $manager = new category_manager(); return $manager->is_top_category($categoryid); } /** * Ensures that this user is allowed to delete this category. * * @param int $todelete a category id. * @throws \required_capability_exception * @throws \dml_exception|moodle_exception * @deprecated Since Moodle 4.5. Use core_question\category_manager::can_delete_category instead. * @todo Final removal in Moodle 6.0 MDL-80804. */ #[\core\attribute\deprecated( 'core_question\category_manager::can_delete_category', since: 4.5, reason: 'Moved to core namespace', mdl: 'MDL-72397' )] public static function question_can_delete_cat(int $todelete): void { \core\deprecation::emit_deprecation([__CLASS__, __FUNCTION__]); $manager = new category_manager(); $manager->require_can_delete_category($todelete); } /** * Only for the use of add_indented_names(). * * Recursively adds an indentedname field to each category, starting with the category * with id $id, and dealing with that category and all its children, and * return a new array, with those categories in the right order. * * @param array $categories an array of categories which has had childids * fields added by flatten_category_tree(). Passed by reference for * performance only. It is not modfied. * @param int $id the category to start the indenting process from. * @param int $depth the indent depth. Used in recursive calls. * @param int $nochildrenof * @return array a new array of categories, in the right order for the tree. */ public static function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array { // Indent the name of this category. $newcategories = []; $newcategories[$id] = $categories[$id]; $newcategories[$id]->indentedname = str_repeat('   ', $depth) . $categories[$id]->name; // Recursively indent the children. foreach ($categories[$id]->childids as $childid) { if ($childid != $nochildrenof) { $newcategories = $newcategories + self::flatten_category_tree( $categories, $childid, $depth + 1, $nochildrenof, ); } } // Remove the childids array that were temporarily added. unset($newcategories[$id]->childids); return $newcategories; } /** * Format categories into an indented list reflecting the tree structure. * * @param array $categories An array of category objects, for example from the. * @param int $nochildrenof * @return array The formatted list of categories. */ public static function add_indented_names(array $categories, int $nochildrenof = -1): array { // Add an array to each category to hold the child category ids. This array // will be removed again by flatten_category_tree(). It should not be used // outside these two functions. foreach (array_keys($categories) as $id) { $categories[$id]->childids = []; } // Build the tree structure, and record which categories are top-level. // We have to be careful, because the categories array may include published // categories from other courses, but not their parents. $toplevelcategoryids = []; foreach (array_keys($categories) as $id) { if ( !empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories) ) { $categories[$categories[$id]->parent]->childids[] = $id; } else { $toplevelcategoryids[] = $id; } } // Flatten the tree to and add the indents. $newcategories = []; foreach ($toplevelcategoryids as $id) { $newcategories = $newcategories + self::flatten_category_tree( $categories, $id, 0, $nochildrenof, ); } return $newcategories; } /** * Output a select menu of question categories. * * Categories from this course and (optionally) published categories from other courses * are included. Optionally, only categories the current user may edit can be included. * * @param array $contexts * @param bool $top * @param int $currentcat * @param string $selected optionally, the id of a category to be selected by * default in the dropdown. * @param int $nochildrenof * @param bool $return to return the string of the select menu or echo that from the method * @return ?string The HTML, or null if the $return is false. * @throws \coding_exception|\dml_exception */ public static function question_category_select_menu( array $contexts, bool $top = false, int $currentcat = 0, string $selected = "", int $nochildrenof = -1, bool $return = false, ): ?string { $categoriesarray = self::question_category_options( $contexts, $top, $currentcat, false, $nochildrenof, false, ); $choose = ''; $options = []; foreach ($categoriesarray as $group => $opts) { $options[] = [$group => $opts]; } $outputhtml = html_writer::label( get_string('questioncategory', 'core_question'), 'id_movetocategory', false, ['class' => 'accesshide'], ); $attrs = [ 'id' => 'id_movetocategory', 'class' => 'form-select', 'data-action' => 'toggle', 'data-togglegroup' => 'qbank', 'data-toggle' => 'action', 'disabled' => false, ]; $outputhtml .= html_writer::select($options, 'category', $selected, $choose, $attrs); if ($return) { return $outputhtml; } else { echo $outputhtml; return null; } } /** * Get all the category objects, including a count of the number of questions in that category, * for all the categories in the lists $contexts. * * @param string $contexts comma separated list of contextids * @param string $sortorder used as the ORDER BY clause in the select statement. * @param bool $top Whether to return the top categories or not. * @param int $showallversions 1 to show all versions not only the latest. * @return array of category objects. * @throws \dml_exception */ public static function get_categories_for_contexts( string $contexts, string $sortorder = 'parent, sortorder, name ASC', bool $top = false, int $showallversions = 0, ): array { return (new question_category_selector())->get_categories_for_contexts($contexts, $sortorder, $top, $showallversions); } /** * Output an array of question categories. * * @param array $contexts The list of contexts. * @param bool $top Whether to return the top categories or not. * @param int $currentcat * @param bool $popupform * @param int $nochildrenof * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. * @return array * @throws \coding_exception|\dml_exception */ public static function question_category_options( array $contexts, bool $top = false, int $currentcat = 0, bool $popupform = false, int $nochildrenof = -1, bool $escapecontextnames = true, ): array { return (new question_category_selector())->question_category_options( $contexts, $top, $currentcat, $popupform, $nochildrenof, $escapecontextnames, ); } /** * Add context in categories key. * * @param array $categories The list of categories. * @return array */ public static function question_add_context_in_key(array $categories): array { return (new question_category_selector())->question_add_context_in_key($categories); } /** * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. * * @param array $categories An array of question categories. * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not. * @return array The same question category list given to the function, with the top category names being translated. * @throws \coding_exception */ public static function question_fix_top_names(array $categories, bool $escape = true): array { return (new question_category_selector())->question_fix_top_names($categories, $escape); } /** * Combine id and context id for a question category * * @param \stdClass $category a category to extract its id and context id * @return string the combined string */ public static function combine_id_context(\stdClass $category): string { return $category->id . ',' . $category->contextid; } } PK\J5bank/managecategories/classes/question_categories.phpnu[. namespace qbank_managecategories; use context; use moodle_url; /** * Builds a tree for categories for rendering the category management page. * * @package qbank_managecategories * @copyright 2024 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_categories { /** * @var moodle_url Object representing url for this page */ public moodle_url $pageurl; /** * @var ?int cmid. */ public ?int $cmid; /** * @var ?int courseid. */ public ?int $courseid; /** * @var ?int The context ID of the current page. */ public ?int $contextid; /** * @var array An array containing a tree of categories for each context. */ public array $editlists; /** * Constructor. * * @param moodle_url $pageurl base URL of the display categories page. Used for redirects. * @param context[] $contexts contexts where the current user can edit categories. * @param ?int $cmid course module id for the current page. * @param ?int $courseid course id for the current page. * @param ?int $thiscontext The context ID of the current page. */ public function __construct( moodle_url $pageurl, array $contexts, ?int $cmid = null, ?int $courseid = null, ?int $thiscontext = null, ) { global $DB; $this->cmid = $cmid; $this->courseid = $courseid; $this->pageurl = $pageurl; $this->contextid = $thiscontext; $contextids = array_map(fn($context) => $context->id, $contexts); [$insql, $params] = $DB->get_in_or_equal($contextids); $topcategories = $DB->get_records_select_menu( 'question_categories', 'parent = 0 AND contextid ' . $insql, $params, fields: 'contextid, id' ); foreach ($contexts as $context) { // We can only have categories in module context. if ($context->contextlevel !== CONTEXT_MODULE) { continue; } $items = helper::get_categories_for_contexts($context->id); // Create an ordered tree with children correctly nested under parents. foreach ($items as $item) { if (array_key_exists((int) $item->parent, $items)) { $item->parentitem = $items[$item->parent]; $items[$item->parent]->children[$item->id] = $item; } } foreach ($items as $item) { if (isset($item->children)) { foreach ($item->children as $children) { unset($items[$children->id]); } } } $this->editlists[$context->id] = (object) [ 'items' => $items, 'context' => $context, 'categoryid' => $topcategories[$context->id], ]; } } } PK\;1s :bank/managecategories/classes/output/categories_header.phpnu[. namespace qbank_managecategories\output; use context; use renderable; use renderer_base; use templatable; use qbank_managecategories\question_categories; /** * Output component for category page header. * * @package qbank_managecategories * @copyright 2024 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 categories_header implements renderable, templatable { /** * Constructor. * * @param question_categories $categories Question categories for display. */ public function __construct( /** @var question_categories $categories Question categories for display. */ protected question_categories $categories, ) { } /** * Output template data for the heading of the category management page. * * @param renderer_base $output * @return array * @throws \coding_exception */ public function export_for_template(renderer_base $output): array { $helpstringhead = $output->heading_with_help( get_string('editcategories', 'question'), 'editcategories', 'question', ); $hascapability = has_capability( 'moodle/question:managecategory', context::instance_by_id($this->categories->contextid), ); $data = [ 'helpstringhead' => $helpstringhead, 'showdescriptions' => [ 'id' => 'showdescriptions-toggle', 'checked' => get_user_preferences('qbank_managecategories_showdescriptions'), 'label' => get_string('showcategorydescription', 'qbank_managecategories'), ], 'hascapability' => $hascapability, 'contextid' => $this->categories->contextid, 'cmid' => $this->categories->cmid, 'courseid' => $this->categories->courseid, ]; return $data; } } PK\$×##1bank/managecategories/classes/output/category.phpnu[. namespace qbank_managecategories\output; use action_menu; use action_menu_link; use context; use core\plugininfo\qbank; use core_question\category_manager; use moodle_url; use pix_icon; use qbank_managecategories\helper; use renderable; use renderer_base; use stdClass; use templatable; /** * Output component for a single category * * @package qbank_managecategories * @copyright 2024 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 category implements renderable, templatable { /** * @var bool Can this category be reordered? */ protected bool $canreorder; /** * Constructor * * @param stdClass $category The record of category we are rendering * @param context $context The context the category belongs to. * @param int $cmid The cmid of the course module context the category belongs to (optional). * @param int $courseid The course ID of the course context the category belongs to (optional). */ public function __construct( /** @var stdClass $category The record of category we are rendering */ protected stdClass $category, /** @var context $context The context the category belongs to. */ protected context $context, /** @var int $cmid The cmid of the course module context the category belongs to. */ protected int $cmid = 0, /** @var int $courseid The course ID of the course context the category belongs to. */ protected int $courseid = 0, ) { $manager = new category_manager(); $this->canreorder = !$manager->is_only_child_of_top_category_in_context($this->category->id); } /** * Get the canreorder flag. * * @return bool */ public function get_canreorder(): bool { return $this->canreorder; } /** * Create the template data for a category, and call recursively for child categories. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output): array { global $PAGE; $canmanagecategory = has_capability('moodle/question:managecategory', $this->context); $params = $PAGE->url->params(); $cmid = $params['cmid'] ?? $this->cmid; $courseid = $params['courseid'] ?? $this->courseid; // Each section adds html to be displayed as part of this list item. $questionbankurl = new moodle_url('/question/edit.php', $params); $questionbankurl->param('cat', helper::combine_id_context($this->category)); $categoryname = format_string($this->category->name, true, ['context' => $this->context, 'escape' => false]); $idnumber = null; if ($this->category->idnumber !== null && $this->category->idnumber !== '') { $idnumber = $this->category->idnumber; } $categorydesc = format_text( $this->category->info, $this->category->infoformat, ['context' => $this->context, 'noclean' => true], ); $menu = new action_menu(); $menu->attributes['draggable'] = 'false'; $menu->set_kebab_trigger(); $menu->prioritise = true; // Don't allow movement if only subcat. if ($canmanagecategory) { // This item display a modal for moving a category. // Move category modal. $menu->add(new action_menu_link( new \moodle_url('#'), new pix_icon( 't/move', get_string('move'), 'moodle', [ 'class' => 'iconsmall', ] ), get_string('move'), false, [ 'class' => 'show-when-movable', // Don't allow moving when this is the only category in the context. 'data-categoryid' => $this->category->id, 'data-actiontype' => 'move', 'data-contextid' => (int) $this->category->contextid, 'data-categoryname' => $categoryname, 'title' => get_string('movecategory', 'qbank_managecategories', $categoryname), ] )); $thiscontext = (int) $this->category->contextid; $editurl = new moodle_url('#'); $menu->add(new action_menu_link( $editurl, new pix_icon('t/edit', 'edit'), get_string('editsettings'), false, [ 'data-action' => 'addeditcategory', 'data-actiontype' => 'edit', 'data-contextid' => $thiscontext, 'data-categoryid' => $this->category->id, 'data-cmid' => $cmid, 'data-courseid' => $courseid, 'data-questioncount' => $this->category->questioncount, ] )); } // Sets up export to XML link. if (qbank::is_plugin_enabled('qbank_exportquestions')) { $exporturl = new moodle_url( '/question/bank/exportquestions/export.php', ['cat' => helper::combine_id_context($this->category)] ); if ($courseid !== 0) { $exporturl->param('courseid', $courseid); } else { $exporturl->param('cmid', $cmid); } $menu->add(new action_menu_link( $exporturl, new pix_icon('t/download', 'download'), get_string('exportasxml', 'question'), false, )); } // The delete action which should be last. if ($canmanagecategory) { // Sets up delete link. $deleteurl = new moodle_url( '/question/bank/managecategories/category.php', ['delete' => $this->category->id, 'sesskey' => sesskey()] ); if ($courseid !== 0) { $deleteurl->param('courseid', $courseid); } else { $deleteurl->param('cmid', $cmid); } $menu->add(new action_menu_link( $deleteurl, new pix_icon('t/delete', 'delete'), get_string('delete'), false, [ 'class' => 'text-danger show-when-movable', // Don't allow deletion when this is the only category in context. 'data-confirmation' => 'modal', 'data-confirmation-type' => 'delete', 'data-confirmation-title-str' => json_encode(['delete', 'core']), 'data-confirmation-yes-button-str' => json_encode(['delete', 'core']), 'data-confirmation-content-str' => json_encode([ 'confirmdelete', 'qbank_managecategories', $this->category->name, ]), ], )); } $children = []; if (!empty($this->category->children)) { foreach ($this->category->children as $child) { $childcategory = new category($child, $this->context); $children[] = $childcategory->export_for_template($output); } } $itemdata = [ 'categoryid' => $this->category->id, 'contextid' => $this->category->contextid, 'questionbankurl' => $questionbankurl->out(false), 'categoryname' => $categoryname, 'idnumber' => $idnumber, 'questioncount' => $this->category->questioncount, 'categorydesc' => $categorydesc, 'editactionmenu' => $menu->export_for_template($output), 'draghandle' => $canmanagecategory && $this->canreorder, 'haschildren' => !empty($children), 'children' => $children, 'parent' => $this->category->parent, 'sortorder' => $this->category->sortorder, 'newchildtooltip' => get_string('newchild', 'qbank_managecategories', $categoryname), ]; return $itemdata; } } PK\?MZ 3bank/managecategories/classes/output/categories.phpnu[. namespace qbank_managecategories\output; use context; use qbank_managecategories\question_categories; use renderable; use renderer_base; use templatable; /** * Output component for the Manage category page. * * This will return the template context for a page containing a tree of categories for each context in the provided * question_categories object, with editing controls. * * @package qbank_managecategories * @copyright 2024 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 categories implements renderable, templatable { /** * Constructor. * * @param question_categories $categories Question categories for display. */ public function __construct( /** @var question_categories $categories Question categories for display. */ protected question_categories $categories, ) { } #[\Override] public function export_for_template(renderer_base $output): array { $categories = []; foreach ($this->categories->editlists as $contextid => $list) { // Get list elements. $context = context::instance_by_id($contextid); $itemstab = []; if (count($list->items)) { foreach ($list->items as $item) { $category = new category($item, $context); $itemstab['items'][] = $category->export_for_template($output); } } if (isset($itemstab['items'])) { $ctxlvl = "contextlevel" . $list->context->contextlevel; $contextname = $list->context->get_context_name(); $heading = get_string('questioncatsfor', 'question', $contextname); // Get categories context. $categories[] = [ 'ctxlvl' => $ctxlvl, 'contextid' => $list->context->id, 'contextname' => $contextname, 'heading' => $heading, 'items' => $itemstab['items'], 'categoryid' => $list->categoryid, ]; } } $data = [ 'categoriesrendered' => $categories, 'contextid' => $this->categories->contextid, 'cmid' => $this->categories->cmid, 'courseid' => $this->categories->courseid, 'showdescriptions' => get_user_preferences('qbank_managecategories_showdescriptions'), ]; return $data; } } PK\B#NE,bank/managecategories/classes/navigation.phpnu[. namespace qbank_managecategories; /** * Class navigation. * * Plugin entrypoint for navigation. * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation extends \core_question\local\bank\navigation_node_base { public function get_navigation_title(): string { return get_string('categories', 'question'); } public function get_navigation_key(): string { return 'categories'; } public function get_navigation_url(): \moodle_url { return new \moodle_url('/question/bank/managecategories/category.php'); } } PK\M.ii:bank/managecategories/classes/question_category_object.phpnu[. namespace qbank_managecategories; /** * QUESTION_PAGE_LENGTH - Number of categories to display on page. * * @deprecated Since Moodle 4.5 MDL-72397. This was only used in moodle_list::display_page_numbers which is now deprecated. * @todo Final removal in Moodle 6.0 MDL-80804. */ define('QUESTION_PAGE_LENGTH', 25); use context; use moodle_exception; use moodle_url; use qbank_managecategories\form\question_category_edit_form; use question_bank; use stdClass; /** * Class for performing operations on question categories. * * @package qbank_managecategories * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager or \qbank_managecategories\question_categories. * @todo Final removal in Moodle 6.0 MDL-80804 */ class question_category_object { /** * @var array common language strings. */ public $str; /** * @var array nested lists to display categories. */ public $editlists = []; /** * @var string tab. */ public $tab; /** * @var int tab size. */ public $tabsize = 3; /** * @var moodle_url Object representing url for this page */ public $pageurl; /** * @var question_category_edit_form Object representing form for adding / editing categories. */ public $catform; /** * Constructor. * * @param int $page page number. * @param moodle_url $pageurl base URL of the display categories page. Used for redirects. * @param context[] $contexts contexts where the current user can edit categories. * @param int $currentcat id of the category to be edited. 0 if none. * @param int|null $defaultcategory id of the current category. null if none. * @param int $todelete id of the category to delete. 0 if none. * @param context[] $addcontexts contexts where the current user can add questions. * @deprecated Since Moodle 4.5 MDL-72397. Use \qbank_managecategories\question_categories or \core_question\category_manager. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\qbank_managecategories\question_categories or \core_question\category_manager', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function __construct($page, $pageurl, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts) { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $this->tab = str_repeat(' ', $this->tabsize); $this->str = new stdClass(); $this->str->course = get_string('course'); $this->str->category = get_string('category', 'question'); $this->str->categoryinfo = get_string('categoryinfo', 'question'); $this->str->questions = get_string('questions', 'question'); $this->str->add = get_string('add'); $this->str->delete = get_string('delete'); $this->str->moveup = get_string('moveup'); $this->str->movedown = get_string('movedown'); $this->str->edit = get_string('editthiscategory', 'question'); $this->str->hide = get_string('hide'); $this->str->order = get_string('order'); $this->str->parent = get_string('parent', 'question'); $this->str->add = get_string('add'); $this->str->action = get_string('action'); $this->str->top = get_string('top'); $this->str->addcategory = get_string('addcategory', 'question'); $this->str->editcategory = get_string('editcategory', 'question'); $this->str->cancel = get_string('cancel'); $this->str->editcategories = get_string('editcategories', 'question'); $this->str->page = get_string('page'); $this->pageurl = $pageurl; } /** * Initializes this classes general category-related variables * * @param int $page page number. * @param context[] $contexts contexts where the current user can edit categories. * @param int $currentcat id of the category to be edited. 0 if none. * @param int|null $defaultcategory id of the current category. null if none. * @param int $todelete id of the category to delete. 0 if none. * @param context[] $addcontexts contexts where the current user can add questions. * @deprecated Since Moodle 4.5 MDL-72397. Use \qbank_managecategories\question_categories instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\qbank_managecategories\question_categories', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function initialize($page, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $lastlist = null; foreach ($contexts as $context) { $this->editlists[$context->id] = new question_category_list('ul', '', true, $this->pageurl, $page, 'cpage', QUESTION_PAGE_LENGTH, $context); $this->editlists[$context->id]->lastlist =& $lastlist; if ($lastlist !== null) { $lastlist->nextlist =& $this->editlists[$context->id]; } $lastlist =& $this->editlists[$context->id]; } $count = 1; $paged = false; foreach ($this->editlists as $key => $list) { list($paged, $count) = $this->editlists[$key]->list_from_records($paged, $count); } $this->catform = new question_category_edit_form($this->pageurl, ['contexts' => $contexts, 'currentcat' => $currentcat ?? 0]); if (!$currentcat) { $this->catform->set_data(['parent' => $defaultcategory]); } } /** * Displays the user interface. * * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function display_user_interface(): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); // Interface for editing existing categories. $this->output_edit_lists(); } /** * Outputs a table to allow entry of a new category * * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function output_new_table(): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $this->catform->display(); } /** * Outputs a list to allow editing/rearranging of existing categories. * * $this->initialize() must have already been called * * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function output_edit_lists(): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $OUTPUT; echo $OUTPUT->heading_with_help(get_string('questioncategories', 'question'), 'editcategories', 'question'); foreach ($this->editlists as $context => $list) { $listhtml = $list->to_html(0, ['str' => $this->str]); if ($listhtml) { echo $OUTPUT->box_start('boxwidthwide boxaligncenter generalbox questioncategories contextlevel' . $list->context->contextlevel); $fullcontext = context::instance_by_id($context); echo $OUTPUT->heading(get_string('questioncatsfor', 'question', $fullcontext->get_context_name()), 3); echo $listhtml; echo $OUTPUT->box_end(); } } echo $list->display_page_numbers(); } /** * Gets all the courseids for the given categories. * * @param array $categories contains category objects in a tree representation * @return array courseids flat array in form categoryid=>courseid * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function get_course_ids(array $categories): array { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $courseids = []; foreach ($categories as $key => $cat) { $courseids[$key] = $cat->course; if (!empty($cat->children)) { $courseids = array_merge($courseids, $this->get_course_ids($cat->children)); } } return $courseids; } /** * Edit a category, or add a new one if the id is zero. * * @param int $categoryid Category id. * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function edit_single_category(int $categoryid): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); // Interface for adding a new category. global $DB; if ($categoryid) { // Editing an existing category. $category = $DB->get_record("question_categories", ["id" => $categoryid], '*', MUST_EXIST); if ($category->parent == 0) { throw new moodle_exception('cannotedittopcat', 'question', '', $categoryid); } $category->parent = "{$category->parent},{$category->contextid}"; $category->submitbutton = get_string('savechanges'); $category->categoryheader = $this->str->edit; $this->catform->set_data($category); } // Show the form. $this->catform->display(); } /** * Sets the viable parents. * * Viable parents are any except for the category itself, or any of it's descendants * The parentstrings parameter is passed by reference and changed by this function. * * @param array $parentstrings a list of parentstrings * @param object $category Category object * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function set_viable_parents(array &$parentstrings, object $category): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); unset($parentstrings[$category->id]); if (isset($category->children)) { foreach ($category->children as $child) { $this->set_viable_parents($parentstrings, $child); } } } /** * Gets question categories. * * @param int|null $parent - if given, restrict records to those with this parent id. * @param string $sort - [[sortfield [,sortfield]] {ASC|DESC}]. * @return array categories. * @deprecated Since Moodle 4.5 MDL-72397. No longer used. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'No longer used', mdl: 'MDL-72397', )] public function get_question_categories(?int $parent = null, string $sort = "sortorder ASC"): array { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $COURSE, $DB; if (is_null($parent)) { $categories = $DB->get_records('question_categories', ['course' => $COURSE->id], $sort); } else { $select = "parent = ? AND course = ?"; $categories = $DB->get_records_select('question_categories', $select, [$parent, $COURSE->id], $sort); } return $categories; } /** * Deletes an existing question category. * * @param int $categoryid id of category to delete. * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::delete_category', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function delete_category(int $categoryid): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $CFG, $DB; helper::question_can_delete_cat($categoryid); if (!$category = $DB->get_record("question_categories", ["id" => $categoryid])) { // Security. throw new moodle_exception('unknowcategory'); } // Send the children categories to live with their grandparent. $DB->set_field("question_categories", "parent", $category->parent, ["parent" => $category->id]); // Finally delete the category itself. $DB->delete_records("question_categories", ["id" => $category->id]); // Log the deletion of this category. $event = \core\event\question_category_deleted::create_from_question_category_instance($category); $event->add_record_snapshot('question_categories', $category); $event->trigger(); } /** * Move questions and then delete the category. * * @param int $oldcat id of the old category. * @param int $newcat id of the new category. * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::move_questions_and_delete_category', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function move_questions_and_delete_category(int $oldcat, int $newcat): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); helper::question_can_delete_cat($oldcat); $this->move_questions($oldcat, $newcat); $this->delete_category($oldcat); } /** * Display the form to move a category. * * @param int $questionsincategory * @param object $category * @throws \coding_exception * * @deprecated No longer used by internal code and not recommended since Moodle 4.2 MDL-77299. */ public function display_move_form($questionsincategory, $category): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); debugging( 'display_move_form() is deprecated and no longer used by internal code.', DEBUG_DEVELOPER ); global $OUTPUT; $vars = new stdClass(); $vars->name = $category->name; $vars->count = $questionsincategory; echo $OUTPUT->box(get_string('categorymove', 'question', $vars), 'generalbox boxaligncenter'); $this->moveform->display(); } /** * Move questions to another category. * * @param int $oldcat id of the old category. * @param int $newcat id of the new category. * @throws \dml_exception * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::move_questions', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function move_questions(int $oldcat, int $newcat): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $questionids = $this->get_real_question_ids_in_category($oldcat); question_move_questions_to_category($questionids, $newcat); } /** * Create a new category. * * Data is expected to come from question_category_edit_form. * * By default redirects on success, unless $return is true. * * @param string $newparent 'categoryid,contextid' of the parent category. * @param string $newcategory the name. * @param string $newinfo the description. * @param bool $return if true, return rather than redirecting. * @param int|string $newinfoformat description format. One of the FORMAT_ constants. * @param null $idnumber the idnumber. '' is converted to null. * @return bool|int New category id if successful, else false. * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::add_category', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function add_category($newparent, $newcategory, $newinfo, $return = false, $newinfoformat = FORMAT_HTML, $idnumber = null): int { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $DB; if (empty($newcategory)) { throw new moodle_exception('categorynamecantbeblank', 'question'); } list($parentid, $contextid) = explode(',', $newparent); // ...moodle_form makes sure select element output is legal no need for further cleaning. require_capability('moodle/question:managecategory', context::instance_by_id($contextid)); if ($parentid) { if (!($DB->get_field('question_categories', 'contextid', ['id' => $parentid]) == $contextid)) { throw new moodle_exception('cannotinsertquestioncatecontext', 'question', '', ['cat' => $newcategory, 'ctx' => $contextid]); } } if ((string) $idnumber === '') { $idnumber = null; } else if (!empty($contextid)) { // While this check already exists in the form validation, this is a backstop preventing unnecessary errors. if ($DB->record_exists('question_categories', ['idnumber' => $idnumber, 'contextid' => $contextid])) { $idnumber = null; } } $cat = new stdClass(); $cat->parent = $parentid; $cat->contextid = $contextid; $cat->name = $newcategory; $cat->info = $newinfo; $cat->infoformat = $newinfoformat; $cat->sortorder = 999; $cat->stamp = make_unique_id_code(); $cat->idnumber = $idnumber; $categoryid = $DB->insert_record("question_categories", $cat); // Log the creation of this category. $category = new stdClass(); $category->id = $categoryid; $category->contextid = $contextid; $event = \core\event\question_category_created::create_from_question_category_instance($category); $event->trigger(); if ($return) { return $categoryid; } else { // Always redirect after successful action. redirect($this->pageurl); } } /** * Updates an existing category with given params. * * Warning! parameter order and meaning confusingly different from add_category in some ways! * * @param int $updateid id of the category to update. * @param int $newparent 'categoryid,contextid' of the parent category to set. * @param string $newname category name. * @param string $newinfo category description. * @param int|string $newinfoformat description format. One of the FORMAT_ constants. * @param int $idnumber the idnumber. '' is converted to null. * @param bool $redirect if true, will redirect once the DB is updated (default). * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::update_category', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function update_category($updateid, $newparent, $newname, $newinfo, $newinfoformat = FORMAT_HTML, $idnumber = null, $redirect = true): void { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $CFG, $DB; if (empty($newname)) { throw new moodle_exception('categorynamecantbeblank', 'question'); } // Get the record we are updating. $oldcat = $DB->get_record('question_categories', ['id' => $updateid]); $lastcategoryinthiscontext = helper::question_is_only_child_of_top_category_in_context($updateid); if (!empty($newparent) && !$lastcategoryinthiscontext) { list($parentid, $tocontextid) = explode(',', $newparent); } else { $parentid = $oldcat->parent; $tocontextid = $oldcat->contextid; } // Check permissions. $fromcontext = context::instance_by_id($oldcat->contextid); require_capability('moodle/question:managecategory', $fromcontext); // If moving to another context, check permissions some more, and confirm contextid,stamp uniqueness. $newstamprequired = false; if ($oldcat->contextid != $tocontextid) { $tocontext = context::instance_by_id($tocontextid); require_capability('moodle/question:managecategory', $tocontext); // Confirm stamp uniqueness in the new context. If the stamp already exists, generate a new one. if ($DB->record_exists('question_categories', ['contextid' => $tocontextid, 'stamp' => $oldcat->stamp])) { $newstamprequired = true; } } if ((string) $idnumber === '') { $idnumber = null; } else if (!empty($tocontextid)) { // While this check already exists in the form validation, this is a backstop preventing unnecessary errors. if ($DB->record_exists_select('question_categories', 'idnumber = ? AND contextid = ? AND id <> ?', [$idnumber, $tocontextid, $updateid])) { $idnumber = null; } } // Update the category record. $cat = new stdClass(); $cat->id = $updateid; $cat->name = $newname; $cat->info = $newinfo; $cat->infoformat = $newinfoformat; $cat->parent = $parentid; $cat->contextid = $tocontextid; $cat->idnumber = $idnumber; if ($newstamprequired) { $cat->stamp = make_unique_id_code(); } $DB->update_record('question_categories', $cat); // Update the set_reference records when moving a category to a different context. move_question_set_references($cat->id, $cat->id, $oldcat->contextid, $tocontextid); // Log the update of this category. $event = \core\event\question_category_updated::create_from_question_category_instance($cat); $event->trigger(); if ($oldcat->contextid != $tocontextid) { // Moving to a new context. Must move files belonging to questions. question_move_category_to_context($cat->id, $oldcat->contextid, $tocontextid); } // Cat param depends on the context id, so update it. $this->pageurl->param('cat', $updateid . ',' . $tocontextid); if ($redirect) { // Always redirect after successful action. redirect($this->pageurl); } } /** * Returns ids of the question in the given question category. * * This method only returns the real question. It does not include * subquestions of question types like multianswer. * * @param int $categoryid id of the category. * @return int[] array of question ids. * @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead. * @todo Final removal in Moodle 6.0 MDL-80804 */ #[\core\attribute\deprecated( '\core_question\category_manager::get_real_question_ids_in_category', since: 4.5, reason: 'API properly divided between qbank_managecategories and core_question', mdl: 'MDL-72397', )] public function get_real_question_ids_in_category(int $categoryid): array { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); global $DB; $sql = "SELECT q.id 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 = :categoryid AND (q.parent = 0 OR q.parent = q.id)"; $questionids = $DB->get_records_sql($sql, ['categoryid' => $categoryid]); return array_keys($questionids); } } PK\b0bank/managecategories/classes/plugin_feature.phpnu[. namespace qbank_managecategories; use core_question\local\bank\navigation_node_base; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class plugin_feature. * * Entry point for qbank plugin. * Every qbank plugin must extent this class. * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { public function get_navigation_node(): ?navigation_node_base { return new navigation(); } public function get_question_filters(?view $qbank = null): array { return [ new category_condition($qbank), ]; } } PK\q6mgg;bank/managecategories/templates/move_category_list.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/move_category_list Partial to render a list of categories that the current category can be moved next to. This partial is included recursively to render nested lists. Take care to pass categories = null for bottom-level categories, to avoid infinite recursion. Context variables required for this template: * categories - Array of category objects. * categoryname - The name of the category to move to. * categoryid - The ID of the category. * firstchild - Is this the first child of its parent? If so, a "before" option will be rendered as well as "after". * current - Is this the category being moved? If so, skip rendering it as a target. * categories - Array containing child categories of this category. If there are none, this must be set null or [] to avoid infinite recursion. Example context (json): { "categories": [ { "categoryname": "Default category for course 1", "categoryid": 3, "firstchild": true, "current": false, "hascategories": false, "categories": [] }, { "categoryname": "Test category 1", "categoryid": 4, "firstchild": false, "current": false, "hascategories": false, "categories": [] }, { "categoryname": "Test category 2", "categoryid": 5, "firstchild": false, "current": true, "hascategories": false, "categories": [] }, { "categoryname": "Test category 3 x < 1 && y > 2 ", "categoryid": 6, "firstchild": false, "current": false, "hascategories": true, "categories": [ { "categoryname": "Test category 4", "categoryid": 7, "firstchild": true, "current": false, "hascategories": false, "categories": [] } ] } ] } }} PK\C1bank/managecategories/templates/newchild.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/newchild Drop zone for creating a new child category. Context variables required for this template: * categoryid - The id of the parent category. Example context (json): { "categoryid": "1", "newchildtooltip": "As new child of Example" } }} + {{#js}} require(['qbank_managecategories/newchild'], function(component) { component.init('.qbank_managecategories-newchild[data-parent="{{categoryid}}"]'); }); {{/js}} PK\W##4bank/managecategories/templates/addcategory.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/addcategory Add category button Context variables required for this template: * contextid - Context id for js init. * cmid - Course module id, if in a plugin. * courseid - The current course ID. Example context (json): { "contextid": "18", "cmid": "1234", "courseid": "1234" } }}
{{#js}} require(['qbank_managecategories/addcategory'], (addcategory) => { addcategory.init('#qbank_managecategories-addcategory'); }); {{/js}} PK\UTGbank/managecategories/templates/include_subcategories_checkbox.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/include_subcategories Example context (json): { "displaydata": [ { "categoryselect": "html string", "categorydesc": "html string" } ] } }} PK\IR% % 9bank/managecategories/templates/category_details.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/category_details Partial template for rendering category details. Name, question count, tags, description, etc. Context variables required for this template: * categoryid - The id of the category this item represents. * questionbankurl - Url for corresponding question bank. * categoryname - Category name. Raw string. Will be HTML-escaped during rendering. * idnumber - Id number string (optional). * questioncount - Number of question in question bank. * categorydesc - Category description. HTML that is safe to output (e.g. from format_text). * editactionmenu - Edit action menu link. * draghandle - Boolean if handle should be displayed (if category is movable). * children - Children category item to curent category (same variables as those above). Example context (json): { "categoryid": "1", "questionbankurl": "question/edit.php?cmid=123", "categoryname": "Default for Miscellaneous & < > \" '", "idnumber": "1", "questioncount": " 1", "categorydesc": "The default category for questions shared in context Miscellaneous", "children": "", "newchildtooltip": "New child of Default for Miscellaneous" } }} {{categoryname}} {{#idnumber}} {{#str}}idnumber, question{{/str}} {{idnumber}} {{/idnumber}} ({{questioncount}}) {{^children}} {{> qbank_managecategories/newchild }} {{/children}}
{{{categorydesc}}}
PK\>=||8bank/managecategories/templates/choose_category.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/choose_category Example context (json): { } }}

{{#str}}selectcategoryabove, question{{/str}}

PK\̳N:bank/managecategories/templates/categories_header.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/categories_header Template for displaying category view. Context variables required for this template: * helpstringhead - String header accompanied with it help button. * hascapability - Boolean, true if user has capability to add categories. * checkbox - Checkbox displaying descriptions. * contextid - Context id for js init. * cmid - Course module id, if in a plugin. Example context (json): { "helpstringhead": "

Edit categories ...

", "hascapability": true, "checkbox": "
...
", "contextid": "18", "cmid": "1234", "courseid": "1234" } }}
{{{helpstringhead}}}
{{#hascapability}} {{>qbank_managecategories/addcategory}} {{/hascapability}} {{#showdescriptions}} {{>qbank_managecategories/showdescriptions}} {{/showdescriptions}}
PK\[Vu u 3bank/managecategories/templates/categories.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/categories Template for displaying category view. Context variables required for this template: * contextid - Context id for js init. * categoriesrendered - Array representing categories data rendered. * items - Categories data. * heading - Category's heading for each context (ie: Course or Quiz). * ctxlvl - Context level for categories section - context. Example context (json): { "contextid": "18", "categoriesrendered": [{ "items": [{ "categoryid": "1", "questionbankurl": "question/edit.php?cmid=123", "categoryname": "Default for Miscellaneous", "idnumber": "1", "questioncount": " (1)", "categorydesc": "The default category for questions shared in context Miscellaneous", "editactionmenu": "
...
", "handle": true, "iconleft": "", "iconright": "", "children": "" }], "heading": "Question categories for 'Quiz: qz'", "ctxlvl": "contextlevel70" }] } }}
{{#categoriesrendered}}

{{heading}}

    {{#items}} {{> qbank_managecategories/category }} {{/items}}
{{/categoriesrendered}}
{{#js}} require(['qbank_managecategories/categoryroot'], (categoryroot) => { categoryroot.init('#categoryroot'); }); require(['qbank_managecategories/categorylist'], (categorylist) => { // Initialise top-level lists. const categoryLists = document.querySelectorAll('.qbank_managecategories-categorylist[data-contextid]'); categoryLists.forEach(element => { categorylist.init(`.qbank_managecategories-categorylist[data-categoryid="${element.dataset.categoryid}"]`); }); }); {{/js}} PK\^* :bank/managecategories/templates/move_context_list.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/move_context_list Modal to move a category. Context variables required for this template: * contexts - Array of contexts containing question categories * contextid - The id of the context. * contextname - The name of the context to display as a heading. * categories - Array of category objects. See qbank_managecategories/move_category_list partial for details. Example context (json): { "contexts": [ { "contexid": 1, "contextname": "Course 1", "categories": [ { "categoryname": "Default category for course 1", "categoryid": 3, "firstchild": true, "current": false, "hascategories": false, "categories": [] }, { "categoryname": "Default category for course category 1", "categoryid": 4, "firstchild": false, "current": true, "hascategories": false, "categories": [] } ] }, { "contexid": 3, "contextname": "Course 2", "categories": [ { "categoryname": "Default category for course 2", "categoryid": 5, "firstchild": true, "current": false, "hascategories": false, "categories": [] }, { "categoryname": "Default category for course category 2", "categoryid": 6, "firstchild": false, "current": false, "hascategories": false, "categories": [] } ] } ] } }}
{{#contexts}}

{{{contextname}}}

{{>qbank_managecategories/move_category_list}} {{/contexts}}
PK\.9bank/managecategories/templates/showdescriptions.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/showdescriptions Template for displaying the Show descriptions toggle. Context variables required for this template: * helpstringhead - String header accompanied with it help button. * hascapability - Boolean, true if user has capability to add categories. * checkbox - Checkbox displaying descriptions. * contextid - Context id for js init. * cmid - Course module id, if in a plugin. Example context (json): { "id": "showdescriptions-toggle", "label": "Show descriptions", "checked": true } }}
{{>core/toggle}}
{{#js}} require(['qbank_managecategories/showdescriptions'], function(component) { component.init('#qbank_managecategories-showdescriptions'); }); {{/js}} PK\1bank/managecategories/templates/category.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/category This template renders the list item for each category. Context variables required for this template: * categoryid - The id of the category this item represents. * questionbankurl - Url for corresponding question bank. * categoryname - Category name. Raw string. Will be HTML-escaped during rendering. * idnumber - Id number string (optional). * questioncount - Number of question in question bank. * categorydesc - Category description. HTML that is safe to output (e.g. from format_text). * editactionmenu - Edit action menu link. * draghandle - Boolean if handle should be displayed (if category is movable). * children - Children category item to curent category (same variables as those above). Example context (json): { "categoryid": "1", "questionbankurl": "question/edit.php?cmid=123", "categoryname": "Default for Miscellaneous & < > \" '", "idnumber": "1", "questioncount": " 1", "categorydesc": "The default category for questions shared in context Miscellaneous", "editactionmenu": "
...
", "draghandle": true, "children": "" } }}
  • {{>qbank_managecategories/category_details}}
    {{#editactionmenu}} {{>core/action_menu}} {{/editactionmenu}}
    {{#haschildren}} {{> qbank_managecategories/childlist }} {{/haschildren}}
  • {{#js}} require(['qbank_managecategories/category'], function(component) { component.init('#category-{{{categoryid}}}'); }); {{/js}} PK\2bank/managecategories/templates/childlist.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_managecategories/childlist This template a sub-list of children within a category. Context variables required for this template: * categoryid - The parent category of the child categories. * children - Children category item to curent category (same variables as qbank_managecategories/category). Example context (json): { "categoryid": "1", "children": [ { "categoryid": "2", "questionbankurl": "question/edit.php?cmid=123", "categoryname": "Default for Miscellaneous", "idnumber": "1", "questioncount": " 1", "categorydesc": "The default category for questions shared in context Miscellaneous", "editactionmenu": "
    ...
    ", "draghandle": true } ] } }}
      {{#children}} {{> qbank_managecategories/category }} {{/children}}
    {{#js}} require(['qbank_managecategories/categorylist'], function(component) { component.init('.qbank_managecategories-categorylist[data-categoryid="{{categoryid}}"]'); }); {{/js}} PK\&M'bank/managecategories/amd/src/events.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . import {dispatchEvent} from 'core/event_dispatcher'; /** * Reactive events for qbank_managecategories * * @module qbank_managecategories/events * */ export const eventTypes = { /** * Event triggered when the activity reactive state is updated. * * @event qbankManagecategoriesStateUpdated * @type {CustomEvent} * @property {Array} nodes The list of parent nodes which were updated */ qbankManagecategoriesStateUpdated: 'qbank_managecategories/stateUpdated', }; /** * Trigger an event to indicate that the activity state is updated. * * @method notifyQbankManagecategoriesStateUpdated * @param {Object} detail the full state * @param {HTMLElement} container the custom event target (document if none provided) * @returns {CustomEvent} * @fires qbankManagecategoriesStateUpdated */ export const notifyQbankManagecategoriesStateUpdated = (detail, container) => { return dispatchEvent(eventTypes.qbankManagecategoriesStateUpdated, detail, container); }; PK\O -bank/managecategories/amd/src/categorylist.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The category list component. * * The category list is a drop target, so that a category may be dropped at the top or bottom of the list. * * @module qbank_managecategories/categorylist * @class qbank_managecategories/categorylist */ import {BaseComponent, DragDrop} from 'core/reactive'; import Templates from 'core/templates'; import {getString} from 'core/str'; import {categorymanager} from 'qbank_managecategories/categorymanager'; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.selectors = { CATEGORY_LIST: '.qbank_managecategories-categorylist', CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]', CATEGORY_CONTENTS: '.qbank_managecategories-item > .container', CATEGORY_DETAILS: '.qbank_managecategories-details', CATEGORY_NO_DRAGHANDLE: '.qbank_managecategories-item[data-categoryid]:not(.draghandle)', CATEGORY_ID: id => `#category-${id}`, }; this.classes = { DROP_TARGET_BEFORE: 'qbank_managecategories-droptarget-before', DROP_TARGET: 'qbank_managecategories-droptarget', NO_BOTTOM_PADDING: 'pb-0', }; this.ids = { CATEGORY: id => `category-${id}`, }; } stateReady() { this.dragdrop = new DragDrop(this); } destroy() { // The draggable element must be unregistered. if (this.dragdrop !== undefined) { this.dragdrop.unregister(); this.dragdrop = undefined; } } /** * Static method to create a component instance. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { return new this({ element: document.querySelector(target), selectors, reactive: categorymanager, }); } validateDropData() { return true; } /** * Highlight the border of the list where the category will be moved. * * If dropping at the top of the list, highlight the top border. * If dropping at the bottom, highlight the bottom border. * * @param {Object} dropData * @param {Event} event */ showDropZone(dropData, event) { const dropTarget = this.getElement(); if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) { // Can't drop onto its own child. return false; } if (this.getInsertBefore(event, dropTarget)) { dropTarget.classList.add(this.classes.DROP_TARGET_BEFORE); dropTarget.classList.remove(this.classes.DROP_TARGET); } else { dropTarget.classList.add(this.classes.DROP_TARGET); dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE); } return true; } /** * Remove highlighting. * * @param {Object} dropData * @param {Event} event */ hideDropZone(dropData, event) { const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST); dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE); dropTarget.classList.remove(this.classes.DROP_TARGET); } /** * Determine whether we're dragging over the top or bottom half of the list. * * @param {Event} event * @param {Element} dropTarget * @return {boolean} */ getInsertBefore(event, dropTarget) { // Get the current mouse position within the drop target const mouseY = event.clientY - dropTarget.getBoundingClientRect().top; // Get the height of the drop target const targetHeight = dropTarget.clientHeight; // Check if the mouse is over the top half of the drop target return mouseY < targetHeight / 2; } /** * Find the new position of the dropped category, and trigger the move. * * @param {Object} dropData * @param {Event} event */ drop(dropData, event) { const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST); if (!dropTarget) { return; } if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) { // Can't drop onto your own child. return; } const source = document.getElementById(this.ids.CATEGORY(dropData.id)); if (!source) { return; } const targetParentId = dropTarget.dataset.categoryid; let precedingSibling; if (this.getInsertBefore(event, dropTarget)) { // Dropped at the top of the list. precedingSibling = null; } else { // Dropped at the bottom of the list. precedingSibling = dropTarget.lastElementChild; } // Insert the category after the target category categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid); } /** * Watch for categories moving to a new parent. * * @return {Array} A list of watchers. */ getWatchers() { return [ // Watch for this category having its child count updated. {watch: `categoryLists[${this.element.dataset.categoryid}].childCount:updated`, handler: this.checkEmptyList}, // Watch for any new category being created. {watch: `categories:created`, handler: this.addCategory}, ]; } /** * If this list is now empty, remove it. * * @param {Object} args * @param {Object} args.element The categoryList state element. */ async checkEmptyList({element}) { if (element.childCount === 0) { // Display a new child drop zone. const categoryItem = this.getElement().closest(this.selectors.CATEGORY_ITEM); const {html, js} = await Templates.renderForPromise( 'qbank_managecategories/newchild', { categoryid: this.getElement().dataset.categoryid, tooltip: getString('newchild', 'qbank_managecategories', categoryItem.dataset.categoryname) } ); const activityNameArea = categoryItem.querySelector(this.selectors.CATEGORY_DETAILS); await Templates.appendNodeContents(activityNameArea, html, js); // Reinstate padding on the parent element. this.element.closest(this.selectors.CATEGORY_CONTENTS).classList.remove(this.classes.NO_BOTTOM_PADDING); // Remove this list. this.remove(); } } /** * If a newly-created category has this list's category as its parent, add it to this list. * * @param {Object} args * @param {Object} args.element * @return {Promise} */ async addCategory({element}) { if (element.parent !== this.getElement().dataset.categoryid) { return; // Not for me. } const {html, js} = await Templates.renderForPromise('qbank_managecategories/category', element.templatecontext); Templates.appendNodeContents(this.getElement(), html, js); // If one of the children has no draghandle, it should do now it has a sibling. const noDragHandle = this.getElement(this.selectors.CATEGORY_NO_DRAGHANDLE); if (noDragHandle) { this.reactive.dispatch('showDragHandle', noDragHandle.dataset.categoryid); } } } PK\*0  -bank/managecategories/amd/src/categoryroot.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The category root component. * * @module qbank_managecategories/categoryroot * @class qbank_managecategories/categoryroot */ import {BaseComponent} from 'core/reactive'; import {categorymanager} from 'qbank_managecategories/categorymanager'; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.classes = { SHOWDESCRIPTIONS: 'showdescriptions', }; } /** * Static method to create a component instance. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { return new this({ element: document.querySelector(target), selectors, reactive: categorymanager, }); } /** * Watch for changes to the page state. * * @return {Array} A list of watchers. */ getWatchers() { return [ // Watch for descriptions being toggled. {watch: `page.showdescriptions:updated`, handler: this.toggleDescriptions} ]; } /** * Show or hide descriptions when the flag in the state is changed. * * @param {Object} args * @param {Object} args.element The updated page state. */ toggleDescriptions({element}) { if (element.showdescriptions) { this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS); } else { this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS); } } } PK\rBB1bank/managecategories/amd/src/showdescriptions.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The show descriptions toggle component. * * @module qbank_managecategories/showdescriptions * @class qbank_managecategories/showdescriptions */ import {BaseComponent} from 'core/reactive'; import {categorymanager} from 'qbank_managecategories/categorymanager'; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.selectors = { TOGGLE: '#showdescriptions-toggle', }; } stateReady() { this.addEventListener(this.getElement(this.selectors.TOGGLE), 'change', this.updateShowDescriptions); } /** * Static method to create a component instance. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { const targetElement = document.querySelector(target); return new this({ element: targetElement, selectors, reactive: categorymanager, }); } /** * Dispatch a mutation to toggle the showDescriptions setting. * * @param {Event} event The toggle change event. * @return {Promise} */ async updateShowDescriptions(event) { const checked = event.target.checked; this.reactive.dispatch('toggleDescriptions', checked); } } PK\J`PP0bank/managecategories/amd/src/categorymanager.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Reactive module for category manager * * @module qbank_managecategories/categorymanager */ import {Reactive} from 'core/reactive'; import {get_string as getString} from 'core/str'; import {mutations} from 'qbank_managecategories/mutations'; import {eventTypes, notifyQbankManagecategoriesStateUpdated} from 'qbank_managecategories/events'; import Ajax from "core/ajax"; import Notification from "core/notification"; import ModalForm from 'core_form/modalform'; const SELECTORS = { CATEGORY_LIST: '.qbank_managecategories-categorylist', CONTEXT: '.qbank_managecategories-categorylist[data-contextid]', CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]', CATEGORY_ROOT: '#categoryroot', SHOWDESCRIPTIONS_TOGGLE: '#showdescriptions-toggle', ADD_EDIT_BUTTON: '[data-action="addeditcategory"]', }; const CLASSES = { DRAGHANDLE: 'draghandle', DANGER: 'alert-danger', }; /** * Load the initial state. * * This iterates over the initial tree of category items, and captures the data required for the state from each category. * It also captures a count of the number of children in each list. * * @param {Reactive} reactive * @return {Promise} */ const loadState = async(reactive) => { const rootElement = document.querySelector(SELECTORS.CATEGORY_ROOT); const stateData = { page: { contextid: rootElement.dataset.contextid, showdescriptions: document.querySelector(SELECTORS.SHOWDESCRIPTIONS_TOGGLE).checked, }, categories: [], categoryLists: [], }; const listItems = document.querySelectorAll(SELECTORS.CATEGORY_ITEM); listItems.forEach(item => { stateData.categories.push({ id: item.dataset.categoryid, name: item.dataset.categoryname, parent: item.dataset.parent, contextid: item.dataset.contextid, sortorder: item.dataset.sortorder, draghandle: item.classList.contains(CLASSES.DRAGHANDLE), }); }); const categoryLists = document.querySelectorAll(SELECTORS.CATEGORY_LIST); categoryLists.forEach(categoryList => { stateData.categoryLists.push({ id: categoryList.dataset.categoryid, childCount: categoryList.querySelectorAll(SELECTORS.CATEGORY_ITEM).length, }); }); reactive.setInitialState(stateData); }; /** * Reactive instance for the category manager. */ class CategoryManager extends Reactive { /** * Move a category to a new position within the given parent. * * This will call the move_category web service function to re-order the categories, then update * the state with the returned updates. * * @param {Number} categoryId The ID of the category being moved. * @param {Number} targetParentId The ID of the destination parent category (this may not have changed). * @param {Number} precedingSiblingId The ID of the category to put the moved category after. * This may be null if moving to the top of a list. */ moveCategory( categoryId, targetParentId, precedingSiblingId = null, ) { const call = { methodname: 'qbank_managecategories_move_category', args: { pagecontextid: this.state.page.contextid, categoryid: categoryId, targetparentid: targetParentId, precedingsiblingid: precedingSiblingId, } }; Ajax.call([call])[0] .then((stateUpdates) => { this.stateManager.processUpdates(stateUpdates); return stateUpdates; }) .catch(error => { Notification.addNotification({ message: error.message, type: 'error', }); document.getElementsByClassName(CLASSES.DANGER)[0]?.scrollIntoView(); }); } /** * Return title for the add/edit modal. * * @param {boolean} isEdit is 'add' or 'edit' form * @returns {String} title string */ getTitle(isEdit) { return getString(isEdit ? 'editcategory' : 'addcategory', 'question'); } /** * Return save button label for the add/edit modal. * * @param {boolean} isEdit is 'add' or 'edit' form * @returns {String} save string */ getSave(isEdit) { return isEdit ? getString('savechanges', 'core') : getString('addcategory', 'question'); } /** * Function handling display of modal form. * * @param {Event} e The click event triggering the modal. */ showEditModal(e) { const addEditButton = e.target.closest(SELECTORS.ADD_EDIT_BUTTON); // Return if it is not 'addeditcategory' button. if (!addEditButton) { return; } // Return if the action type is not specified. if (!addEditButton.dataset.actiontype) { return; } e.preventDefault(); // Data for the modal. const title = categorymanager.getTitle(addEditButton.dataset.actiontype === 'edit'); const save = categorymanager.getSave(addEditButton.dataset.actiontype === 'edit'); const cmid = addEditButton.dataset.cmid; const courseid = addEditButton.dataset.courseid; const questioncount = addEditButton.dataset.questioncount; let contextid = addEditButton.dataset.contextid; let categoryid = null; let sortorder = null; let parent = null; const categoryItem = e.target.closest(SELECTORS.CATEGORY_ITEM); if (categoryItem) { contextid = categoryItem.dataset.contextid; categoryid = categoryItem.dataset.categoryid; sortorder = categoryItem.dataset.sortorder; const parentContext = categoryItem.closest(SELECTORS.CONTEXT); parent = categoryItem.dataset.parent + ',' + parentContext.dataset.contextid; } // Call the modal. const modalForm = new ModalForm({ formClass: "qbank_managecategories\\form\\question_category_edit_form", args: { cmid, courseid, questioncount, contextid, categoryid, sortorder, parent, }, modalConfig: { title: title, large: true, }, saveButtonText: save, returnFocus: addEditButton, }); // Once the form has been submitted via the web service, update the state with the new or updated // category based on the web service response. modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (response) => { categorymanager.stateManager.processUpdates(response.detail); }); // Show the form. modalForm.show(); } } export const categorymanager = new CategoryManager({ name: 'qtype_managecategories_categorymanager', eventName: eventTypes.qbankManagecategoriesStateUpdated, eventDispatch: notifyQbankManagecategoriesStateUpdated, mutations, }); /** * Load the initial state. */ export const init = () => { loadState(categorymanager); }; PK\((*bank/managecategories/amd/src/mutations.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . import {setUserPreference} from 'core_user/repository'; /** * Default mutation manager * * @module qbank_managecategories/mutations */ class Mutations { /** * Set the showdescriptions property to true or false in the page's state. * * @param {stateManager} stateManager * @param {boolean} showDescriptions * @return {Promise} */ async toggleDescriptions(stateManager, showDescriptions) { stateManager.setReadOnly(false); await setUserPreference('qbank_managecategories_showdescriptions', showDescriptions); stateManager.state.page.showdescriptions = showDescriptions; stateManager.setReadOnly(true); } /** * Set the draghandle property to true in a given category's state. * * @param {stateManager} stateManager * @param {Number} categoryId * @return {Promise} */ async showDragHandle(stateManager, categoryId) { stateManager.setReadOnly(false); stateManager.state.categories.get(categoryId).draghandle = true; stateManager.setReadOnly(true); } } export const mutations = new Mutations(); PK\*hh,bank/managecategories/amd/src/addcategory.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Add category button for displaying the modal form. * * This just connects up the button to the showEditModal listener. * * @module qbank_managecategories/addcategory * @class qbank_managecategories/addcategory */ import {BaseComponent} from 'core/reactive'; import {categorymanager} from 'qbank_managecategories/categorymanager'; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.selectors = { ADD_BUTTON: '[data-action="addeditcategory"]', }; } stateReady() { this.addEventListener(this.getElement(this.selectors.ADD_BUTTON), 'click', categorymanager.showEditModal); } /** * Static method to create a component instance form the mustache template. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { const targetElement = document.querySelector(target); return new this({ element: targetElement, selectors, reactive: categorymanager, }); } } PK\y;"F"F)bank/managecategories/amd/src/category.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The category component. * * @module qbank_managecategories/category * @class qbank_managecategories/category */ import {BaseComponent, DragDrop} from 'core/reactive'; import {categorymanager} from 'qbank_managecategories/categorymanager'; import Templates from 'core/templates'; import Modal from "core/modal"; import {get_string as getString} from "core/str"; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.selectors = { CATEGORY_LIST: '.qbank_managecategories-categorylist', CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]', CATEGORY_CONTENTS: '.qbank_managecategories-item > .container', EDIT_BUTTON: '[data-action="addeditcategory"]', MOVE_BUTTON: '[role="menuitem"][data-actiontype="move"]', CONTEXT: '.qbank_managecategories-categorylist[data-contextid]', MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]', CONTENT_AREA: '.qbank_managecategories-details', CATEGORY_ID: id => `#category-${id}`, CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`, CHILD_LIST: id => `ul[data-categoryid="${id}"]`, PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`, }; this.classes = { NO_BOTTOM_PADDING: 'pb-0', DRAGHANDLE: 'draghandle', DROPTARGET: 'qbank_managecategories-droptarget-before', }; this.ids = { CATEGORY: id => `category-${id}`, }; } stateReady() { this.initDragDrop(); this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal); const moveButton = this.getElement(this.selectors.MOVE_BUTTON); this.addEventListener(moveButton, 'click', this.showMoveModal); } destroy() { // The draggable element must be unregistered. this.deInitDragDrop(); } /** * Remove any existing DragDrop component, and create a new one. */ initDragDrop() { this.deInitDragDrop(); // If the element is currently draggable, register the getDraggableData method. if (this.element.classList.contains(this.classes.DRAGHANDLE)) { this.getDraggableData = this._getDraggableData; } this.dragdrop = new DragDrop(this); } /** * If the DragDrop component is currently registered, unregister it. */ deInitDragDrop() { if (this.dragdrop !== undefined) { if (this.getDraggableData !== undefined) { this.dragdrop.setDraggable(false); this.getDraggableData = undefined; } this.dragdrop.unregister(); this.dragdrop = undefined; } } /** * Static method to create a component instance. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { return new this({ element: document.querySelector(target), selectors, reactive: categorymanager, }); } /** * Return the category ID from the component's element. * * This method is referenced as getDraggableData when the component can be dragged. * * @return {{id: string}} * @private */ _getDraggableData() { return { id: this.getElement().dataset.categoryid }; } validateDropData() { return true; } /** * Highlight the top border of the category item. * * @param {Object} dropData */ showDropZone(dropData) { if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) { // Can't drop onto itself or its own child. return false; } this.getElement().classList.add(this.classes.DROPTARGET); return true; } /** * Remove highlighting. */ hideDropZone() { this.getElement().classList.remove(this.classes.DROPTARGET); } /** * Find the new position of the dropped category, and trigger the move. * * @param {Object} dropData The category being moved. * @param {Event} event The drop event. */ drop(dropData, event) { const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM); if (!dropTarget) { return; } if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) { // Can't drop onto your own child. return; } const source = document.getElementById(this.ids.CATEGORY(dropData.id)); if (!source) { return; } const targetParentId = dropTarget.dataset.parent; const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST); let precedingSibling; if (dropTarget === parentList.firstElementChild) { // Dropped at the top of the list. precedingSibling = null; } else { precedingSibling = dropTarget.previousElementSibling; } // Insert the category after the target category categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid); } getWatchers() { return [ // After any update to this category, move it to the new position. {watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition}, // When the template context is added or updated, re-render the content. {watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender}, {watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender}, // When a new category is created, check whether we need to add a child list to this category. {watch: `categories:created`, handler: this.checkChildList}, ]; } /** * Re-render the category content. * * @param {Object} args * @param {Element} args.element * @return {Promise} */ async rerender({element}) { const {html, js} = await Templates.renderForPromise( 'qbank_managecategories/category_details', element.templatecontext ); return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js); } /** * Render and append a new child list. * * @param {Object} context Template context, must include at least categoryid. * @return {Promise} */ async createChildList(context) { const {html, js} = await Templates.renderForPromise( 'qbank_managecategories/childlist', context, ); const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid)); await Templates.appendNodeContents(parentContainer, html, js); const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid)); childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING); return childList; } /** * Move a category to its new position. * * A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those * changes and move the element to the new position. If the parent doesn't already have a child list, one will be created. * * If the parent has changed, this will also update the state with the new child count of the old and new parents. * * @param {Object} args * @param {Object} args.element * @return {Promise} */ async updatePosition({element}) { // Move to a new parent category. let newParent; const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent)); if (parseInt(this.getElement().dataset.parent) !== element.parent) { newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent)); if (!newParent) { // The target category doesn't have a child list yet. We'd better create one. newParent = await this.createChildList({categoryid: element.parent}); } this.getElement().dataset.parent = element.parent; } else { newParent = this.getElement().parentElement; } // Move to a new position within the parent. let previousSibling; let nextSibling; if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) { // Move to the top of the list. nextSibling = newParent.firstElementChild; } else { // Move later in the list. previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1)); nextSibling = previousSibling?.nextElementSibling; } // Check if this has actually moved, or if it's just having its sortorder updated due to another element moving. const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement()); if (moved) { if (nextSibling) { // Move to the specified position in the list. newParent.insertBefore(this.getElement(), nextSibling); } else { // Move to the end of the list (may also be the top of the list is empty). newParent.appendChild(this.getElement()); } } if (originParent !== newParent) { // Update child count of old and new parent. this.reactive.stateManager.processUpdates([ { name: 'categoryLists', action: 'put', fields: { id: originParent.dataset.categoryid, childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length } }, { name: 'categoryLists', action: 'put', fields: { id: newParent.dataset.categoryid, childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length } } ]); } this.element.dataset.sortorder = element.sortorder; // Enable/disable dragging. const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE); if (isDraggable && !element.draghandle) { this.element.classList.remove(this.classes.DRAGHANDLE); this.initDragDrop(); } else if (!isDraggable && element.draghandle) { this.element.classList.add(this.classes.DRAGHANDLE); this.initDragDrop(); } } /** * Recursively create a list of all valid destinations for a current category within a parent category. * * @param {Element} item * @param {Number} movingCategoryId * @return {Array} */ createMoveCategoryList(item, movingCategoryId) { const categories = []; if (item.children) { let precedingSibling = null; item.children.forEach(category => { const categoryId = parseInt(category.dataset.categoryid); // Don't create a target for the category that's moving. if (categoryId === movingCategoryId) { return; } // Create a target to move before this child. let child = { categoryid: categoryId, movingcategoryid: movingCategoryId, precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0, parent: category.dataset.parent, categoryname: category.dataset.categoryname, categories: null, current: categoryId === movingCategoryId, }; const childList = category.querySelector(this.selectors.CATEGORY_LIST); if (childList) { // If the child has its own children, recursively make a list of those. child.categories = this.createMoveCategoryList(childList, movingCategoryId); } else { // Otherwise, create a target to move as a new child of this one. child.categories = [ { movingcategoryid: movingCategoryId, precedingsiblingid: 0, parent: categoryId, categoryname: category.dataset.categoryname, categories: null, newchild: true, } ]; } categories.push(child); precedingSibling = category; }); if (precedingSibling) { const precedingId = parseInt(precedingSibling.dataset.categoryid); if (precedingId !== movingCategoryId) { // If this is the last child of its parent, also create a target to move the category after this one. categories.push({ movingcategoryid: movingCategoryId, precedingsiblingid: precedingId, parent: precedingSibling.dataset.parent, categoryname: precedingSibling.dataset.categoryname, categories: null, lastchild: true, }); } } } return categories; } /** * Displays a modal containing links to move the category to a new location. * * @param {Event} e Button click event. */ async showMoveModal(e) { // Return if it is not menu item. const item = e.target.closest(this.selectors.MOVE_BUTTON); if (!item) { return; } // Return if it is disabled. if (item.getAttribute('aria-disabled') === 'true') { return; } // Prevent addition click on the item. item.setAttribute('aria-disabled', true); // Build the list of move links. let moveList = {contexts: []}; const contexts = document.querySelectorAll(this.selectors.CONTEXT); contexts.forEach(context => { const moveContext = { contextname: context.dataset.contextname, categories: [], hascategories: false, }; moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid)); moveContext.hascategories = moveContext.categories.length > 0; moveList.contexts.push(moveContext); }); const modal = await Modal.create({ title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname), body: Templates.render('qbank_managecategories/move_context_list', moveList), footer: '', show: true, large: true, }); // Show modal and add click event for list items. modal.getBody()[0].addEventListener('click', e => { const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM); if (!target) { return; } categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid); modal.destroy(); }); item.setAttribute('aria-disabled', false); } /** * Check and add a child list if needed. * * Check whether the category that has just been added has this category as its parent. If it does, * check that this category has a child list, and if not, add one. * * @param {Object} args * @param {Element} args.element The new category. * @return {Promise} */ async checkChildList({element}) { if (element.parent !== this.getElement().dataset.categoryid) { return null; // Not for me. } let childList = this.getElement(this.selectors.CATEGORY_LIST); if (childList) { return null; // List already exists, it will handle adding the new category. } // Render and add a new child list containing the new category. return this.createChildList({ categoryid: element.parent, children: [ element.templatecontext, ] }); } } PK\Bbank/managecategories/amd/src/datafilter/filtertypes/categories.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Filter managing display of subcategories questions. * * @module qbank_managecategories/datafilter/filtertypes/categories * @author Mark Johnson * @copyright 2023 Catalyst IT Europe Ltd. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import GenericFilter from 'core/datafilter/filtertype'; import Templates from 'core/templates'; import {getUserPreference, setUserPreference} from 'core_user/repository'; import Notification from 'core/notification'; import {get_strings as getStrings} from 'core/str'; export default class extends GenericFilter { SELECTORS = { includeSubcategories: 'input[name=category-subcategories]', selectInput: 'select#filter-value-category', selectInputOption: 'select#filter-value-category option', validationInput: 'div[data-filter-type="category"] div.form-autocomplete-input input', }; /** * Construct a new categoires filter * * @param {String} filterType The type of filter that this relates to (categories) * @param {HTMLElement} rootNode The root node for the participants filterset * @param {Array} initialValues The currently selected category IDs. * @param {Object} filterOptions An object containing the additional options for the filter, currently "includesubcategories" * is supported, which if true will display the "Also show questions from subcategories" checkbox as checked. */ constructor(filterType, rootNode, initialValues, filterOptions = {includesubcategories: false}) { super(filterType, rootNode, initialValues); this.addSubcategoryCheckbox(filterOptions.includesubcategories); } async addSubcategoryCheckbox(checked = null) { const filterValueNode = this.getFilterValueNode(); if (checked === null || checked === undefined) { checked = await getUserPreference('qbank_managecategories_includesubcategories_filter_default'); } else { setUserPreference('qbank_managecategories_includesubcategories_filter_default', checked); } const {html} = await Templates.renderForPromise('qbank_managecategories/include_subcategories_checkbox', { checked: checked && checked !== '0', }); filterValueNode.insertAdjacentHTML('afterend', html); } get filterOptions() { return [ {name: 'includesubcategories', value: this.filterRoot.querySelector(this.SELECTORS.includeSubcategories).checked} ]; } get filterValue() { return { name: this.name, jointype: this.jointype, values: this.values, filteroptions: this.filterOptions, }; } validate() { // Get the possible option values and filter out empty ones. const nodelist = this.filterRoot.querySelectorAll(this.SELECTORS.selectInputOption); // This gets all the values from the autocomplete's hidden select menu and filters out // any which are not positive integers. const validoptions = Array.from(nodelist).map( option => (parseInt(option.value) > 0) ? parseInt(option.value) : false ).filter(Boolean); // Get the value supplied. const value = parseInt(this.filterRoot.querySelector(this.SELECTORS.selectInput).value); // Reset validation. const node = this.filterRoot.querySelector(this.SELECTORS.validationInput); node.setCustomValidity(''); // Make sure the value supplied is valid. if (!validoptions.includes(value)) { getStrings([ { key: 'error:category', component: 'qbank_managecategories', }, ]).then((strings) => { node.setCustomValidity(strings[0]); node.reportValidity(); return strings; }).catch(Notification.exception); return false; } return true; } } PK\;<)bank/managecategories/amd/src/newchild.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * The newchild component. * * This is a drop target for moving a category to an as-yet-nonexistant child list under another category. * * @module qbank_managecategories/newchild * @class qbank_managecategories/newchild */ import {BaseComponent, DragDrop} from 'core/reactive'; import {categorymanager} from 'qbank_managecategories/categorymanager'; import Tooltip from 'theme_boost/bootstrap/tooltip'; export default class extends BaseComponent { create(descriptor) { this.name = descriptor.element.id; this.selectors = { NEW_CHILD: '.qbank_managecategories-newchild', CATEGORY_ID: id => `#category-${id}` }; this.classes = { DROP_TARGET: 'qbank_managecategories-droptarget', }; this.ids = { CATEGORY: id => `category-${id}`, }; } stateReady() { this.dragdrop = new DragDrop(this); } destroy() { // The draggable element must be unregistered. if (this.dragdrop !== undefined) { this.dragdrop.unregister(); this.dragdrop = undefined; } } /** * Static method to create a component instance form the mustache template. * * @param {string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides * @return {Component} */ static init(target, selectors) { const targetElement = document.querySelector(target); return new this({ element: targetElement, selectors, reactive: categorymanager, }); } /** * Cannot drop a category as a new child of its own descendant. * * @param {Object} dropData * @return {boolean} */ validateDropData(dropData) { if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) { return false; } return true; } showDropZone(dropData, event) { const dropTarget = event.target.closest(this.selectors.NEW_CHILD); dropTarget.classList.add(this.classes.DROP_TARGET); Tooltip.getOrCreateInstance(dropTarget).show(); } hideDropZone(dropData, event) { const dropTarget = event.target.closest(this.selectors.NEW_CHILD); dropTarget.classList.remove(this.classes.DROP_TARGET); Tooltip.getOrCreateInstance(dropTarget).hide(); } drop(dropData, event) { const dropTarget = event.target.closest(this.selectors.NEW_CHILD); if (!dropTarget) { return; } const source = document.getElementById(this.ids.CATEGORY(dropData.id)); if (!source) { return; } const targetParentId = dropTarget.dataset.parent; // Insert the category as the first child of the new parent. categorymanager.moveCategory(dropData.id, targetParentId); } /** * Watch for categories moving to a new parent. * * @return {Array} A list of watchers. */ getWatchers() { return [ // Watch for any category having its parent changed. {watch: `categories.parent:updated`, handler: this.checkNewChild}, ]; } /** * If an element now has this category as the parent, remove this new child target. * * @param {Object} args * @param {Element} args.element */ checkNewChild({element}) { if (element.parent === parseInt(this.element.dataset.parent)) { this.remove(); } } } PK\һ6bank/managecategories/amd/build/categorymanager.min.jsnu[define("qbank_managecategories/categorymanager",["exports","core/reactive","core/str","qbank_managecategories/mutations","qbank_managecategories/events","core/ajax","core/notification","core_form/modalform"],(function(_exports,_reactive,_str,_mutations,_events,_ajax,_notification,_modalform){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.categorymanager=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_modalform=_interopRequireDefault(_modalform);const SELECTORS_CATEGORY_LIST=".qbank_managecategories-categorylist",SELECTORS_CONTEXT=".qbank_managecategories-categorylist[data-contextid]",SELECTORS_CATEGORY_ITEM=".qbank_managecategories-item[data-categoryid]",SELECTORS_CATEGORY_ROOT="#categoryroot",SELECTORS_SHOWDESCRIPTIONS_TOGGLE="#showdescriptions-toggle",SELECTORS_ADD_EDIT_BUTTON='[data-action="addeditcategory"]',CLASSES_DRAGHANDLE="draghandle",CLASSES_DANGER="alert-danger";class CategoryManager extends _reactive.Reactive{moveCategory(categoryId,targetParentId){let precedingSiblingId=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const call={methodname:"qbank_managecategories_move_category",args:{pagecontextid:this.state.page.contextid,categoryid:categoryId,targetparentid:targetParentId,precedingsiblingid:precedingSiblingId}};_ajax.default.call([call])[0].then((stateUpdates=>(this.stateManager.processUpdates(stateUpdates),stateUpdates))).catch((error=>{var _document$getElements;_notification.default.addNotification({message:error.message,type:"error"}),null===(_document$getElements=document.getElementsByClassName(CLASSES_DANGER)[0])||void 0===_document$getElements||_document$getElements.scrollIntoView()}))}getTitle(isEdit){return(0,_str.get_string)(isEdit?"editcategory":"addcategory","question")}getSave(isEdit){return isEdit?(0,_str.get_string)("savechanges","core"):(0,_str.get_string)("addcategory","question")}showEditModal(e){const addEditButton=e.target.closest(SELECTORS_ADD_EDIT_BUTTON);if(!addEditButton)return;if(!addEditButton.dataset.actiontype)return;e.preventDefault();const title=categorymanager.getTitle("edit"===addEditButton.dataset.actiontype),save=categorymanager.getSave("edit"===addEditButton.dataset.actiontype),cmid=addEditButton.dataset.cmid,courseid=addEditButton.dataset.courseid,questioncount=addEditButton.dataset.questioncount;let contextid=addEditButton.dataset.contextid,categoryid=null,sortorder=null,parent=null;const categoryItem=e.target.closest(SELECTORS_CATEGORY_ITEM);if(categoryItem){contextid=categoryItem.dataset.contextid,categoryid=categoryItem.dataset.categoryid,sortorder=categoryItem.dataset.sortorder;const parentContext=categoryItem.closest(SELECTORS_CONTEXT);parent=categoryItem.dataset.parent+","+parentContext.dataset.contextid}const modalForm=new _modalform.default({formClass:"qbank_managecategories\\form\\question_category_edit_form",args:{cmid:cmid,courseid:courseid,questioncount:questioncount,contextid:contextid,categoryid:categoryid,sortorder:sortorder,parent:parent},modalConfig:{title:title,large:!0},saveButtonText:save,returnFocus:addEditButton});modalForm.addEventListener(modalForm.events.FORM_SUBMITTED,(response=>{categorymanager.stateManager.processUpdates(response.detail)})),modalForm.show()}}const categorymanager=new CategoryManager({name:"qtype_managecategories_categorymanager",eventName:_events.eventTypes.qbankManagecategoriesStateUpdated,eventDispatch:_events.notifyQbankManagecategoriesStateUpdated,mutations:_mutations.mutations});_exports.categorymanager=categorymanager;_exports.init=()=>{(async reactive=>{const stateData={page:{contextid:document.querySelector(SELECTORS_CATEGORY_ROOT).dataset.contextid,showdescriptions:document.querySelector(SELECTORS_SHOWDESCRIPTIONS_TOGGLE).checked},categories:[],categoryLists:[]};document.querySelectorAll(SELECTORS_CATEGORY_ITEM).forEach((item=>{stateData.categories.push({id:item.dataset.categoryid,name:item.dataset.categoryname,parent:item.dataset.parent,contextid:item.dataset.contextid,sortorder:item.dataset.sortorder,draghandle:item.classList.contains(CLASSES_DRAGHANDLE)})})),document.querySelectorAll(SELECTORS_CATEGORY_LIST).forEach((categoryList=>{stateData.categoryLists.push({id:categoryList.dataset.categoryid,childCount:categoryList.querySelectorAll(SELECTORS_CATEGORY_ITEM).length})})),reactive.setInitialState(stateData)})(categorymanager)}})); //# sourceMappingURL=categorymanager.min.js.mapPK\*9M/bank/managecategories/amd/build/newchild.min.jsnu[define("qbank_managecategories/newchild",["exports","core/reactive","qbank_managecategories/categorymanager","theme_boost/bootstrap/tooltip"],(function(_exports,_reactive,_categorymanager,_tooltip){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_tooltip=(obj=_tooltip)&&obj.__esModule?obj:{default:obj};class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={NEW_CHILD:".qbank_managecategories-newchild",CATEGORY_ID:id=>"#category-".concat(id)},this.classes={DROP_TARGET:"qbank_managecategories-droptarget"},this.ids={CATEGORY:id=>"category-".concat(id)}}stateReady(){this.dragdrop=new _reactive.DragDrop(this)}destroy(){void 0!==this.dragdrop&&(this.dragdrop.unregister(),this.dragdrop=void 0)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}validateDropData(dropData){return!this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))}showDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);dropTarget.classList.add(this.classes.DROP_TARGET),_tooltip.default.getOrCreateInstance(dropTarget).show()}hideDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);dropTarget.classList.remove(this.classes.DROP_TARGET),_tooltip.default.getOrCreateInstance(dropTarget).hide()}drop(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);if(!dropTarget)return;if(!document.getElementById(this.ids.CATEGORY(dropData.id)))return;const targetParentId=dropTarget.dataset.parent;_categorymanager.categorymanager.moveCategory(dropData.id,targetParentId)}getWatchers(){return[{watch:"categories.parent:updated",handler:this.checkNewChild}]}checkNewChild(_ref){let{element:element}=_ref;element.parent===parseInt(this.element.dataset.parent)&&this.remove()}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=newchild.min.js.mapPK\r3bank/managecategories/amd/build/categorylist.min.jsnu[define("qbank_managecategories/categorylist",["exports","core/reactive","core/templates","core/str","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_templates,_str,_categorymanager){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj};class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={CATEGORY_LIST:".qbank_managecategories-categorylist",CATEGORY_ITEM:".qbank_managecategories-item[data-categoryid]",CATEGORY_CONTENTS:".qbank_managecategories-item > .container",CATEGORY_DETAILS:".qbank_managecategories-details",CATEGORY_NO_DRAGHANDLE:".qbank_managecategories-item[data-categoryid]:not(.draghandle)",CATEGORY_ID:id=>"#category-".concat(id)},this.classes={DROP_TARGET_BEFORE:"qbank_managecategories-droptarget-before",DROP_TARGET:"qbank_managecategories-droptarget",NO_BOTTOM_PADDING:"pb-0"},this.ids={CATEGORY:id=>"category-".concat(id)}}stateReady(){this.dragdrop=new _reactive.DragDrop(this)}destroy(){void 0!==this.dragdrop&&(this.dragdrop.unregister(),this.dragdrop=void 0)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}validateDropData(){return!0}showDropZone(dropData,event){const dropTarget=this.getElement();return!dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))&&(this.getInsertBefore(event,dropTarget)?(dropTarget.classList.add(this.classes.DROP_TARGET_BEFORE),dropTarget.classList.remove(this.classes.DROP_TARGET)):(dropTarget.classList.add(this.classes.DROP_TARGET),dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE)),!0)}hideDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.CATEGORY_LIST);dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE),dropTarget.classList.remove(this.classes.DROP_TARGET)}getInsertBefore(event,dropTarget){return event.clientY-dropTarget.getBoundingClientRect().top.\n\n/**\n * The category component.\n *\n * @module qbank_managecategories/category\n * @class qbank_managecategories/category\n */\n\nimport {BaseComponent, DragDrop} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\nimport Templates from 'core/templates';\nimport Modal from \"core/modal\";\nimport {get_string as getString} from \"core/str\";\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n CATEGORY_LIST: '.qbank_managecategories-categorylist',\n CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',\n CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',\n EDIT_BUTTON: '[data-action=\"addeditcategory\"]',\n MOVE_BUTTON: '[role=\"menuitem\"][data-actiontype=\"move\"]',\n CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',\n MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]',\n CONTENT_AREA: '.qbank_managecategories-details',\n CATEGORY_ID: id => `#category-${id}`,\n CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,\n CHILD_LIST: id => `ul[data-categoryid=\"${id}\"]`,\n PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder=\"${sortorder}\"]`,\n };\n this.classes = {\n NO_BOTTOM_PADDING: 'pb-0',\n DRAGHANDLE: 'draghandle',\n DROPTARGET: 'qbank_managecategories-droptarget-before',\n };\n this.ids = {\n CATEGORY: id => `category-${id}`,\n };\n }\n\n stateReady() {\n this.initDragDrop();\n this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal);\n const moveButton = this.getElement(this.selectors.MOVE_BUTTON);\n this.addEventListener(moveButton, 'click', this.showMoveModal);\n }\n\n destroy() {\n // The draggable element must be unregistered.\n this.deInitDragDrop();\n }\n\n /**\n * Remove any existing DragDrop component, and create a new one.\n */\n initDragDrop() {\n this.deInitDragDrop();\n // If the element is currently draggable, register the getDraggableData method.\n if (this.element.classList.contains(this.classes.DRAGHANDLE)) {\n this.getDraggableData = this._getDraggableData;\n }\n this.dragdrop = new DragDrop(this);\n }\n\n /**\n * If the DragDrop component is currently registered, unregister it.\n */\n deInitDragDrop() {\n if (this.dragdrop !== undefined) {\n if (this.getDraggableData !== undefined) {\n this.dragdrop.setDraggable(false);\n this.getDraggableData = undefined;\n }\n this.dragdrop.unregister();\n this.dragdrop = undefined;\n }\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Return the category ID from the component's element.\n *\n * This method is referenced as getDraggableData when the component can be dragged.\n *\n * @return {{id: string}}\n * @private\n */\n _getDraggableData() {\n return {\n id: this.getElement().dataset.categoryid\n };\n }\n\n validateDropData() {\n return true;\n }\n\n /**\n * Highlight the top border of the category item.\n *\n * @param {Object} dropData\n */\n showDropZone(dropData) {\n if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {\n // Can't drop onto itself or its own child.\n return false;\n }\n this.getElement().classList.add(this.classes.DROPTARGET);\n return true;\n }\n\n /**\n * Remove highlighting.\n */\n hideDropZone() {\n this.getElement().classList.remove(this.classes.DROPTARGET);\n }\n\n /**\n * Find the new position of the dropped category, and trigger the move.\n *\n * @param {Object} dropData The category being moved.\n * @param {Event} event The drop event.\n */\n drop(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM);\n\n if (!dropTarget) {\n return;\n }\n\n if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {\n // Can't drop onto your own child.\n return;\n }\n\n const source = document.getElementById(this.ids.CATEGORY(dropData.id));\n\n if (!source) {\n return;\n }\n\n const targetParentId = dropTarget.dataset.parent;\n const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST);\n let precedingSibling;\n\n if (dropTarget === parentList.firstElementChild) {\n // Dropped at the top of the list.\n precedingSibling = null;\n } else {\n precedingSibling = dropTarget.previousElementSibling;\n }\n\n // Insert the category after the target category\n categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);\n }\n\n getWatchers() {\n return [\n // After any update to this category, move it to the new position.\n {watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition},\n // When the template context is added or updated, re-render the content.\n {watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender},\n {watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender},\n // When a new category is created, check whether we need to add a child list to this category.\n {watch: `categories:created`, handler: this.checkChildList},\n ];\n }\n\n /**\n * Re-render the category content.\n *\n * @param {Object} args\n * @param {Element} args.element\n * @return {Promise}\n */\n async rerender({element}) {\n const {html, js} = await Templates.renderForPromise(\n 'qbank_managecategories/category_details',\n element.templatecontext\n );\n return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js);\n }\n\n /**\n * Render and append a new child list.\n *\n * @param {Object} context Template context, must include at least categoryid.\n * @return {Promise}\n */\n async createChildList(context) {\n const {html, js} = await Templates.renderForPromise(\n 'qbank_managecategories/childlist',\n context,\n );\n const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));\n await Templates.appendNodeContents(parentContainer, html, js);\n const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid));\n childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING);\n return childList;\n }\n\n /**\n * Move a category to its new position.\n *\n * A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those\n * changes and move the element to the new position. If the parent doesn't already have a child list, one will be created.\n *\n * If the parent has changed, this will also update the state with the new child count of the old and new parents.\n *\n * @param {Object} args\n * @param {Object} args.element\n * @return {Promise}\n */\n async updatePosition({element}) {\n // Move to a new parent category.\n let newParent;\n const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));\n if (parseInt(this.getElement().dataset.parent) !== element.parent) {\n newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent));\n if (!newParent) {\n // The target category doesn't have a child list yet. We'd better create one.\n newParent = await this.createChildList({categoryid: element.parent});\n }\n this.getElement().dataset.parent = element.parent;\n } else {\n newParent = this.getElement().parentElement;\n }\n\n // Move to a new position within the parent.\n let previousSibling;\n let nextSibling;\n if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) {\n // Move to the top of the list.\n nextSibling = newParent.firstElementChild;\n } else {\n // Move later in the list.\n previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1));\n nextSibling = previousSibling?.nextElementSibling;\n }\n\n // Check if this has actually moved, or if it's just having its sortorder updated due to another element moving.\n const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement());\n\n if (moved) {\n if (nextSibling) {\n // Move to the specified position in the list.\n newParent.insertBefore(this.getElement(), nextSibling);\n } else {\n // Move to the end of the list (may also be the top of the list is empty).\n newParent.appendChild(this.getElement());\n }\n }\n if (originParent !== newParent) {\n // Update child count of old and new parent.\n this.reactive.stateManager.processUpdates([\n {\n name: 'categoryLists',\n action: 'put',\n fields: {\n id: originParent.dataset.categoryid,\n childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length\n }\n },\n {\n name: 'categoryLists',\n action: 'put',\n fields: {\n id: newParent.dataset.categoryid,\n childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length\n }\n }\n ]);\n }\n\n this.element.dataset.sortorder = element.sortorder;\n\n // Enable/disable dragging.\n const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE);\n if (isDraggable && !element.draghandle) {\n this.element.classList.remove(this.classes.DRAGHANDLE);\n this.initDragDrop();\n } else if (!isDraggable && element.draghandle) {\n this.element.classList.add(this.classes.DRAGHANDLE);\n this.initDragDrop();\n }\n }\n\n /**\n * Recursively create a list of all valid destinations for a current category within a parent category.\n *\n * @param {Element} item\n * @param {Number} movingCategoryId\n * @return {Array}\n */\n createMoveCategoryList(item, movingCategoryId) {\n const categories = [];\n if (item.children) {\n let precedingSibling = null;\n item.children.forEach(category => {\n const categoryId = parseInt(category.dataset.categoryid);\n // Don't create a target for the category that's moving.\n if (categoryId === movingCategoryId) {\n return;\n }\n // Create a target to move before this child.\n let child = {\n categoryid: categoryId,\n movingcategoryid: movingCategoryId,\n precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,\n parent: category.dataset.parent,\n categoryname: category.dataset.categoryname,\n categories: null,\n current: categoryId === movingCategoryId,\n };\n const childList = category.querySelector(this.selectors.CATEGORY_LIST);\n if (childList) {\n // If the child has its own children, recursively make a list of those.\n child.categories = this.createMoveCategoryList(childList, movingCategoryId);\n } else {\n // Otherwise, create a target to move as a new child of this one.\n child.categories = [\n {\n movingcategoryid: movingCategoryId,\n precedingsiblingid: 0,\n parent: categoryId,\n categoryname: category.dataset.categoryname,\n categories: null,\n newchild: true,\n }\n ];\n }\n categories.push(child);\n precedingSibling = category;\n });\n if (precedingSibling) {\n const precedingId = parseInt(precedingSibling.dataset.categoryid);\n if (precedingId !== movingCategoryId) {\n // If this is the last child of its parent, also create a target to move the category after this one.\n categories.push({\n movingcategoryid: movingCategoryId,\n precedingsiblingid: precedingId,\n parent: precedingSibling.dataset.parent,\n categoryname: precedingSibling.dataset.categoryname,\n categories: null,\n lastchild: true,\n });\n }\n }\n }\n return categories;\n }\n\n /**\n * Displays a modal containing links to move the category to a new location.\n *\n * @param {Event} e Button click event.\n */\n async showMoveModal(e) {\n // Return if it is not menu item.\n const item = e.target.closest(this.selectors.MOVE_BUTTON);\n if (!item) {\n return;\n }\n // Return if it is disabled.\n if (item.getAttribute('aria-disabled') === 'true') {\n return;\n }\n\n // Prevent addition click on the item.\n item.setAttribute('aria-disabled', true);\n\n // Build the list of move links.\n let moveList = {contexts: []};\n const contexts = document.querySelectorAll(this.selectors.CONTEXT);\n contexts.forEach(context => {\n const moveContext = {\n contextname: context.dataset.contextname,\n categories: [],\n hascategories: false,\n };\n moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid));\n moveContext.hascategories = moveContext.categories.length > 0;\n moveList.contexts.push(moveContext);\n });\n\n const modal = await Modal.create({\n title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),\n body: Templates.render('qbank_managecategories/move_context_list', moveList),\n footer: '',\n show: true,\n large: true,\n });\n // Show modal and add click event for list items.\n modal.getBody()[0].addEventListener('click', e => {\n const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);\n if (!target) {\n return;\n }\n categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);\n modal.destroy();\n });\n item.setAttribute('aria-disabled', false);\n }\n\n /**\n * Check and add a child list if needed.\n *\n * Check whether the category that has just been added has this category as its parent. If it does,\n * check that this category has a child list, and if not, add one.\n *\n * @param {Object} args\n * @param {Element} args.element The new category.\n * @return {Promise}\n */\n async checkChildList({element}) {\n if (element.parent !== this.getElement().dataset.categoryid) {\n return null; // Not for me.\n }\n let childList = this.getElement(this.selectors.CATEGORY_LIST);\n if (childList) {\n return null; // List already exists, it will handle adding the new category.\n }\n // Render and add a new child list containing the new category.\n return this.createChildList({\n categoryid: element.parent,\n children: [\n element.templatecontext,\n ]\n });\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","CATEGORY_LIST","CATEGORY_ITEM","CATEGORY_CONTENTS","EDIT_BUTTON","MOVE_BUTTON","CONTEXT","MODAL_CATEGORY_ITEM","CONTENT_AREA","CATEGORY_ID","CONTENT_CONTAINER","CHILD_LIST","PREVIOUS_SIBLING","sortorder","classes","NO_BOTTOM_PADDING","DRAGHANDLE","DROPTARGET","ids","CATEGORY","stateReady","initDragDrop","addEventListener","this","getElement","categorymanager","showEditModal","moveButton","showMoveModal","destroy","deInitDragDrop","classList","contains","getDraggableData","_getDraggableData","dragdrop","DragDrop","undefined","setDraggable","unregister","target","document","querySelector","reactive","dataset","categoryid","validateDropData","showDropZone","dropData","closest","add","hideDropZone","remove","drop","event","dropTarget","getElementById","targetParentId","parent","parentList","precedingSibling","firstElementChild","previousElementSibling","moveCategory","_precedingSibling","getWatchers","watch","handler","updatePosition","rerender","checkChildList","html","js","Templates","renderForPromise","templatecontext","replaceNodeContents","context","parentContainer","appendNodeContents","childList","newParent","originParent","previousSibling","nextSibling","parseInt","createChildList","parentElement","_previousSibling","nextElementSibling","insertBefore","appendChild","stateManager","processUpdates","action","fields","childCount","querySelectorAll","length","isDraggable","draghandle","createMoveCategoryList","item","movingCategoryId","categories","children","forEach","category","categoryId","child","movingcategoryid","precedingsiblingid","_precedingSibling2","categoryname","current","newchild","push","precedingId","lastchild","e","getAttribute","setAttribute","moveList","contexts","moveContext","contextname","hascategories","modal","Modal","title","body","render","footer","show","large","getBody"],"mappings":"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"}PK\E +-+-:bank/managecategories/amd/build/categorymanager.min.js.mapnu[{"version":3,"file":"categorymanager.min.js","sources":["../src/categorymanager.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Reactive module for category manager\n *\n * @module qbank_managecategories/categorymanager\n */\n\nimport {Reactive} from 'core/reactive';\nimport {get_string as getString} from 'core/str';\nimport {mutations} from 'qbank_managecategories/mutations';\nimport {eventTypes, notifyQbankManagecategoriesStateUpdated} from 'qbank_managecategories/events';\nimport Ajax from \"core/ajax\";\nimport Notification from \"core/notification\";\nimport ModalForm from 'core_form/modalform';\n\nconst SELECTORS = {\n CATEGORY_LIST: '.qbank_managecategories-categorylist',\n CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',\n CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',\n CATEGORY_ROOT: '#categoryroot',\n SHOWDESCRIPTIONS_TOGGLE: '#showdescriptions-toggle',\n ADD_EDIT_BUTTON: '[data-action=\"addeditcategory\"]',\n};\n\nconst CLASSES = {\n DRAGHANDLE: 'draghandle',\n DANGER: 'alert-danger',\n};\n\n/**\n * Load the initial state.\n *\n * This iterates over the initial tree of category items, and captures the data required for the state from each category.\n * It also captures a count of the number of children in each list.\n *\n * @param {Reactive} reactive\n * @return {Promise}\n */\nconst loadState = async(reactive) => {\n const rootElement = document.querySelector(SELECTORS.CATEGORY_ROOT);\n const stateData = {\n page: {\n contextid: rootElement.dataset.contextid,\n showdescriptions: document.querySelector(SELECTORS.SHOWDESCRIPTIONS_TOGGLE).checked,\n },\n categories: [],\n categoryLists: [],\n };\n const listItems = document.querySelectorAll(SELECTORS.CATEGORY_ITEM);\n listItems.forEach(item => {\n stateData.categories.push({\n id: item.dataset.categoryid,\n name: item.dataset.categoryname,\n parent: item.dataset.parent,\n contextid: item.dataset.contextid,\n sortorder: item.dataset.sortorder,\n draghandle: item.classList.contains(CLASSES.DRAGHANDLE),\n });\n });\n const categoryLists = document.querySelectorAll(SELECTORS.CATEGORY_LIST);\n categoryLists.forEach(categoryList => {\n stateData.categoryLists.push({\n id: categoryList.dataset.categoryid,\n childCount: categoryList.querySelectorAll(SELECTORS.CATEGORY_ITEM).length,\n });\n });\n reactive.setInitialState(stateData);\n};\n\n/**\n * Reactive instance for the category manager.\n */\nclass CategoryManager extends Reactive {\n /**\n * Move a category to a new position within the given parent.\n *\n * This will call the move_category web service function to re-order the categories, then update\n * the state with the returned updates.\n *\n * @param {Number} categoryId The ID of the category being moved.\n * @param {Number} targetParentId The ID of the destination parent category (this may not have changed).\n * @param {Number} precedingSiblingId The ID of the category to put the moved category after.\n * This may be null if moving to the top of a list.\n */\n moveCategory(\n categoryId,\n targetParentId,\n precedingSiblingId = null,\n ) {\n const call = {\n methodname: 'qbank_managecategories_move_category',\n args: {\n pagecontextid: this.state.page.contextid,\n categoryid: categoryId,\n targetparentid: targetParentId,\n precedingsiblingid: precedingSiblingId,\n }\n };\n Ajax.call([call])[0]\n .then((stateUpdates) => {\n this.stateManager.processUpdates(stateUpdates);\n return stateUpdates;\n })\n .catch(error => {\n Notification.addNotification({\n message: error.message,\n type: 'error',\n });\n document.getElementsByClassName(CLASSES.DANGER)[0]?.scrollIntoView();\n });\n }\n\n /**\n * Return title for the add/edit modal.\n *\n * @param {boolean} isEdit is 'add' or 'edit' form\n * @returns {String} title string\n */\n getTitle(isEdit) {\n return getString(isEdit ? 'editcategory' : 'addcategory', 'question');\n }\n\n /**\n * Return save button label for the add/edit modal.\n *\n * @param {boolean} isEdit is 'add' or 'edit' form\n * @returns {String} save string\n */\n getSave(isEdit) {\n return isEdit ? getString('savechanges', 'core') : getString('addcategory', 'question');\n }\n\n /**\n * Function handling display of modal form.\n *\n * @param {Event} e The click event triggering the modal.\n */\n showEditModal(e) {\n const addEditButton = e.target.closest(SELECTORS.ADD_EDIT_BUTTON);\n\n // Return if it is not 'addeditcategory' button.\n if (!addEditButton) {\n return;\n }\n\n // Return if the action type is not specified.\n if (!addEditButton.dataset.actiontype) {\n return;\n }\n\n e.preventDefault();\n // Data for the modal.\n const title = categorymanager.getTitle(addEditButton.dataset.actiontype === 'edit');\n const save = categorymanager.getSave(addEditButton.dataset.actiontype === 'edit');\n const cmid = addEditButton.dataset.cmid;\n const courseid = addEditButton.dataset.courseid;\n const questioncount = addEditButton.dataset.questioncount;\n let contextid = addEditButton.dataset.contextid;\n let categoryid = null;\n let sortorder = null;\n let parent = null;\n const categoryItem = e.target.closest(SELECTORS.CATEGORY_ITEM);\n if (categoryItem) {\n contextid = categoryItem.dataset.contextid;\n categoryid = categoryItem.dataset.categoryid;\n sortorder = categoryItem.dataset.sortorder;\n const parentContext = categoryItem.closest(SELECTORS.CONTEXT);\n parent = categoryItem.dataset.parent + ',' + parentContext.dataset.contextid;\n }\n\n // Call the modal.\n const modalForm = new ModalForm({\n formClass: \"qbank_managecategories\\\\form\\\\question_category_edit_form\",\n args: {\n cmid,\n courseid,\n questioncount,\n contextid,\n categoryid,\n sortorder,\n parent,\n },\n modalConfig: {\n title: title,\n large: true,\n },\n saveButtonText: save,\n returnFocus: addEditButton,\n });\n // Once the form has been submitted via the web service, update the state with the new or updated\n // category based on the web service response.\n modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (response) => {\n categorymanager.stateManager.processUpdates(response.detail);\n });\n // Show the form.\n modalForm.show();\n }\n}\n\nexport const categorymanager = new CategoryManager({\n name: 'qtype_managecategories_categorymanager',\n eventName: eventTypes.qbankManagecategoriesStateUpdated,\n eventDispatch: notifyQbankManagecategoriesStateUpdated,\n mutations,\n});\n\n/**\n * Load the initial state.\n */\nexport const init = () => {\n loadState(categorymanager);\n};\n"],"names":["SELECTORS","CLASSES","CategoryManager","Reactive","moveCategory","categoryId","targetParentId","precedingSiblingId","call","methodname","args","pagecontextid","this","state","page","contextid","categoryid","targetparentid","precedingsiblingid","then","stateUpdates","stateManager","processUpdates","catch","error","addNotification","message","type","document","getElementsByClassName","scrollIntoView","getTitle","isEdit","getSave","showEditModal","e","addEditButton","target","closest","dataset","actiontype","preventDefault","title","categorymanager","save","cmid","courseid","questioncount","sortorder","parent","categoryItem","parentContext","modalForm","ModalForm","formClass","modalConfig","large","saveButtonText","returnFocus","addEventListener","events","FORM_SUBMITTED","response","detail","show","name","eventName","eventTypes","qbankManagecategoriesStateUpdated","eventDispatch","notifyQbankManagecategoriesStateUpdated","mutations","async","stateData","querySelector","showdescriptions","checked","categories","categoryLists","querySelectorAll","forEach","item","push","id","categoryname","draghandle","classList","contains","categoryList","childCount","length","reactive","setInitialState","loadState"],"mappings":"ymBA6BMA,wBACa,uCADbA,kBAEO,uDAFPA,wBAGa,gDAHbA,wBAIa,gBAJbA,kCAKuB,2BALvBA,0BAMe,kCAGfC,mBACU,aADVA,eAEM,qBA8CNC,wBAAwBC,mBAY1BC,aACIC,WACAC,oBACAC,0EAAqB,WAEfC,KAAO,CACTC,WAAY,uCACZC,KAAM,CACFC,cAAeC,KAAKC,MAAMC,KAAKC,UAC/BC,WAAYX,WACZY,eAAgBX,eAChBY,mBAAoBX,mCAGvBC,KAAK,CAACA,OAAO,GACbW,MAAMC,oBACEC,aAAaC,eAAeF,cAC1BA,gBAEVG,OAAMC,wDACUC,gBAAgB,CACzBC,QAASF,MAAME,QACfC,KAAM,wCAEVC,SAASC,uBAAuB5B,gBAAgB,2DAAI6B,oBAUhEC,SAASC,eACE,mBAAUA,OAAS,eAAiB,cAAe,YAS9DC,QAAQD,eACGA,QAAS,mBAAU,cAAe,SAAU,mBAAU,cAAe,YAQhFE,cAAcC,SACJC,cAAgBD,EAAEE,OAAOC,QAAQtC,+BAGlCoC,yBAKAA,cAAcG,QAAQC,kBAI3BL,EAAEM,uBAEIC,MAAQC,gBAAgBZ,SAA8C,SAArCK,cAAcG,QAAQC,YACvDI,KAAOD,gBAAgBV,QAA6C,SAArCG,cAAcG,QAAQC,YACrDK,KAAOT,cAAcG,QAAQM,KAC7BC,SAAWV,cAAcG,QAAQO,SACjCC,cAAgBX,cAAcG,QAAQQ,kBACxChC,UAAYqB,cAAcG,QAAQxB,UAClCC,WAAa,KACbgC,UAAY,KACZC,OAAS,WACPC,aAAef,EAAEE,OAAOC,QAAQtC,4BAClCkD,aAAc,CACdnC,UAAYmC,aAAaX,QAAQxB,UACjCC,WAAakC,aAAaX,QAAQvB,WAClCgC,UAAYE,aAAaX,QAAQS,gBAC3BG,cAAgBD,aAAaZ,QAAQtC,mBAC3CiD,OAASC,aAAaX,QAAQU,OAAS,IAAME,cAAcZ,QAAQxB,gBAIjEqC,UAAY,IAAIC,mBAAU,CAC5BC,UAAW,4DACX5C,KAAM,CACFmC,KAAAA,KACAC,SAAAA,SACAC,cAAAA,cACAhC,UAAAA,UACAC,WAAAA,WACAgC,UAAAA,UACAC,OAAAA,QAEJM,YAAa,CACTb,MAAOA,MACPc,OAAO,GAEXC,eAAgBb,KAChBc,YAAatB,gBAIjBgB,UAAUO,iBAAiBP,UAAUQ,OAAOC,gBAAiBC,WACzDnB,gBAAgBtB,aAAaC,eAAewC,SAASC,WAGzDX,UAAUY,cAILrB,gBAAkB,IAAIzC,gBAAgB,CAC/C+D,KAAM,yCACNC,UAAWC,mBAAWC,kCACtBC,cAAeC,gDACfC,UAAAA,8EAMgB,KA3KFC,OAAAA,iBAERC,UAAY,CACd3D,KAAM,CACFC,UAHYa,SAAS8C,cAAc1E,yBAGZuC,QAAQxB,UAC/B4D,iBAAkB/C,SAAS8C,cAAc1E,mCAAmC4E,SAEhFC,WAAY,GACZC,cAAe,IAEDlD,SAASmD,iBAAiB/E,yBAClCgF,SAAQC,OACdR,UAAUI,WAAWK,KAAK,CACtBC,GAAIF,KAAK1C,QAAQvB,WACjBiD,KAAMgB,KAAK1C,QAAQ6C,aACnBnC,OAAQgC,KAAK1C,QAAQU,OACrBlC,UAAWkE,KAAK1C,QAAQxB,UACxBiC,UAAWiC,KAAK1C,QAAQS,UACxBqC,WAAYJ,KAAKK,UAAUC,SAAStF,yBAGtB2B,SAASmD,iBAAiB/E,yBAClCgF,SAAQQ,eAClBf,UAAUK,cAAcI,KAAK,CACzBC,GAAIK,aAAajD,QAAQvB,WACzByE,WAAYD,aAAaT,iBAAiB/E,yBAAyB0F,YAG3EC,SAASC,gBAAgBnB,YAgJzBoB,CAAUlD"}PK\ɕ0 0 4bank/managecategories/amd/build/mutations.min.js.mapnu[{"version":3,"file":"mutations.min.js","sources":["../src/mutations.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport {setUserPreference} from 'core_user/repository';\n\n/**\n * Default mutation manager\n *\n * @module qbank_managecategories/mutations\n */\nclass Mutations {\n /**\n * Set the showdescriptions property to true or false in the page's state.\n *\n * @param {stateManager} stateManager\n * @param {boolean} showDescriptions\n * @return {Promise}\n */\n async toggleDescriptions(stateManager, showDescriptions) {\n stateManager.setReadOnly(false);\n await setUserPreference('qbank_managecategories_showdescriptions', showDescriptions);\n stateManager.state.page.showdescriptions = showDescriptions;\n stateManager.setReadOnly(true);\n }\n\n /**\n * Set the draghandle property to true in a given category's state.\n *\n * @param {stateManager} stateManager\n * @param {Number} categoryId\n * @return {Promise}\n */\n async showDragHandle(stateManager, categoryId) {\n stateManager.setReadOnly(false);\n stateManager.state.categories.get(categoryId).draghandle = true;\n stateManager.setReadOnly(true);\n }\n}\n\nexport const mutations = new Mutations();\n"],"names":["mutations","stateManager","showDescriptions","setReadOnly","state","page","showdescriptions","categoryId","categories","get","draghandle"],"mappings":"qMAmDaA,UAAY,mCArBIC,aAAcC,kBACnCD,aAAaE,aAAY,SACnB,iCAAkB,0CAA2CD,kBACnED,aAAaG,MAAMC,KAAKC,iBAAmBJ,iBAC3CD,aAAaE,aAAY,wBAURF,aAAcM,YAC/BN,aAAaE,aAAY,GACzBF,aAAaG,MAAMI,WAAWC,IAAIF,YAAYG,YAAa,EAC3DT,aAAaE,aAAY"}PK\+1bank/managecategories/amd/build/events.min.js.mapnu[{"version":3,"file":"events.min.js","sources":["../src/events.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport {dispatchEvent} from 'core/event_dispatcher';\n\n/**\n * Reactive events for qbank_managecategories\n *\n * @module qbank_managecategories/events\n *\n */\nexport const eventTypes = {\n /**\n * Event triggered when the activity reactive state is updated.\n *\n * @event qbankManagecategoriesStateUpdated\n * @type {CustomEvent}\n * @property {Array} nodes The list of parent nodes which were updated\n */\n qbankManagecategoriesStateUpdated: 'qbank_managecategories/stateUpdated',\n};\n\n/**\n * Trigger an event to indicate that the activity state is updated.\n *\n * @method notifyQbankManagecategoriesStateUpdated\n * @param {Object} detail the full state\n * @param {HTMLElement} container the custom event target (document if none provided)\n * @returns {CustomEvent}\n * @fires qbankManagecategoriesStateUpdated\n */\nexport const notifyQbankManagecategoriesStateUpdated = (detail, container) => {\n return dispatchEvent(eventTypes.qbankManagecategoriesStateUpdated, detail, container);\n};\n"],"names":["eventTypes","qbankManagecategoriesStateUpdated","detail","container"],"mappings":"2PAuBaA,WAAa,CAQtBC,kCAAmC,uHAYgB,CAACC,OAAQC,aACrD,mCAAcH,WAAWC,kCAAmCC,OAAQC"}PK\Lϸ3bank/managecategories/amd/build/newchild.min.js.mapnu[{"version":3,"file":"newchild.min.js","sources":["../src/newchild.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The newchild component.\n *\n * This is a drop target for moving a category to an as-yet-nonexistant child list under another category.\n *\n * @module qbank_managecategories/newchild\n * @class qbank_managecategories/newchild\n */\n\nimport {BaseComponent, DragDrop} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\nimport Tooltip from 'theme_boost/bootstrap/tooltip';\n\nexport default class extends BaseComponent {\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n NEW_CHILD: '.qbank_managecategories-newchild',\n CATEGORY_ID: id => `#category-${id}`\n };\n this.classes = {\n DROP_TARGET: 'qbank_managecategories-droptarget',\n };\n this.ids = {\n CATEGORY: id => `category-${id}`,\n };\n }\n\n stateReady() {\n this.dragdrop = new DragDrop(this);\n }\n\n destroy() {\n // The draggable element must be unregistered.\n if (this.dragdrop !== undefined) {\n this.dragdrop.unregister();\n this.dragdrop = undefined;\n }\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const targetElement = document.querySelector(target);\n return new this({\n element: targetElement,\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Cannot drop a category as a new child of its own descendant.\n *\n * @param {Object} dropData\n * @return {boolean}\n */\n validateDropData(dropData) {\n if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {\n return false;\n }\n return true;\n }\n\n showDropZone(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.NEW_CHILD);\n dropTarget.classList.add(this.classes.DROP_TARGET);\n Tooltip.getOrCreateInstance(dropTarget).show();\n }\n\n hideDropZone(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.NEW_CHILD);\n dropTarget.classList.remove(this.classes.DROP_TARGET);\n Tooltip.getOrCreateInstance(dropTarget).hide();\n }\n\n drop(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.NEW_CHILD);\n\n if (!dropTarget) {\n return;\n }\n\n const source = document.getElementById(this.ids.CATEGORY(dropData.id));\n\n if (!source) {\n return;\n }\n\n const targetParentId = dropTarget.dataset.parent;\n\n // Insert the category as the first child of the new parent.\n categorymanager.moveCategory(dropData.id, targetParentId);\n }\n\n /**\n * Watch for categories moving to a new parent.\n *\n * @return {Array} A list of watchers.\n */\n getWatchers() {\n return [\n // Watch for any category having its parent changed.\n {watch: `categories.parent:updated`, handler: this.checkNewChild},\n ];\n }\n\n /**\n * If an element now has this category as the parent, remove this new child target.\n *\n * @param {Object} args\n * @param {Element} args.element\n */\n checkNewChild({element}) {\n if (element.parent === parseInt(this.element.dataset.parent)) {\n this.remove();\n }\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","NEW_CHILD","CATEGORY_ID","classes","DROP_TARGET","ids","CATEGORY","stateReady","dragdrop","DragDrop","this","destroy","undefined","unregister","target","document","querySelector","reactive","categorymanager","validateDropData","dropData","getElement","closest","showDropZone","event","dropTarget","classList","add","getOrCreateInstance","show","hideDropZone","remove","hide","drop","getElementById","targetParentId","dataset","parent","moveCategory","getWatchers","watch","handler","checkNewChild","parseInt"],"mappings":"+WA4B6BA,wBACzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,UAAW,mCACXC,YAAaH,wBAAmBA,UAE/BI,QAAU,CACXC,YAAa,0CAEZC,IAAM,CACPC,SAAUP,uBAAkBA,KAIpCQ,kBACSC,SAAW,IAAIC,mBAASC,MAGjCC,eAE0BC,IAAlBF,KAAKF,gBACAA,SAASK,kBACTL,cAAWI,eAWZE,OAAQd,kBAET,IAAIU,KAAK,CACZZ,QAFkBiB,SAASC,cAAcF,QAGzCd,UAAAA,UACAiB,SAAUC,mCAUlBC,iBAAiBC,iBACTV,KAAKW,aAAaC,QAAQZ,KAAKV,UAAUE,YAAYkB,SAASrB,KAMtEwB,aAAaH,SAAUI,aACbC,WAAaD,MAAMV,OAAOQ,QAAQZ,KAAKV,UAAUC,WACvDwB,WAAWC,UAAUC,IAAIjB,KAAKP,QAAQC,8BAC9BwB,oBAAoBH,YAAYI,OAG5CC,aAAaV,SAAUI,aACbC,WAAaD,MAAMV,OAAOQ,QAAQZ,KAAKV,UAAUC,WACvDwB,WAAWC,UAAUK,OAAOrB,KAAKP,QAAQC,8BACjCwB,oBAAoBH,YAAYO,OAG5CC,KAAKb,SAAUI,aACLC,WAAaD,MAAMV,OAAOQ,QAAQZ,KAAKV,UAAUC,eAElDwB,sBAIUV,SAASmB,eAAexB,KAAKL,IAAIC,SAASc,SAASrB,kBAM5DoC,eAAiBV,WAAWW,QAAQC,wCAG1BC,aAAalB,SAASrB,GAAIoC,gBAQ9CI,oBACW,CAEH,CAACC,kCAAoCC,QAAS/B,KAAKgC,gBAU3DA,wBAAc5C,QAACA,cACPA,QAAQuC,SAAWM,SAASjC,KAAKZ,QAAQsC,QAAQC,cAC5CN"}PK\(JFbl-l-7bank/managecategories/amd/build/categorylist.min.js.mapnu[{"version":3,"file":"categorylist.min.js","sources":["../src/categorylist.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The category list component.\n *\n * The category list is a drop target, so that a category may be dropped at the top or bottom of the list.\n *\n * @module qbank_managecategories/categorylist\n * @class qbank_managecategories/categorylist\n */\n\nimport {BaseComponent, DragDrop} from 'core/reactive';\nimport Templates from 'core/templates';\nimport {getString} from 'core/str';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n CATEGORY_LIST: '.qbank_managecategories-categorylist',\n CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',\n CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',\n CATEGORY_DETAILS: '.qbank_managecategories-details',\n CATEGORY_NO_DRAGHANDLE: '.qbank_managecategories-item[data-categoryid]:not(.draghandle)',\n CATEGORY_ID: id => `#category-${id}`,\n };\n this.classes = {\n DROP_TARGET_BEFORE: 'qbank_managecategories-droptarget-before',\n DROP_TARGET: 'qbank_managecategories-droptarget',\n NO_BOTTOM_PADDING: 'pb-0',\n };\n this.ids = {\n CATEGORY: id => `category-${id}`,\n };\n }\n\n stateReady() {\n this.dragdrop = new DragDrop(this);\n }\n\n destroy() {\n // The draggable element must be unregistered.\n if (this.dragdrop !== undefined) {\n this.dragdrop.unregister();\n this.dragdrop = undefined;\n }\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n selectors,\n reactive: categorymanager,\n });\n }\n\n validateDropData() {\n return true;\n }\n\n /**\n * Highlight the border of the list where the category will be moved.\n *\n * If dropping at the top of the list, highlight the top border.\n * If dropping at the bottom, highlight the bottom border.\n *\n * @param {Object} dropData\n * @param {Event} event\n */\n showDropZone(dropData, event) {\n const dropTarget = this.getElement();\n if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {\n // Can't drop onto its own child.\n return false;\n }\n if (this.getInsertBefore(event, dropTarget)) {\n dropTarget.classList.add(this.classes.DROP_TARGET_BEFORE);\n dropTarget.classList.remove(this.classes.DROP_TARGET);\n } else {\n dropTarget.classList.add(this.classes.DROP_TARGET);\n dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE);\n }\n return true;\n }\n\n /**\n * Remove highlighting.\n *\n * @param {Object} dropData\n * @param {Event} event\n */\n hideDropZone(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST);\n dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE);\n dropTarget.classList.remove(this.classes.DROP_TARGET);\n }\n\n /**\n * Determine whether we're dragging over the top or bottom half of the list.\n *\n * @param {Event} event\n * @param {Element} dropTarget\n * @return {boolean}\n */\n getInsertBefore(event, dropTarget) {\n // Get the current mouse position within the drop target\n const mouseY = event.clientY - dropTarget.getBoundingClientRect().top;\n\n // Get the height of the drop target\n const targetHeight = dropTarget.clientHeight;\n\n // Check if the mouse is over the top half of the drop target\n return mouseY < targetHeight / 2;\n }\n\n /**\n * Find the new position of the dropped category, and trigger the move.\n *\n * @param {Object} dropData\n * @param {Event} event\n */\n drop(dropData, event) {\n const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST);\n\n if (!dropTarget) {\n return;\n }\n\n if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {\n // Can't drop onto your own child.\n return;\n }\n\n const source = document.getElementById(this.ids.CATEGORY(dropData.id));\n\n if (!source) {\n return;\n }\n\n const targetParentId = dropTarget.dataset.categoryid;\n let precedingSibling;\n\n if (this.getInsertBefore(event, dropTarget)) {\n // Dropped at the top of the list.\n precedingSibling = null;\n } else {\n // Dropped at the bottom of the list.\n precedingSibling = dropTarget.lastElementChild;\n }\n\n // Insert the category after the target category\n categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);\n }\n\n /**\n * Watch for categories moving to a new parent.\n *\n * @return {Array} A list of watchers.\n */\n getWatchers() {\n return [\n // Watch for this category having its child count updated.\n {watch: `categoryLists[${this.element.dataset.categoryid}].childCount:updated`, handler: this.checkEmptyList},\n // Watch for any new category being created.\n {watch: `categories:created`, handler: this.addCategory},\n ];\n }\n\n /**\n * If this list is now empty, remove it.\n *\n * @param {Object} args\n * @param {Object} args.element The categoryList state element.\n */\n async checkEmptyList({element}) {\n if (element.childCount === 0) {\n // Display a new child drop zone.\n const categoryItem = this.getElement().closest(this.selectors.CATEGORY_ITEM);\n const {html, js} = await Templates.renderForPromise(\n 'qbank_managecategories/newchild',\n {\n categoryid: this.getElement().dataset.categoryid,\n tooltip: getString('newchild', 'qbank_managecategories', categoryItem.dataset.categoryname)\n }\n );\n const activityNameArea = categoryItem.querySelector(this.selectors.CATEGORY_DETAILS);\n await Templates.appendNodeContents(activityNameArea, html, js);\n // Reinstate padding on the parent element.\n this.element.closest(this.selectors.CATEGORY_CONTENTS).classList.remove(this.classes.NO_BOTTOM_PADDING);\n // Remove this list.\n this.remove();\n }\n }\n\n /**\n * If a newly-created category has this list's category as its parent, add it to this list.\n *\n * @param {Object} args\n * @param {Object} args.element\n * @return {Promise}\n */\n async addCategory({element}) {\n if (element.parent !== this.getElement().dataset.categoryid) {\n return; // Not for me.\n }\n const {html, js} = await Templates.renderForPromise('qbank_managecategories/category', element.templatecontext);\n Templates.appendNodeContents(this.getElement(), html, js);\n // If one of the children has no draghandle, it should do now it has a sibling.\n const noDragHandle = this.getElement(this.selectors.CATEGORY_NO_DRAGHANDLE);\n if (noDragHandle) {\n this.reactive.dispatch('showDragHandle', noDragHandle.dataset.categoryid);\n }\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","CATEGORY_LIST","CATEGORY_ITEM","CATEGORY_CONTENTS","CATEGORY_DETAILS","CATEGORY_NO_DRAGHANDLE","CATEGORY_ID","classes","DROP_TARGET_BEFORE","DROP_TARGET","NO_BOTTOM_PADDING","ids","CATEGORY","stateReady","dragdrop","DragDrop","this","destroy","undefined","unregister","target","document","querySelector","reactive","categorymanager","validateDropData","showDropZone","dropData","event","dropTarget","getElement","closest","getInsertBefore","classList","add","remove","hideDropZone","clientY","getBoundingClientRect","top","clientHeight","drop","getElementById","targetParentId","dataset","categoryid","precedingSibling","lastElementChild","moveCategory","_precedingSibling","getWatchers","watch","handler","checkEmptyList","addCategory","childCount","categoryItem","html","js","Templates","renderForPromise","tooltip","categoryname","activityNameArea","appendNodeContents","parent","templatecontext","noDragHandle","dispatch"],"mappings":"0XA6B6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,cAAe,uCACfC,cAAe,gDACfC,kBAAmB,4CACnBC,iBAAkB,kCAClBC,uBAAwB,iEACxBC,YAAaP,wBAAmBA,UAE/BQ,QAAU,CACXC,mBAAoB,2CACpBC,YAAa,oCACbC,kBAAmB,aAElBC,IAAM,CACPC,SAAUb,uBAAkBA,KAIpCc,kBACSC,SAAW,IAAIC,mBAASC,MAGjCC,eAE0BC,IAAlBF,KAAKF,gBACAA,SAASK,kBACTL,cAAWI,eAWZE,OAAQpB,kBACT,IAAIgB,KAAK,CACZlB,QAASuB,SAASC,cAAcF,QAChCpB,UAAAA,UACAuB,SAAUC,mCAIlBC,0BACW,EAYXC,aAAaC,SAAUC,aACbC,WAAab,KAAKc,oBACpBD,WAAWE,QAAQf,KAAKhB,UAAUM,YAAYqB,SAAS5B,OAIvDiB,KAAKgB,gBAAgBJ,MAAOC,aAC5BA,WAAWI,UAAUC,IAAIlB,KAAKT,QAAQC,oBACtCqB,WAAWI,UAAUE,OAAOnB,KAAKT,QAAQE,eAEzCoB,WAAWI,UAAUC,IAAIlB,KAAKT,QAAQE,aACtCoB,WAAWI,UAAUE,OAAOnB,KAAKT,QAAQC,sBAEtC,GASX4B,aAAaT,SAAUC,aACbC,WAAaD,MAAMR,OAAOW,QAAQf,KAAKhB,UAAUC,eACvD4B,WAAWI,UAAUE,OAAOnB,KAAKT,QAAQC,oBACzCqB,WAAWI,UAAUE,OAAOnB,KAAKT,QAAQE,aAU7CuB,gBAAgBJ,MAAOC,mBAEJD,MAAMS,QAAUR,WAAWS,wBAAwBC,IAG7CV,WAAWW,aAGD,EASnCC,KAAKd,SAAUC,mCACLC,WAAaD,MAAMR,OAAOW,QAAQf,KAAKhB,UAAUC,mBAElD4B,qBAIDA,WAAWE,QAAQf,KAAKhB,UAAUM,YAAYqB,SAAS5B,gBAK5CsB,SAASqB,eAAe1B,KAAKL,IAAIC,SAASe,SAAS5B,kBAM5D4C,eAAiBd,WAAWe,QAAQC,eACtCC,iBAIAA,iBAFA9B,KAAKgB,gBAAgBJ,MAAOC,YAET,KAGAA,WAAWkB,kDAIlBC,aAAarB,SAAS5B,GAAI4C,yCAAgBG,qDAAAG,kBAAkBL,QAAQC,YAQxFK,oBACW,CAEH,CAACC,8BAAwBnC,KAAKlB,QAAQ8C,QAAQC,mCAAkCO,QAASpC,KAAKqC,gBAE9F,CAACF,2BAA6BC,QAASpC,KAAKsC,6CAU/BxD,QAACA,iBACS,IAAvBA,QAAQyD,WAAkB,OAEpBC,aAAexC,KAAKc,aAAaC,QAAQf,KAAKhB,UAAUE,gBACxDuD,KAACA,KAADC,GAAOA,UAAYC,mBAAUC,iBAC/B,kCACA,CACIf,WAAY7B,KAAKc,aAAac,QAAQC,WACtCgB,SAAS,kBAAU,WAAY,yBAA0BL,aAAaZ,QAAQkB,gBAGhFC,iBAAmBP,aAAalC,cAAcN,KAAKhB,UAAUI,wBAC7DuD,mBAAUK,mBAAmBD,iBAAkBN,KAAMC,SAEtD5D,QAAQiC,QAAQf,KAAKhB,UAAUG,mBAAmB8B,UAAUE,OAAOnB,KAAKT,QAAQG,wBAEhFyB,uCAWKrC,QAACA,kBACXA,QAAQmE,SAAWjD,KAAKc,aAAac,QAAQC,wBAG3CY,KAACA,KAADC,GAAOA,UAAYC,mBAAUC,iBAAiB,kCAAmC9D,QAAQoE,oCACrFF,mBAAmBhD,KAAKc,aAAc2B,KAAMC,UAEhDS,aAAenD,KAAKc,WAAWd,KAAKhB,UAAUK,wBAChD8D,mBACK5C,SAAS6C,SAAS,iBAAkBD,aAAavB,QAAQC"}PK\  ;bank/managecategories/amd/build/showdescriptions.min.js.mapnu[{"version":3,"file":"showdescriptions.min.js","sources":["../src/showdescriptions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The show descriptions toggle component.\n *\n * @module qbank_managecategories/showdescriptions\n * @class qbank_managecategories/showdescriptions\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n TOGGLE: '#showdescriptions-toggle',\n };\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.TOGGLE), 'change', this.updateShowDescriptions);\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const targetElement = document.querySelector(target);\n return new this({\n element: targetElement,\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Dispatch a mutation to toggle the showDescriptions setting.\n *\n * @param {Event} event The toggle change event.\n * @return {Promise}\n */\n async updateShowDescriptions(event) {\n const checked = event.target.checked;\n this.reactive.dispatch('toggleDescriptions', checked);\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","TOGGLE","stateReady","addEventListener","this","getElement","updateShowDescriptions","target","document","querySelector","reactive","categorymanager","event","checked","dispatch"],"mappings":"4QAyB6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,OAAQ,4BAIhBC,kBACSC,iBAAiBC,KAAKC,WAAWD,KAAKJ,UAAUC,QAAS,SAAUG,KAAKE,oCAUrEC,OAAQP,kBAET,IAAII,KAAK,CACZN,QAFkBU,SAASC,cAAcF,QAGzCP,UAAAA,UACAU,SAAUC,gEAUWC,aACnBC,QAAUD,MAAML,OAAOM,aACxBH,SAASI,SAAS,qBAAsBD"}PK\"5BB-bank/managecategories/amd/build/events.min.jsnu[define("qbank_managecategories/events",["exports","core/event_dispatcher"],(function(_exports,_event_dispatcher){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.notifyQbankManagecategoriesStateUpdated=_exports.eventTypes=void 0;const eventTypes={qbankManagecategoriesStateUpdated:"qbank_managecategories/stateUpdated"};_exports.eventTypes=eventTypes;_exports.notifyQbankManagecategoriesStateUpdated=(detail,container)=>(0,_event_dispatcher.dispatchEvent)(eventTypes.qbankManagecategoriesStateUpdated,detail,container)})); //# sourceMappingURL=events.min.js.mapPK\?Jq0bank/managecategories/amd/build/mutations.min.jsnu[define("qbank_managecategories/mutations",["exports","core_user/repository"],(function(_exports,_repository){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.mutations=void 0;const mutations=new class{async toggleDescriptions(stateManager,showDescriptions){stateManager.setReadOnly(!1),await(0,_repository.setUserPreference)("qbank_managecategories_showdescriptions",showDescriptions),stateManager.state.page.showdescriptions=showDescriptions,stateManager.setReadOnly(!0)}async showDragHandle(stateManager,categoryId){stateManager.setReadOnly(!1),stateManager.state.categories.get(categoryId).draghandle=!0,stateManager.setReadOnly(!0)}};_exports.mutations=mutations})); //# sourceMappingURL=mutations.min.js.mapPK\Xނ 6bank/managecategories/amd/build/addcategory.min.js.mapnu[{"version":3,"file":"addcategory.min.js","sources":["../src/addcategory.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Add category button for displaying the modal form.\n *\n * This just connects up the button to the showEditModal listener.\n *\n * @module qbank_managecategories/addcategory\n * @class qbank_managecategories/addcategory\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n ADD_BUTTON: '[data-action=\"addeditcategory\"]',\n };\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.ADD_BUTTON), 'click', categorymanager.showEditModal);\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const targetElement = document.querySelector(target);\n return new this({\n element: targetElement,\n selectors,\n reactive: categorymanager,\n });\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","ADD_BUTTON","stateReady","addEventListener","this","getElement","categorymanager","showEditModal","target","document","querySelector","reactive"],"mappings":"uQA2B6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,WAAY,mCAIpBC,kBACSC,iBAAiBC,KAAKC,WAAWD,KAAKJ,UAAUC,YAAa,QAASK,iCAAgBC,2BAUnFC,OAAQR,kBAET,IAAII,KAAK,CACZN,QAFkBW,SAASC,cAAcF,QAGzCR,UAAAA,UACAW,SAAUL"}PK\:g_a2bank/managecategories/amd/build/addcategory.min.jsnu[define("qbank_managecategories/addcategory",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={ADD_BUTTON:'[data-action="addeditcategory"]'}}stateReady(){this.addEventListener(this.getElement(this.selectors.ADD_BUTTON),"click",_categorymanager.categorymanager.showEditModal)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=addcategory.min.js.mapPK\Lbank/managecategories/amd/build/datafilter/filtertypes/categories.min.js.mapnu[{"version":3,"file":"categories.min.js","sources":["../../../src/datafilter/filtertypes/categories.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Filter managing display of subcategories questions.\n *\n * @module qbank_managecategories/datafilter/filtertypes/categories\n * @author Mark Johnson \n * @copyright 2023 Catalyst IT Europe Ltd.\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport GenericFilter from 'core/datafilter/filtertype';\nimport Templates from 'core/templates';\nimport {getUserPreference, setUserPreference} from 'core_user/repository';\nimport Notification from 'core/notification';\nimport {get_strings as getStrings} from 'core/str';\n\nexport default class extends GenericFilter {\n\n SELECTORS = {\n includeSubcategories: 'input[name=category-subcategories]',\n selectInput: 'select#filter-value-category',\n selectInputOption: 'select#filter-value-category option',\n validationInput: 'div[data-filter-type=\"category\"] div.form-autocomplete-input input',\n };\n\n /**\n * Construct a new categoires filter\n *\n * @param {String} filterType The type of filter that this relates to (categories)\n * @param {HTMLElement} rootNode The root node for the participants filterset\n * @param {Array} initialValues The currently selected category IDs.\n * @param {Object} filterOptions An object containing the additional options for the filter, currently \"includesubcategories\"\n * is supported, which if true will display the \"Also show questions from subcategories\" checkbox as checked.\n */\n constructor(filterType, rootNode, initialValues, filterOptions = {includesubcategories: false}) {\n super(filterType, rootNode, initialValues);\n this.addSubcategoryCheckbox(filterOptions.includesubcategories);\n }\n\n async addSubcategoryCheckbox(checked = null) {\n const filterValueNode = this.getFilterValueNode();\n if (checked === null || checked === undefined) {\n checked = await getUserPreference('qbank_managecategories_includesubcategories_filter_default');\n } else {\n setUserPreference('qbank_managecategories_includesubcategories_filter_default', checked);\n }\n const {html} = await Templates.renderForPromise('qbank_managecategories/include_subcategories_checkbox', {\n checked: checked && checked !== '0',\n });\n filterValueNode.insertAdjacentHTML('afterend', html);\n }\n\n get filterOptions() {\n return [\n {name: 'includesubcategories', value: this.filterRoot.querySelector(this.SELECTORS.includeSubcategories).checked}\n ];\n }\n\n get filterValue() {\n return {\n name: this.name,\n jointype: this.jointype,\n values: this.values,\n filteroptions: this.filterOptions,\n };\n }\n\n validate() {\n\n // Get the possible option values and filter out empty ones.\n const nodelist = this.filterRoot.querySelectorAll(this.SELECTORS.selectInputOption);\n\n // This gets all the values from the autocomplete's hidden select menu and filters out\n // any which are not positive integers.\n const validoptions = Array.from(nodelist).map(\n option => (parseInt(option.value) > 0) ? parseInt(option.value) : false\n ).filter(Boolean);\n\n // Get the value supplied.\n const value = parseInt(this.filterRoot.querySelector(this.SELECTORS.selectInput).value);\n\n // Reset validation.\n const node = this.filterRoot.querySelector(this.SELECTORS.validationInput);\n node.setCustomValidity('');\n\n // Make sure the value supplied is valid.\n if (!validoptions.includes(value)) {\n getStrings([\n {\n key: 'error:category',\n component: 'qbank_managecategories',\n },\n ]).then((strings) => {\n node.setCustomValidity(strings[0]);\n node.reportValidity();\n return strings;\n }).catch(Notification.exception);\n\n return false;\n }\n\n return true;\n\n }\n\n}\n"],"names":["GenericFilter","constructor","filterType","rootNode","initialValues","filterOptions","includesubcategories","includeSubcategories","selectInput","selectInputOption","validationInput","addSubcategoryCheckbox","checked","filterValueNode","this","getFilterValueNode","html","Templates","renderForPromise","insertAdjacentHTML","name","value","filterRoot","querySelector","SELECTORS","filterValue","jointype","values","filteroptions","validate","nodelist","querySelectorAll","validoptions","Array","from","map","option","parseInt","filter","Boolean","node","setCustomValidity","includes","key","component","then","strings","reportValidity","catch","Notification","exception"],"mappings":"ukBA8B6BA,oBAkBzBC,YAAYC,WAAYC,SAAUC,mBAAeC,qEAAgB,CAACC,sBAAsB,2BAC9EJ,WAAYC,SAAUC,qBAjBpB,CACRG,qBAAsB,qCACtBC,YAAa,+BACbC,kBAAmB,sCACnBC,gBAAiB,+MAcZC,uBAAuBN,cAAcC,yDAGjBM,+DAAU,WAC7BC,gBAAkBC,KAAKC,qBACzBH,MAAAA,QACAA,cAAgB,iCAAkB,gGAEhB,6DAA8DA,eAE9EI,KAACA,YAAcC,mBAAUC,iBAAiB,wDAAyD,CACrGN,QAASA,SAAuB,MAAZA,UAExBC,gBAAgBM,mBAAmB,WAAYH,MAG/CX,0BACO,CACH,CAACe,KAAM,uBAAwBC,MAAOP,KAAKQ,WAAWC,cAAcT,KAAKU,UAAUjB,sBAAsBK,UAI7Ga,wBACO,CACHL,KAAMN,KAAKM,KACXM,SAAUZ,KAAKY,SACfC,OAAQb,KAAKa,OACbC,cAAed,KAAKT,eAI5BwB,iBAGUC,SAAWhB,KAAKQ,WAAWS,iBAAiBjB,KAAKU,UAAUf,mBAI3DuB,aAAeC,MAAMC,KAAKJ,UAAUK,KACtCC,QAAWC,SAASD,OAAOf,OAAS,GAAKgB,SAASD,OAAOf,SAC3DiB,OAAOC,SAGHlB,MAAQgB,SAASvB,KAAKQ,WAAWC,cAAcT,KAAKU,UAAUhB,aAAaa,OAG3EmB,KAAO1B,KAAKQ,WAAWC,cAAcT,KAAKU,UAAUd,wBAC1D8B,KAAKC,kBAAkB,MAGlBT,aAAaU,SAASrB,8BACZ,CACP,CACIsB,IAAK,iBACLC,UAAW,4BAEhBC,MAAMC,UACLN,KAAKC,kBAAkBK,QAAQ,IAC/BN,KAAKO,iBACED,WACRE,MAAMC,sBAAaC,YAEf"}PK\^[ Hbank/managecategories/amd/build/datafilter/filtertypes/categories.min.jsnu[define("qbank_managecategories/datafilter/filtertypes/categories",["exports","core/datafilter/filtertype","core/templates","core_user/repository","core/notification","core/str"],(function(_exports,_filtertype,_templates,_repository,_notification,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_filtertype=_interopRequireDefault(_filtertype),_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification);class _default extends _filtertype.default{constructor(filterType,rootNode,initialValues){let filterOptions=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{includesubcategories:!1};var obj,key,value;super(filterType,rootNode,initialValues),value={includeSubcategories:"input[name=category-subcategories]",selectInput:"select#filter-value-category",selectInputOption:"select#filter-value-category option",validationInput:'div[data-filter-type="category"] div.form-autocomplete-input input'},(key="SELECTORS")in(obj=this)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,this.addSubcategoryCheckbox(filterOptions.includesubcategories)}async addSubcategoryCheckbox(){let checked=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;const filterValueNode=this.getFilterValueNode();null==checked?checked=await(0,_repository.getUserPreference)("qbank_managecategories_includesubcategories_filter_default"):(0,_repository.setUserPreference)("qbank_managecategories_includesubcategories_filter_default",checked);const{html:html}=await _templates.default.renderForPromise("qbank_managecategories/include_subcategories_checkbox",{checked:checked&&"0"!==checked});filterValueNode.insertAdjacentHTML("afterend",html)}get filterOptions(){return[{name:"includesubcategories",value:this.filterRoot.querySelector(this.SELECTORS.includeSubcategories).checked}]}get filterValue(){return{name:this.name,jointype:this.jointype,values:this.values,filteroptions:this.filterOptions}}validate(){const nodelist=this.filterRoot.querySelectorAll(this.SELECTORS.selectInputOption),validoptions=Array.from(nodelist).map((option=>parseInt(option.value)>0&&parseInt(option.value))).filter(Boolean),value=parseInt(this.filterRoot.querySelector(this.SELECTORS.selectInput).value),node=this.filterRoot.querySelector(this.SELECTORS.validationInput);return node.setCustomValidity(""),!!validoptions.includes(value)||((0,_str.get_strings)([{key:"error:category",component:"qbank_managecategories"}]).then((strings=>(node.setCustomValidity(strings[0]),node.reportValidity(),strings))).catch(_notification.default.exception),!1)}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=categories.min.js.mapPK\F##/bank/managecategories/amd/build/category.min.jsnu[define("qbank_managecategories/category",["exports","core/reactive","qbank_managecategories/categorymanager","core/templates","core/modal","core/str"],(function(_exports,_reactive,_categorymanager,_templates,_modal,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_modal=_interopRequireDefault(_modal);class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={CATEGORY_LIST:".qbank_managecategories-categorylist",CATEGORY_ITEM:".qbank_managecategories-item[data-categoryid]",CATEGORY_CONTENTS:".qbank_managecategories-item > .container",EDIT_BUTTON:'[data-action="addeditcategory"]',MOVE_BUTTON:'[role="menuitem"][data-actiontype="move"]',CONTEXT:".qbank_managecategories-categorylist[data-contextid]",MODAL_CATEGORY_ITEM:".modal_category_item[data-movingcategoryid]",CONTENT_AREA:".qbank_managecategories-details",CATEGORY_ID:id=>"#category-".concat(id),CONTENT_CONTAINER:id=>"#category-".concat(id," .qbank_managecategories-childlistcontainer"),CHILD_LIST:id=>'ul[data-categoryid="'.concat(id,'"]'),PREVIOUS_SIBLING:sortorder=>':scope > [data-sortorder="'.concat(sortorder,'"]')},this.classes={NO_BOTTOM_PADDING:"pb-0",DRAGHANDLE:"draghandle",DROPTARGET:"qbank_managecategories-droptarget-before"},this.ids={CATEGORY:id=>"category-".concat(id)}}stateReady(){this.initDragDrop(),this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON),"click",_categorymanager.categorymanager.showEditModal);const moveButton=this.getElement(this.selectors.MOVE_BUTTON);this.addEventListener(moveButton,"click",this.showMoveModal)}destroy(){this.deInitDragDrop()}initDragDrop(){this.deInitDragDrop(),this.element.classList.contains(this.classes.DRAGHANDLE)&&(this.getDraggableData=this._getDraggableData),this.dragdrop=new _reactive.DragDrop(this)}deInitDragDrop(){void 0!==this.dragdrop&&(void 0!==this.getDraggableData&&(this.dragdrop.setDraggable(!1),this.getDraggableData=void 0),this.dragdrop.unregister(),this.dragdrop=void 0)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}_getDraggableData(){return{id:this.getElement().dataset.categoryid}}validateDropData(){return!0}showDropZone(dropData){return!this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))&&(this.getElement().classList.add(this.classes.DROPTARGET),!0)}hideDropZone(){this.getElement().classList.remove(this.classes.DROPTARGET)}drop(dropData,event){var _precedingSibling;const dropTarget=event.target.closest(this.selectors.CATEGORY_ITEM);if(!dropTarget)return;if(dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id)))return;if(!document.getElementById(this.ids.CATEGORY(dropData.id)))return;const targetParentId=dropTarget.dataset.parent,parentList=dropTarget.closest(this.selectors.CATEGORY_LIST);let precedingSibling;precedingSibling=dropTarget===parentList.firstElementChild?null:dropTarget.previousElementSibling,_categorymanager.categorymanager.moveCategory(dropData.id,targetParentId,null===(_precedingSibling=precedingSibling)||void 0===_precedingSibling?void 0:_precedingSibling.dataset.categoryid)}getWatchers(){return[{watch:"categories[".concat(this.element.dataset.categoryid,"]:updated"),handler:this.updatePosition},{watch:"categories[".concat(this.element.dataset.categoryid,"].templatecontext:created"),handler:this.rerender},{watch:"categories[".concat(this.element.dataset.categoryid,"].templatecontext:updated"),handler:this.rerender},{watch:"categories:created",handler:this.checkChildList}]}async rerender(_ref){let{element:element}=_ref;const{html:html,js:js}=await _templates.default.renderForPromise("qbank_managecategories/category_details",element.templatecontext);return _templates.default.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA),html,js)}async createChildList(context){const{html:html,js:js}=await _templates.default.renderForPromise("qbank_managecategories/childlist",context),parentContainer=document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));await _templates.default.appendNodeContents(parentContainer,html,js);const childList=document.querySelector(this.selectors.CHILD_LIST(context.categoryid));return childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING),childList}async updatePosition(_ref2){let newParent,{element:element}=_ref2;const originParent=document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));let previousSibling,nextSibling;var _previousSibling;(parseInt(this.getElement().dataset.parent)!==element.parent?(newParent=document.querySelector(this.selectors.CHILD_LIST(element.parent)),newParent||(newParent=await this.createChildList({categoryid:element.parent})),this.getElement().dataset.parent=element.parent):newParent=this.getElement().parentElement,newParent.firstElementChild&&parseInt(element.sortorder)<=parseInt(newParent.firstElementChild.dataset.sortorder))?nextSibling=newParent.firstElementChild:(previousSibling=newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder-1)),nextSibling=null===(_previousSibling=previousSibling)||void 0===_previousSibling?void 0:_previousSibling.nextElementSibling);(newParent!==this.getElement().parentElement||nextSibling!==this.getElement())&&(nextSibling?newParent.insertBefore(this.getElement(),nextSibling):newParent.appendChild(this.getElement())),originParent!==newParent&&this.reactive.stateManager.processUpdates([{name:"categoryLists",action:"put",fields:{id:originParent.dataset.categoryid,childCount:originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length}},{name:"categoryLists",action:"put",fields:{id:newParent.dataset.categoryid,childCount:newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length}}]),this.element.dataset.sortorder=element.sortorder;const isDraggable=this.element.classList.contains(this.classes.DRAGHANDLE);isDraggable&&!element.draghandle?(this.element.classList.remove(this.classes.DRAGHANDLE),this.initDragDrop()):!isDraggable&&element.draghandle&&(this.element.classList.add(this.classes.DRAGHANDLE),this.initDragDrop())}createMoveCategoryList(item,movingCategoryId){const categories=[];if(item.children){let precedingSibling=null;if(item.children.forEach((category=>{var _precedingSibling$dat,_precedingSibling2;const categoryId=parseInt(category.dataset.categoryid);if(categoryId===movingCategoryId)return;let child={categoryid:categoryId,movingcategoryid:movingCategoryId,precedingsiblingid:null!==(_precedingSibling$dat=null===(_precedingSibling2=precedingSibling)||void 0===_precedingSibling2?void 0:_precedingSibling2.dataset.categoryid)&&void 0!==_precedingSibling$dat?_precedingSibling$dat:0,parent:category.dataset.parent,categoryname:category.dataset.categoryname,categories:null,current:categoryId===movingCategoryId};const childList=category.querySelector(this.selectors.CATEGORY_LIST);child.categories=childList?this.createMoveCategoryList(childList,movingCategoryId):[{movingcategoryid:movingCategoryId,precedingsiblingid:0,parent:categoryId,categoryname:category.dataset.categoryname,categories:null,newchild:!0}],categories.push(child),precedingSibling=category})),precedingSibling){const precedingId=parseInt(precedingSibling.dataset.categoryid);precedingId!==movingCategoryId&&categories.push({movingcategoryid:movingCategoryId,precedingsiblingid:precedingId,parent:precedingSibling.dataset.parent,categoryname:precedingSibling.dataset.categoryname,categories:null,lastchild:!0})}}return categories}async showMoveModal(e){const item=e.target.closest(this.selectors.MOVE_BUTTON);if(!item)return;if("true"===item.getAttribute("aria-disabled"))return;item.setAttribute("aria-disabled",!0);let moveList={contexts:[]};document.querySelectorAll(this.selectors.CONTEXT).forEach((context=>{const moveContext={contextname:context.dataset.contextname,categories:[],hascategories:!1};moveContext.categories=this.createMoveCategoryList(context,parseInt(item.dataset.categoryid)),moveContext.hascategories=moveContext.categories.length>0,moveList.contexts.push(moveContext)}));const modal=await _modal.default.create({title:(0,_str.get_string)("movecategory","qbank_managecategories",item.dataset.categoryname),body:_templates.default.render("qbank_managecategories/move_context_list",moveList),footer:"",show:!0,large:!0});modal.getBody()[0].addEventListener("click",(e=>{const target=e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);target&&(_categorymanager.categorymanager.moveCategory(target.dataset.movingcategoryid,target.dataset.parent,target.dataset.precedingsiblingid),modal.destroy())})),item.setAttribute("aria-disabled",!1)}async checkChildList(_ref3){let{element:element}=_ref3;return element.parent!==this.getElement().dataset.categoryid||this.getElement(this.selectors.CATEGORY_LIST)?null:this.createChildList({categoryid:element.parent,children:[element.templatecontext]})}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=category.min.js.mapPK\dY5 5 7bank/managecategories/amd/build/categoryroot.min.js.mapnu[{"version":3,"file":"categoryroot.min.js","sources":["../src/categoryroot.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The category root component.\n *\n * @module qbank_managecategories/categoryroot\n * @class qbank_managecategories/categoryroot\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.classes = {\n SHOWDESCRIPTIONS: 'showdescriptions',\n };\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Watch for changes to the page state.\n *\n * @return {Array} A list of watchers.\n */\n getWatchers() {\n return [\n // Watch for descriptions being toggled.\n {watch: `page.showdescriptions:updated`, handler: this.toggleDescriptions}\n ];\n }\n\n /**\n * Show or hide descriptions when the flag in the state is changed.\n *\n * @param {Object} args\n * @param {Object} args.element The updated page state.\n */\n toggleDescriptions({element}) {\n if (element.showdescriptions) {\n this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS);\n } else {\n this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS);\n }\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","classes","SHOWDESCRIPTIONS","target","selectors","this","document","querySelector","reactive","categorymanager","getWatchers","watch","handler","toggleDescriptions","showdescriptions","getElement","classList","add","remove"],"mappings":"wQAyB6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,QAAU,CACXC,iBAAkB,gCAWdC,OAAQC,kBACT,IAAIC,KAAK,CACZN,QAASO,SAASC,cAAcJ,QAChCC,UAAAA,UACAI,SAAUC,mCASlBC,oBACW,CAEH,CAACC,sCAAwCC,QAASP,KAAKQ,qBAU/DA,6BAAmBd,QAACA,cACZA,QAAQe,sBACHC,aAAaC,UAAUC,IAAIZ,KAAKJ,QAAQC,uBAExCa,aAAaC,UAAUE,OAAOb,KAAKJ,QAAQC"}PK\y7tt7bank/managecategories/amd/build/showdescriptions.min.jsnu[define("qbank_managecategories/showdescriptions",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={TOGGLE:"#showdescriptions-toggle"}}stateReady(){this.addEventListener(this.getElement(this.selectors.TOGGLE),"change",this.updateShowDescriptions)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}async updateShowDescriptions(event){const checked=event.target.checked;this.reactive.dispatch("toggleDescriptions",checked)}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=showdescriptions.min.js.mapPK\63bank/managecategories/amd/build/categoryroot.min.jsnu[define("qbank_managecategories/categoryroot",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.classes={SHOWDESCRIPTIONS:"showdescriptions"}}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}getWatchers(){return[{watch:"page.showdescriptions:updated",handler:this.toggleDescriptions}]}toggleDescriptions(_ref){let{element:element}=_ref;element.showdescriptions?this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS):this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS)}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=categoryroot.min.js.mapPK\q4f' ' "bank/managecategories/UPGRADING.mdnu[# qbank_managecategories Upgrade notes ## 4.5 ### Changed - The `\qbank_managecategories\question_category_object` class has been deprecated. Methods previously part of this class have been moved to either - `\qbank_managecategories\question_categories`, for the parts used within this plugin for display a list of categories; or `\core_question\category_manager`, for the parts used for generate CRUD operations on question categories, including outside of this plugin. This change will allow `\qbank_managecategories\question_category_object` to be deprecated, and avoids other parts of the system wishing to manipulate question categories from having to violate cross-component communication rules. For more information see [MDL-72397](https://tracker.moodle.org/browse/MDL-72397) ### Deprecated - Category lists are now generated by templates. The following classes have been deprecated: - `\qbank_managecategories\question_category_list` - `\qbank_managecategories\question_category_list_item` For more information see [MDL-72397](https://tracker.moodle.org/browse/MDL-72397) - The following methods of `\qbank_managecategories\helper`have been deprecated and moved to `\core_question\category_manager`: | Method | Replacement | | --- | --- | | `question_is_only_child_of_top_category_in_context` | `\core_question\category_manager::is_only_child_of_top_category_in_context` | | `question_is_top_category` | `\core_question\category_manager::is_top_category` | | `question_can_delete_cat` | `\core_question\category_manager::can_delete_cat` | For more information see [MDL-72397](https://tracker.moodle.org/browse/MDL-72397) - `\qbank_managecategories\question_category_object` is now completely deprecated. Its methods have either been migrated to `\qbank_managecategories\question_categories`, `\core_question\category_manager`, or are no longer used at all. For more information see [MDL-72397](https://tracker.moodle.org/browse/MDL-72397) PK\Bӳ>>+bank/managecategories/tests/helper_test.phpnu[. namespace qbank_managecategories; use core\exception\moodle_exception; use core_question\category_manager; defined('MOODLE_INTERNAL') || die; global $CFG; require_once($CFG->dirroot . '/question/bank/managecategories/tests/manage_category_test_base.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Unit tests for helper class. * * @package qbank_managecategories * @copyright 2006 The Open University * @author 2021, Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_managecategories\helper */ final class helper_test extends manage_category_test_base { use \quiz_question_helper_test_trait; /** * @var \context_module module context. */ protected $context; /** * @var \stdClass course object. */ protected $course; /** * @var \component_generator_base question generator. */ protected $qgenerator; /** * @var \stdClass quiz object. */ protected $quiz; /** * Tests initial setup. */ protected function setUp(): void { parent::setUp(); self::setAdminUser(); $this->resetAfterTest(); $datagenerator = $this->getDataGenerator(); $this->course = $datagenerator->create_course(); $this->quiz = $datagenerator->create_module( 'quiz', ['course' => $this->course->id, 'name' => 'Quiz 1'], ); $this->qgenerator = $datagenerator->get_plugin_generator('core_question'); $this->context = \context_module::instance($this->quiz->cmid); } /** * Test question_remove_stale_questions_from_category function. * * @covers ::question_remove_stale_questions_from_category */ public function test_question_remove_stale_questions_from_category(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); // Quiz and its context. $quiz = $this->create_quiz(); // Create category 1 and one question. $qcat1 = $this->create_question_category_for_a_quiz($quiz); $q1a = $this->create_question_in_a_category('shortanswer', $qcat1->id); $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q1a->id]); // Create category 2 and two questions. $qcat2 = $this->create_question_category_for_a_quiz($quiz); $q2a = $this->create_question_in_a_category('shortanswer', $qcat2->id); $q2b = $this->create_question_in_a_category('shortanswer', $qcat2->id); $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2a->id]); $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2b->id]); // Add question to the quiz. quiz_add_quiz_question($q2b->id, $quiz); // Adding a new random question does not add a new question, adds a question_set_references record. $this->add_random_questions($quiz->id, 0, $qcat2->id, 1); // We added one random question to the quiz and we expect the quiz to have only one random question. $q2d = $DB->get_record_sql( "SELECT qsr.* FROM {quiz_slots} qs JOIN {question_set_references} qsr ON qsr.itemid = qs.id WHERE qs.quizid = ? AND qsr.component = ? AND qsr.questionarea = ?", [$quiz->id, 'mod_quiz', 'slot'], MUST_EXIST ); // The following 2 lines have to be after the quiz_add_random_questions() call above. // Otherwise, quiz_add_random_questions() will to be "smart" and use them instead of creating a new "random" question. $q1b = $this->create_question_in_a_category('random', $qcat1->id); $q2c = $this->create_question_in_a_category('random', $qcat2->id); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $manager = new category_manager(); $this->assertEquals(2, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts))); $this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts))); // Non-existing category, nothing will happen. helper::question_remove_stale_questions_from_category(0); $this->assertEquals(2, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts))); $this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts))); // First category, should be empty afterwards. helper::question_remove_stale_questions_from_category($qcat1->id); $this->assertEquals(0, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts))); $this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts))); $this->assertFalse($DB->record_exists('question', ['id' => $q1a->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q1b->id])); // Second category, used questions should be left untouched. helper::question_remove_stale_questions_from_category($qcat2->id); $this->assertEquals(0, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts))); $this->assertEquals(1, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts))); $this->assertFalse($DB->record_exists('question', ['id' => $q2a->id])); $this->assertTrue($DB->record_exists('question', ['id' => $q2b->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q2c->id])); $this->assertTrue($DB->record_exists( 'question_set_references', ['id' => $q2d->id, 'component' => 'mod_quiz', 'questionarea' => 'slot'], )); } /** * Test delete top category in function question_can_delete_cat. * * @covers ::question_can_delete_cat * @covers ::question_is_top_category */ public function test_question_can_delete_cat_top_category(): void { $qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); // Try to delete a top category. $categorytop = question_get_top_category($qcategory1->contextid, true)->id; try { helper::question_can_delete_cat($categorytop); } catch (moodle_exception $e) { $this->assertEquals(get_string('cannotdeletetopcat', 'question'), $e->getMessage()); } $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' . 'Moved to core namespace. ' . 'Use core_question\category_manager::can_delete_category instead. ' . 'See MDL-72397 for more information.', ); } /** * Test delete only child category in function question_can_delete_cat. * * @covers ::question_can_delete_cat * @covers ::question_is_only_child_of_top_category_in_context */ public function test_question_can_delete_cat_child_category(): void { $qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); // Try to delete an only child of top category having also at least one child. try { helper::question_can_delete_cat($qcategory1->id); } catch (moodle_exception $e) { $this->assertEquals(get_string('cannotdeletecate', 'question'), $e->getMessage()); } $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' . 'Moved to core namespace. ' . 'Use core_question\category_manager::can_delete_category instead. ' . 'See MDL-72397 for more information.', ); } /** * Test delete category in function question_can_delete_cat without capabilities. * * @covers ::question_can_delete_cat */ public function test_question_can_delete_cat_capability(): void { $qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]); // This call should not throw an exception as admin user has the capabilities moodle/question:managecategory. helper::question_can_delete_cat($qcategory2->id); // Try to delete a category with and user without the capability. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); try { helper::question_can_delete_cat($qcategory2->id); } catch (\required_capability_exception $e) { $this->assertEquals( get_string('nopermissions', 'error', get_string('question:managecategory', 'role')), $e->getMessage(), ); } $message = 'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' . 'Moved to core namespace. ' . 'Use core_question\category_manager::can_delete_category instead. ' . 'See MDL-72397 for more information.'; $this->assertdebuggingcalledcount(2, [$message, $message]); } /** * Test question_category_select_menu function. * * @covers ::question_category_select_menu * @covers ::question_category_options */ public function test_question_category_select_menu(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create category. $quiz = $this->create_quiz(); $this->create_question_category_for_a_quiz($quiz, ['name' => 'Test this question category']); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); ob_start(); helper::question_category_select_menu($contexts->having_cap('moodle/question:add')); $output = ob_get_clean(); // Test the select menu of question categories output. $this->assertStringContainsString('Question category', $output); $this->assertStringContainsString('Test this question category', $output); } /** * Test that question_category_options function returns the correct category tree. * * @covers ::question_category_options * @covers ::get_categories_for_contexts * @covers ::question_fix_top_names * @covers ::question_add_context_in_key * @covers ::add_indented_names */ public function test_question_category_options(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create categories. $quiz = $this->create_quiz(); $context = \context_module::instance($quiz->cmid); $qcategory1 = question_get_default_category($context->id); $this->create_question_category_for_a_quiz($quiz, ['parent' => $qcategory1->id]); $this->create_question_category_for_a_quiz($quiz); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); // Validate that we have the array with the categories tree. $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add')); // The quiz name 'Quiz 1' is set in setUp function. $categorycontext = $categorycontexts['Quiz: ' . $quiz->name]; $this->assertCount(3, $categorycontext); // Validate that we have the array with the categories tree and that top category is there. $newcategorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add'), true); foreach ($newcategorycontexts as $key => $categorycontext) { $oldcategorycontext = $categorycontexts[$key]; $count = count($oldcategorycontext); $this->assertCount($count + 1, $categorycontext); } } /** * Test that question_category_options function does not include the current category. * * @covers ::question_category_options */ public function test_question_category_options_exclude_current(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create categories. $quiz = $this->create_quiz(); $context = \context_module::instance($quiz->cmid); $qcategory1 = question_get_default_category($context->id); $qcategory2 = $this->create_question_category_for_a_quiz($quiz, ['parent' => $qcategory1->id]); $qcategory3 = $this->create_question_category_for_a_quiz($quiz); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add')); // We get all categories without the currentcat parameter. $categorycontext = $categorycontexts['Quiz: ' . $quiz->name]; $this->assertCount(3, $categorycontext); // The currentcat category is excluded. $newcategorycontexts = helper::question_category_options( $contexts->having_cap('moodle/question:add'), currentcat: $qcategory2->id, ); $newcategorycontext = $newcategorycontexts['Quiz: ' . $quiz->name]; $this->assertCount(2, $newcategorycontext); $this->assertContains($qcategory1->name, $newcategorycontext); $this->assertNotContains($qcategory2->name, $newcategorycontext); $this->assertContains($qcategory3->name, $newcategorycontext); } /** * Test that get_categories_for_contexts function returns the correct question count number. * * @covers ::get_categories_for_contexts */ public function test_question_category_question_count(): void { global $DB; // Create quiz. $quiz = $this->quiz; $context = \context_module::instance($quiz->cmid); // Get the question category and create one hidden question. $qcat = question_get_default_category($context->id); $q1 = $this->create_question_in_a_category('shortanswer', $qcat->id); $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q1->id]); $contexts = new \core_question\local\bank\question_edit_contexts($context); $contexts = $contexts->having_cap('moodle/question:add'); foreach ($contexts as $context) { $contextslist[] = $context->id; } $contextslist = join(', ', $contextslist); // Verify we have 0 question in category since it is hidden. $categorycontexts = helper::get_categories_for_contexts($contextslist); $this->assertEquals(0, reset($categorycontexts)->questioncount); // Add an extra question. $this->create_question_in_a_category('shortanswer', $qcat->id); $categorycontexts = helper::get_categories_for_contexts($contextslist); // Verify we have 1 question in category. $this->assertEquals(1, reset($categorycontexts)->questioncount); } } PK\j@@;bank/managecategories/tests/external/move_category_test.phpnu[. namespace qbank_managecategories\external; use context; use context_module; use moodle_url; use qbank_managecategories\question_categories; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../manage_category_test_base.php'); /** * Unit tests for move_category * * @package qbank_managecategories * @copyright 2024 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 \qbank_managecategories\external\move_category */ final class move_category_test extends \qbank_managecategories\manage_category_test_base { /** * Return order of categories for a given context. * * @param context $context The context to get the category order for. * @return array Nested array, keyed by category IDs. */ private function get_current_order(context $context): array { $categories = new question_categories(new moodle_url('/'), [$context]); return $this->reduce_tree($categories->editlists[$context->id]->items); } /** * Reduce the ordered tree of categories to a multi-dimensional array of IDs for easier comparison. * * @param array $tree Tree of categories from question_categories. * @return array */ private function reduce_tree(array $tree): array { $result = []; foreach ($tree as $category) { $result[$category->id] = []; if (isset($category->children) && !empty((array)$category->children)) { $result[$category->id] = $this->reduce_tree($category->children); } } return $result; } /** * Move a category below another category within the same parent. * * @return void */ public function test_move_category_down(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $course = $this->create_course(); $qbank = $this->create_qbank($course); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank); $this->assertEquals(999, $qcat1->sortorder); $this->assertEquals(1000, $qcat2->sortorder); $this->assertEquals(1001, $qcat3->sortorder); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [], $qcat2->id => [], $qcat3->id => [], ]; $this->assertEquals($expectedorder, $currentorder); // Move category 1 after category 2. $stateupdates = move_category::execute($context->id, $qcat1->id, $qcat1->parent, $qcat2->id); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat2->id => [], $qcat1->id => [], $qcat3->id => [], ]; $this->assertEquals($newexpectedorder, $neworder); // We should have an update to the sortorder of the moved category and following categories. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat1->id, 'sortorder' => 1001, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat3->id, 'sortorder' => 1002, ], ], ]; $this->assertEquals($stateupdates, $expectedstateupdates); } /** * Move a category to the top of its parent. * * @return void * @throws \moodle_exception */ public function test_move_category_to_top(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $qbank = $this->create_qbank($this->create_course()); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [], $qcat2->id => [], $qcat3->id => [], ]; $this->assertEquals($expectedorder, $currentorder); // Move category 3 to the top. $stateupdates = move_category::execute($context->id, $qcat3->id, $qcat3->parent); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat3->id => [], $qcat1->id => [], $qcat2->id => [], ]; $this->assertEquals($newexpectedorder, $neworder); // Expecting all categories to have an updated sortorder. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat3->id, 'sortorder' => 1, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat1->id, 'sortorder' => 2, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat2->id, 'sortorder' => 3, ], ], ]; $this->assertEquals($stateupdates, $expectedstateupdates); } /** * Move a category to a new parent that doesn't currently have any children. * * @return void */ public function test_move_category_as_new_child(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $qbank = $this->create_qbank($this->create_course()); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [], $qcat2->id => [], $qcat3->id => [], ]; $this->assertEquals($expectedorder, $currentorder); // Set Category 2 as the parent of Category 1. $stateupdates = move_category::execute($context->id, $qcat1->id, $qcat2->id); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat2->id => [ $qcat1->id => [], ], $qcat3->id => [], ]; $this->assertEquals($newexpectedorder, $neworder); // Expecting an update to the parent and sortorder of the moved category, and updated sortorders for the children // of the original parent. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat1->id, 'sortorder' => 1, 'parent' => $qcat2->id, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat2->id, 'sortorder' => 1, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat3->id, 'sortorder' => 2, ], ], ]; $this->assertEquals($stateupdates, $expectedstateupdates); } /** * Move a category from one parent to another that already has a child. * * @return void * @throws \moodle_exception */ public function test_move_category_between_parents(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $qbank = $this->create_qbank($this->create_course()); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat1->id]); $qcat4 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat2->id]); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [ $qcat3->id => [], ], $qcat2->id => [ $qcat4->id => [], ], ]; $this->assertEquals($expectedorder, $currentorder); // Set Category 2 as the parent of Category 1. $stateupdates = move_category::execute($context->id, $qcat3->id, $qcat2->id, $qcat4->id); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat1->id => [], $qcat2->id => [ $qcat4->id => [], $qcat3->id => [], ], ]; $this->assertEquals($newexpectedorder, $neworder); // As there are no remaining children of the original parent, and this was moved to the bottom of the new parent, // just the moved category is updated. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat3->id, 'sortorder' => 2, 'parent' => $qcat2->id, ], ], ]; $this->assertEquals($stateupdates, $expectedstateupdates); } /** * Move a category that has its own children. * * The children should move with the parent. * * @return void */ public function test_move_category_with_children(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $qbank = $this->create_qbank($this->create_course()); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank); $qcat4 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat3->id]); $qcat5 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat3->id]); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [], $qcat2->id => [], $qcat3->id => [ $qcat4->id => [], $qcat5->id => [], ], ]; $this->assertEquals($expectedorder, $currentorder); $stateupdates = move_category::execute($context->id, $qcat3->id, $qcat3->parent, $qcat1->id); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat1->id => [], $qcat3->id => [ $qcat4->id => [], $qcat5->id => [], ], $qcat2->id => [], ]; $this->assertEquals($neworder, $newexpectedorder); // Update the sortorder of the moving category and the following sibling. No updates to the children are required. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat3->id, 'sortorder' => 1000, ], ], (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat2->id, 'sortorder' => 1001, ], ], ]; $this->assertEquals($stateupdates, $expectedstateupdates); } /** * Move a category that has its own children to a new parent. * * The children should also move and become descendants of the new parent. * * @return void */ public function test_change_parent_with_children(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create context for question categories. $qbank = $this->create_qbank($this->create_course()); $context = context_module::instance($qbank->cmid); $this->create_course_category(); // Question categories. $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank); $qcat3 = $this->create_question_category_for_a_qbank($qbank); $qcat4 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat3->id]); $qcat5 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat4->id]); // Check current order. $currentorder = $this->get_current_order($context); $expectedorder = [ $qcat1->id => [], $qcat2->id => [], $qcat3->id => [ $qcat4->id => [ $qcat5->id => [], ], ], ]; $this->assertEquals($expectedorder, $currentorder); $stateupdates = move_category::execute($context->id, $qcat4->id, $qcat2->id); $neworder = $this->get_current_order($context); $newexpectedorder = [ $qcat1->id => [], $qcat2->id => [ $qcat4->id => [ $qcat5->id => [], ], ], $qcat3->id => [], ]; $this->assertEquals($newexpectedorder, $neworder); // Expecting an update to the sortorder and parent of the moved category. No updates to the children are required. $expectedstateupdates = [ (object)[ 'name' => 'categories', 'action' => 'put', 'fields' => (object)[ 'id' => $qcat4->id, 'parent' => $qcat2->id, 'sortorder' => 1, ], ], ]; $this->assertEquals($expectedstateupdates, $stateupdates); } } PK\kx  5bank/managecategories/tests/privacy/provider_test.phpnu[. namespace qbank_managecategories\privacy; use advanced_testcase; use core_privacy\local\request\writer; use qbank_managecategories\privacy\provider; /** * Unit tests for qbank_managecategories privacy provider. * * @package qbank_managecategories * @copyright 2021 Catalyst IT Australia Pty Ltd * @author 2021, Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_managecategories\privacy\provider */ final class provider_test extends advanced_testcase { /** * Test to check export_user_preferences. * * @covers ::export_user_preferences */ public function test_export_user_preferences(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference('qbank_managecategories_showdescriptions', 1, $user); set_user_preference('qbank_managecategories_includesubcategories_filter_default', 1, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $prefs = $writer->get_user_preferences('qbank_managecategories'); $this->assertEquals(1, $prefs->showdescr->value); $this->assertEquals(1, $prefs->includesubcategories->value); $this->assertEquals(get_string('displaydescription', 'qbank_managecategories'), $prefs->showdescr->description); $this->assertEquals(get_string('questionsubcategoriesdisplayed', 'qbank_managecategories'), $prefs->includesubcategories->description); } } PK\/(6 6 Fbank/managecategories/tests/behat/question_categories_idnumber.featurenu[@qbank @qbank_managecategories @question_categories_idnumber @javascript Feature: A teacher can put questions with idnumbers in categories with idnumbers in the question bank In order to organize my questions As a teacher I create and edit categories (now with idnumbers) Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And I log in as "teacher1" And I am on "Course 1" course homepage Scenario: A new question category can only be created with a unique idnumber for a context # Note need to create the top category each time. When the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | | Activity module | qbank1 | top | Used category | c1used | And I am on the "Qbank 1" "core_question > question categories" page And I press "Add category" And I set the following fields to these values: | Name | New cat | | Parent category | Top for Qbank 1 | | Category info | Created as a test | | ID number | c1used | And I click on "Add category" "button" in the "Add category" "dialogue" # Standard warning. Then I should see "This ID number is already in use" # Correction to a unique idnumber for the context. And I set the field "ID number" to "c1unused" And I click on "Add category" "button" in the "Add category" "dialogue" Then I should see "New cat" And I should see "ID number" And I should see "c1unused" And I should see "(0)" And I click on "Show descriptions" "checkbox" And I should see "Created as a test" in the "New cat" "list_item" Scenario: A question category can be edited and saved without changing the idnumber When the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | | Activity module | qbank1 | top | Used category | c1used | And I am on the "Qbank 1" "core_question > question categories" page Then I open the action menu in "Used category" "list_item" And I choose "Edit settings" in the open action menu And I click on "Save changes" "button" in the "Edit category" "dialogue" Then I should not see "This ID number is already in use" PK\Bbank/managecategories/tests/behat/move_question_categories.featurenu[@qbank @qbank_managecategories @category_reorder @javascript Feature: A teacher can reorder question categories In order to change question category order As a teacher I need to reorder them Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | | qbank | Qbank 2 | Question bank 2 | C1 | qbank2 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "system role assigns" exist: | user | role | contextlevel | | teacher1 | editingteacher | System | And the following "question categories" exist: | contextlevel | reference | name | idnumber | | Activity module | qbank1 | Course category 1 | questioncat1 | | Activity module | qbank1 | Course category 2 | questioncat2 | | Activity module | qbank1 | Course category 3 | questioncat3 | And I am on the "Qbank 1" "core_question > question categories" page logged in as "teacher1" Scenario: Teacher cannot move or delete single category under context And I am on the "Qbank 2" "core_question > question categories" page logged in as "teacher1" When I open the action menu in "Default for Qbank 2" "list_item" Then I should not see "Delete" Scenario: Teacher can see complete edit menu if multiples categories exist under context When I open the action menu in "Course category 1" "list_item" Then I should see "Edit settings" And I should see "Delete" And I should see "Export as Moodle XML" Scenario: Teacher can move one category after another Given "Course category 1" "list_item" should appear before "Course category 2" "list_item" And "Course category 2" "list_item" should appear before "Course category 3" "list_item" When I open the action menu in "Course category 1" "list_item" And I choose "Move" in the open action menu And I click on "After Course category 3" "link" in the "Move Course category 1" "dialogue" Then "Course category 2" "list_item" should appear before "Course category 3" "list_item" And "Course category 3" "list_item" should appear before "Course category 1" "list_item" Scenario: Teacher can move one category before another Given "Course category 1" "list_item" should appear before "Course category 2" "list_item" And "Course category 2" "list_item" should appear before "Course category 3" "list_item" And I open the action menu in "Course category 3" "list_item" And I choose "Move" in the open action menu And I click on "Before Course category 1" "link" in the "Move Course category 3" "dialogue" Given "Course category 3" "list_item" should appear before "Course category 1" "list_item" And "Course category 1" "list_item" should appear before "Course category 2" "list_item" Scenario: Teacher can make a category a child of an existing category Given "Course category 1" "list_item" should appear before "Course category 2" "list_item" And "Course category 2" "list_item" should appear before "Course category 3" "list_item" And "Course category 3" "list_item" should not exist in the "Course category 1" "list_item" When I open the action menu in "Course category 3" "list_item" And I choose "Move" in the open action menu And I click on "As new child of Course category 1" "link" in the "Move Course category 3" "dialogue" And "Course category 3" "list_item" should appear before "Course category 2" "list_item" And "Course category 3" "list_item" should exist in the "Course category 1" "list_item" Scenario: Teacher can display and hide category descriptions Given I am on the "Qbank 2" "core_question > question categories" page logged in as "teacher1" When I click on "Show descriptions" "checkbox" Then I should see "The default category for questions shared in context 'Qbank 2'." And I click on "Show descriptions" "checkbox" And I should not see "The default category for questions shared in context 'Qbank 2'." Scenario: Teacher can move a category via the edit form Given "Course category 1" "list_item" should appear before "Course category 2" "list_item" And "Course category 2" "list_item" should appear before "Course category 3" "list_item" When I open the action menu in "Course category 3" "list_item" And I choose "Edit" in the open action menu And I should see "Top for Qbank 1" in the "Parent category" "select" And I should see "Course category 1" in the "Parent category" "field" And I should see "Course category 2" in the "Parent category" "field" And I should not see "Course category 3" in the "Parent category" "field" And I set the field "Parent category" to "Course category 1" And I press "Save changes" Then "Course category 1" "list_item" should appear before "Course category 3" "list_item" And "Course category 3" "list_item" should appear before "Course category 2" "list_item" And I open the action menu in "Course category 1" "list_item" And I choose "Edit" in the open action menu And I should see "Top for Qbank 1" in the "Parent category" "select" And I should not see "Course category 1" in the "Parent category" "field" And I should see "Course category 2" in the "Parent category" "field" And I should not see "Course category 3" in the "Parent category" "field" Scenario: Dragging a category over a category with no children shows an 'as new child' drop target Given the following "question categories" exist: | contextlevel | reference | name | idnumber | questioncategory | | Activity module | qbank1 | Course category 4 | questioncat4 | Course category 2 | And I reload the page And I change viewport size to "large" And I drag "Course category 3" "list_item" and I drop it in "Course category 2" "text" And I should not see "+" in the "Course category 2" "list_item" When I drag "Course category 3" "list_item" and I drop it in "Course category 1" "text" Then I should see "+" in the "Course category 1" "list_item" PK\X X Gbank/managecategories/tests/behat/view_manage_categories_plugin.featurenu[@qbank @qbank_managecategories @view_manage_categories_plugin @javascript Feature: Use the qbank plugin manager page for managecategories In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | Scenario: Enable/disable managecategories plugin from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Manage categories" And I click on "Disable" "link" in the "Manage categories" "table_row" And I am on the "Test quiz" "quiz activity" page Then "Categories" "link" should not exist in current page administration And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Manage categories" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should see "Categories" in the "Question bank tertiary navigation" "select" Scenario: Enable/disable the tab New category when trying to add a random question to a quiz Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Manage categories" And I click on "Disable" "link" in the "Manage categories" "table_row" And I am on the "Test quiz" "quiz activity" page And I click on "Add question" "link" And I open the "last" add to quiz menu And I follow "a random question" Then I should not see "New category" And I click on "cancel" "button" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Manage categories" "table_row" And I am on the "Test quiz" "quiz activity" page And I click on "Add question" "link" And I open the "last" add to quiz menu And I follow "a random question" And I should see "New category" PK\ 0\##=bank/managecategories/tests/behat/question_categories.featurenu[@qbank @qbank_managecategories @question_categories @javascript Feature: A teacher can put questions in categories in the question bank In order to organize my questions As a teacher I create and edit categories, move questions between them and filter question using category filter Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Activity module | qbank1 | Default for Qbank 1 | Subcategory & < > " ' & | | Activity module | qbank1 | Default for Qbank 1 | Another subcat | | Activity module | qbank1 | top | Used category | | Activity module | qbank1 | top | Default & testing | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Used category | essay | Test question to be moved | Write about whatever you want | | Another subcat | essay | Question 1 | Write about whatever you want | And I log in as "teacher1" Scenario: A new question category can be created When I am on the "Qbank 1" "core_question > question categories" page And I press "Add category" And I set the following fields to these values: | Name | 'Test' & 'display' | | Parent category | Default & testing | | Category info | Created for testing category, HTML entity & its encoding | | ID number | newcatidnumber | And I click on "Add category" "button" in the "Add category" "dialogue" Then I should see "Default & testing" And I should see "ID number" And I should see "newcatidnumber" And I should see "(0)" And I click on "Show descriptions" "checkbox" And I should see "Created for testing category, HTML entity & its encoding" in the "'Test' & 'display'" "list_item" And I press "Add category" And the "Parent category" select box should contain "'Test' & 'display' [newcatidnumber]" Scenario: A question category can be edited When I am on the "Qbank 1" "core_question > question categories" page # There have been bugs which only happened if a question category was not empty, so add a question. And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Subcategory & < > " ' & | essay | Test question for renaming category | Write about whatever you want | And I open the action menu in "Subcategory & < > \" ' &" "list_item" And I choose "Edit settings" in the open action menu And the field "parent" matches value "   Default for Qbank 1" And I set the following fields to these values: | Name | New name | | Category info | I was edited | And I press "Save changes" Then I should see "New name" And I click on "Show descriptions" "checkbox" And I should see "I was edited" Scenario: An empty question category can be deleted When I am on the "Qbank 1" "core_question > question categories" page And I should see "Subcategory & < > \" ' &" And the "data-bs-original-title" attribute of "span.qbank_managecategories-newchild.dropready" "css_element" should contain "As new child of Subcategory & < > \" ' &" And I open the action menu in "Subcategory" "list_item" And I choose "Delete" in the open action menu And I click on "Delete" "button" in the "Delete" "dialogue" Then I should not see "Subcategory & < > \" ' &" Scenario: An non-empty question category can be deleted if you move the contents elsewhere When I am on the "Qbank 1" "core_question > question categories" page And I open the action menu in "Used category" "list_item" And I choose "Delete" in the open action menu And I click on "Delete" "button" in the "Delete" "dialogue" And I should see "The category 'Used category' contains 1 questions" And I select "Default for Qbank 1" from the "Category" singleselect And I press "Save in category" Then I should not see "Used category" And I press "Add category" And I should see "Default for Qbank 1 (1)" @_file_upload Scenario: Multi answer questions with their child questions can be moved to another category when the current category is deleted When I am on the "Qbank 1" "core_question > question import" page And I set the field "id_format_xml" to "1" And I upload "question/format/xml/tests/fixtures/multianswer.xml" file to "Import" filemanager And I press "id_submitbutton" And I press "Continue" And I am on the "Qbank 1" "core_question > question categories" page And I open the action menu in "Default for Test images in backup" "list_item" And I choose "Delete" in the open action menu And I click on "Delete" "button" in the ".modal-footer" "css_element" And I should see "The category 'Default for Test images in backup' contains 1 questions" And I select "Used category" from the "Category" singleselect And I press "Save in category" Then I should not see "Default for Test images in backup" And I press "Add category" And I should see "Used category (2)" Scenario: Filter questions by category and subcategories When I am on the "Qbank 1" "core_question > question bank" page And I apply question bank filter "Category" with value "Default for Qbank 1" Then I should not see "Question 1" When I set the field "Also show questions from subcategories" to "1" And I click on "Apply filters" "button" Then I should see "Question 1" in the "categoryquestions" "table" When I reload the page Then I should see "Question 1" in the "categoryquestions" "table" And the field "Also show questions from subcategories" matches value "1" And I am on the "Course 1" "core_question > course question bank" page And the field "Also show questions from subcategories" matches value "1" Scenario: Filter question by category and subcategories in Quiz question page Given the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And I am on the "Test quiz" "mod_quiz > Edit" page And I open the "last" add to quiz menu And I follow "from question bank" And I click on "Switch bank" "button" And I click on "Qbank 1" "link" in the "Select question bank" "dialogue" And I apply question bank filter "Category" with value "Default for Qbank 1" When I set the field "Also show questions from subcategories" to "1" And I click on "Apply filters" "button" Then I should see "Question 1" in the "categoryquestions" "table" And I set the field "Also show questions from subcategories" to "0" And I click on "Apply filters" "button" And I should not see "Question 1" And I click on "Close" "button" in the "Add from the question bank at the end" "dialogue" And I open the "last" add to quiz menu And I follow "from question bank" And the field "Also show questions from subcategories" matches value "0" Scenario: Filter question by an invalid category should show validation error When I am on the "Qbank 1" "core_question > question bank" page And I click on "Default for Qbank 1" "text" in the ".form-autocomplete-selection" "css_element" And I click on "Apply filters" "button" Then the "Category" field validity check should return "false" And the "Category" field validation message should contain "You must select a valid category" Scenario: Correcting an invalid category should no longer show validation error When I am on the "Qbank 1" "core_question > question bank" page # First try to submit with a blank category. And I click on "Default for Qbank 1" "text" in the ".form-autocomplete-selection" "css_element" And I click on "Apply filters" "button" # Then apply a correct category. And I apply question bank filter "Category" with value "Used category" Then the "Category" field validity check should return "true" And I should see "Test question to be moved" PK\VUtUt=bank/managecategories/tests/question_category_object_test.phpnu[. namespace qbank_managecategories; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); use context; use context_course; use context_module; use moodle_url; use core_question\local\bank\question_edit_contexts; use stdClass; /** * Unit tests for qbank_managecategories\question_category_object. * * @package qbank_managecategories * @copyright 2019 the Open University * @author 2021, Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_managecategories\question_category_object */ final class question_category_object_test extends \advanced_testcase { /** * @var question_category_object used in the tests. */ protected $qcobject; /** * @var context a context to use. */ protected $context; /** * @var stdClass top category in context. */ protected $topcat; /** * @var stdClass course object. */ protected $course; /** * @var stdClass quiz object. */ protected $quiz; /** * @var question_edit_contexts */ private $qcontexts; /** * @var false|object|stdClass|null */ private $defaultcategoryobj; /** * @var string */ private $defaultcategory; /** * @var question_category_object */ private $qcobjectquiz; protected function setUp(): void { parent::setUp(); self::setAdminUser(); $this->resetAfterTest(); // Set up tests in a quiz context. $this->course = $this->getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course->id]); $qbankcontext = context_module::instance($qbank->cmid); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->qcontexts = new question_edit_contexts(context_module::instance($this->quiz->cmid)); $contexts = new question_edit_contexts($qbankcontext); $this->topcat = question_get_top_category($qbankcontext->id, true); $this->resetDebugging(); $this->qcobject = new question_category_object(null, new moodle_url('/question/bank/managecategories/category.php', ['cmid' => $qbank->cmid]), $contexts->having_one_edit_tab_cap('categories'), 0, null, 0, $contexts->having_cap('moodle/question:add') ); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::__construct has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \qbank_managecategories\question_categories or \core_question\category_manager instead. ' . 'See MDL-72397 for more information.', ); $this->defaultcategoryobj = question_get_default_category($this->qcontexts->lowest()->id, true); $this->defaultcategory = $this->defaultcategoryobj->id . ',' . $this->defaultcategoryobj->contextid; $this->resetDebugging(); $this->qcobjectquiz = new question_category_object( 1, new moodle_url('/mod/quiz/edit.php', ['cmid' => $this->quiz->cmid]), $this->qcontexts->having_one_edit_tab_cap('categories'), $this->defaultcategoryobj->id, $this->defaultcategory, null, $this->qcontexts->having_cap('moodle/question:add')); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::__construct has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \qbank_managecategories\question_categories or \core_question\category_manager instead. ' . 'See MDL-72397 for more information.', ); } /** * Test creating a category. * * @covers ::add_category */ public function test_add_category_no_idnumber(): void { global $DB; $this->resetDebugging(); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'New category', '', true, FORMAT_HTML, ''); // No idnumber passed as '' to match form data. $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New category', $newcat->name); $this->assertNull($newcat->idnumber); } /** * Test creating a category with a tricky idnumber. * * @covers ::add_category */ public function test_add_category_set_idnumber_0(): void { global $DB; $this->resetDebugging(); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'New category', '', true, FORMAT_HTML, '0'); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New category', $newcat->name); $this->assertSame('0', $newcat->idnumber); } /** * Trying to add a category with duplicate idnumber blanks it. * (In reality, this would probably get caught by form validation.) * * @covers ::add_category */ public function test_add_category_try_to_set_duplicate_idnumber(): void { global $DB; $this->resetDebugging(); $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'Existing category', '', true, FORMAT_HTML, 'frog'); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'New category', '', true, FORMAT_HTML, 'frog'); $deprecationmessage = 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.'; $this->assertdebuggingcalledcount(2, [$deprecationmessage, $deprecationmessage]); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New category', $newcat->name); $this->assertNull($newcat->idnumber); } /** * Test updating a category. * * @covers ::update_category */ public function test_update_category(): void { global $DB; $this->resetDebugging(); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'Old name', 'Description', true, FORMAT_HTML, 'frog'); $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, 'New name', 'New description', FORMAT_HTML, '0', false); $this->assertdebuggingcalledcount( 3, [ 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\question_category_object::update_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::update_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_is_only_child_of_top_category_in_context ' . 'has been deprecated since 4.5. Moved to core namespace. ' . 'Use core_question\category_manager::is_only_child_of_top_category_in_context instead. ' . 'See MDL-72397 for more information.', ], ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New name', $newcat->name); $this->assertSame('0', $newcat->idnumber); } /** * Test updating a category to remove the idnumber. * * @covers ::update_category */ public function test_update_category_removing_idnumber(): void { global $DB; $this->resetDebugging(); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'Old name', 'Description', true, FORMAT_HTML, 'frog'); $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, 'New name', 'New description', FORMAT_HTML, '', false); $this->assertdebuggingcalledcount( 3, [ 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\question_category_object::update_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::update_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_is_only_child_of_top_category_in_context ' . 'has been deprecated since 4.5. Moved to core namespace. ' . 'Use core_question\category_manager::is_only_child_of_top_category_in_context instead. ' . 'See MDL-72397 for more information.', ], ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New name', $newcat->name); $this->assertNull($newcat->idnumber); } /** * Test updating a category without changing the idnumber. * * @covers ::update_category */ public function test_update_category_dont_change_idnumber(): void { global $DB; $this->resetDebugging(); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'Old name', 'Description', true, FORMAT_HTML, 'frog'); $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, 'New name', 'New description', FORMAT_HTML, 'frog', false); $this->assertdebuggingcalledcount( 3, [ 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\question_category_object::update_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::update_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_is_only_child_of_top_category_in_context ' . 'has been deprecated since 4.5. Moved to core namespace. ' . 'Use core_question\category_manager::is_only_child_of_top_category_in_context instead. ' . 'See MDL-72397 for more information.', ], ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New name', $newcat->name); $this->assertSame('frog', $newcat->idnumber); } /** * Trying to update a category so its idnumber duplicates idnumber blanks it. * (In reality, this would probably get caught by form validation.) * * @covers ::update_category */ public function test_update_category_try_to_set_duplicate_idnumber(): void { global $DB; $this->resetDebugging(); $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'Existing category', '', true, FORMAT_HTML, 'toad'); $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, 'old name', '', true, FORMAT_HTML, 'frog'); $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, 'New name', '', FORMAT_HTML, 'toad', false); $addmsg = 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.'; $this->assertdebuggingcalledcount( 4, [ $addmsg, $addmsg, 'Deprecation: qbank_managecategories\question_category_object::update_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::update_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_is_only_child_of_top_category_in_context ' . 'has been deprecated since 4.5. Moved to core namespace. ' . 'Use core_question\category_manager::is_only_child_of_top_category_in_context instead. ' . 'See MDL-72397 for more information.', ], ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New name', $newcat->name); $this->assertNull($newcat->idnumber); } /** * Test the question category created event. * * @covers ::add_category */ public function test_question_category_created(): void { // Trigger and capture the event. $sink = $this->redirectEvents(); $this->resetDebugging(); $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', ); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_created', $event); $this->assertEquals(context_module::instance($this->quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the question category deleted event. * * @covers ::delete_category */ public function test_question_category_deleted(): void { // Create the category. $this->resetDebugging(); $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); // Trigger and capture the event. $sink = $this->redirectEvents(); $this->qcobjectquiz->delete_category($categoryid); $this->assertdebuggingcalledcount( 3, [ 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\question_category_object::delete_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::delete_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' . 'Moved to core namespace. Use core_question\category_manager::can_delete_category instead. ' . 'See MDL-72397 for more information.', ], ); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_deleted', $event); $this->assertEquals(context_module::instance($this->quiz->cmid), $event->get_context()); $this->assertEquals($categoryid, $event->objectid); } /** * Test the question category updated event. * * @covers ::update_category */ public function test_question_category_updated(): void { $this->resetDebugging(); // Create the category. $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); // Trigger and capture the event. $sink = $this->redirectEvents(); $this->qcobjectquiz->update_category($categoryid, $this->defaultcategory, 'updatedcategory', '', FORMAT_HTML, '', false); $this->assertdebuggingcalledcount( 3, [ 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\question_category_object::update_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::update_category instead. ' . 'See MDL-72397 for more information.', 'Deprecation: qbank_managecategories\helper::question_is_only_child_of_top_category_in_context ' . 'has been deprecated since 4.5. Moved to core namespace. ' . 'Use core_question\category_manager::is_only_child_of_top_category_in_context instead. ' . 'See MDL-72397 for more information.', ], ); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_updated', $event); $this->assertEquals(context_module::instance($this->quiz->cmid), $event->get_context()); $this->assertEquals($categoryid, $event->objectid); } /** * Test the question category viewed event. * There is no external API for viewing the category, so the unit test will simply * create and trigger the event and ensure data is returned as expected. * * @covers ::add_category */ public function test_question_category_viewed(): void { $this->resetDebugging(); // Create the category. $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::add_category has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::add_category instead. ' . 'See MDL-72397 for more information.', ); // Log the view of this category. $category = new stdClass(); $category->id = $categoryid; $context = context_module::instance($this->quiz->cmid); $event = \core\event\question_category_viewed::create_from_question_category_instance($category, $context); // Trigger and capture the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_viewed', $event); $this->assertEquals(context_module::instance($this->quiz->cmid), $event->get_context()); $this->assertEquals($categoryid, $event->objectid); } /** * Test that get_real_question_ids_in_category() returns question id * of a shortanswer question in a category. * * @covers ::get_real_question_ids_in_category */ public function test_get_real_question_ids_in_category_shortanswer(): void { $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $categoryid = $this->defaultcategoryobj->id; // Short answer question is made of one question. $shortanswer = $generator->create_question('shortanswer', null, ['category' => $categoryid]); $this->resetDebugging(); $questionids = $this->qcobject->get_real_question_ids_in_category($categoryid); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::get_real_question_ids_in_category ' . 'has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::get_real_question_ids_in_category instead. ' . 'See MDL-72397 for more information.', ); $this->assertCount(1, $questionids); $this->assertContains($shortanswer->id, $questionids); } /** * Test that get_real_question_ids_in_category() returns question id * of a multianswer question in a category. * * @covers ::get_real_question_ids_in_category */ public function test_get_real_question_ids_in_category_multianswer(): void { global $DB; $countq = $DB->count_records('question'); $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $categoryid = $this->defaultcategoryobj->id; // Multi answer question is made of one parent and two child questions. $multianswer = $generator->create_question('multianswer', null, ['category' => $categoryid]); $this->resetDebugging(); $questionids = $this->qcobject->get_real_question_ids_in_category($categoryid); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::get_real_question_ids_in_category ' . 'has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::get_real_question_ids_in_category instead. ' . 'See MDL-72397 for more information.', ); $this->assertCount(1, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertEquals(3, $DB->count_records('question') - $countq); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } /** * Test that get_real_question_ids_in_category() returns question ids * of two versions of a multianswer question in a category. * * @covers ::get_real_question_ids_in_category */ public function test_get_real_question_ids_in_category_multianswer_two_versions(): void { global $DB; $countq = $DB->count_records('question'); $countqv = $DB->count_records('question_versions'); $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $categoryid = $this->defaultcategoryobj->id; // Create two versions of a multianswer question which will lead to // 2 parents and 4 child questions in the question bank. $multianswer = $generator->create_question('multianswer', null, ['category' => $categoryid]); $multianswernew = $generator->update_question($multianswer, null, ['name' => 'This is a new version']); $this->resetDebugging(); $questionids = $this->qcobject->get_real_question_ids_in_category($categoryid); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::get_real_question_ids_in_category ' . 'has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::get_real_question_ids_in_category instead. ' . 'See MDL-72397 for more information.', ); $this->assertCount(2, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertContains($multianswernew->id, $questionids); $this->assertEquals(6, $DB->count_records('question') - $countq); $this->assertEquals(6, $DB->count_records('question_versions') - $countqv); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } /** * Test that get_real_question_ids_in_category() returns question id * of a multianswer question in a category even if their child questions are * linked to a category that doesn't exist. * * @covers ::get_real_question_ids_in_category */ public function test_get_real_question_ids_in_category_multianswer_bad_data(): void { global $DB; $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $categoryid = $this->defaultcategoryobj->id; // Multi answer question is made of one parent and two child questions. $multianswer = $generator->create_question('multianswer', null, ['category' => $categoryid]); $qversion = $DB->get_record('question_versions', ['questionid' => $multianswer->id]); // Update category id for child questions to a category that doesn't exist. $DB->set_field_select('question_bank_entries', 'questioncategoryid', 123456, 'id <> :id', ['id' => $qversion->questionbankentryid]); $this->resetDebugging(); $questionids = $this->qcobject->get_real_question_ids_in_category($categoryid); $this->assertDebuggingCalled( 'Deprecation: qbank_managecategories\question_category_object::get_real_question_ids_in_category ' . 'has been deprecated since 4.5. ' . 'API properly divided between qbank_managecategories and core_question. ' . 'Use \core_question\category_manager::get_real_question_ids_in_category instead. ' . 'See MDL-72397 for more information.', ); $this->assertCount(1, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } } PK\OQ9bank/managecategories/tests/manage_category_test_base.phpnu[. namespace qbank_managecategories; /** * Test base for category tests * * @package qbank_managecategories * @copyright 2022 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class manage_category_test_base extends \advanced_testcase { /** * Create a question category for a context. * * @param int $contextid the context where question category will be created for * @param array $categorydetails details of the category * @return \stdClass question category record */ private function create_new_question_category_for_a_context(int $contextid, array $categorydetails = []) { $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $categorydetails['contextid'] = $contextid; return $questiongenerator->create_question_category($categorydetails); } /** * Create a course category * * @return \core_course_category new course category */ protected function create_course_category(): \core_course_category { // Course category. return $this->getDataGenerator()->create_category(); } /** * Create a course * * @return \stdClass new course */ protected function create_course(): \stdClass { // Course. return $this->getDataGenerator()->create_course(); } /** * Create a mod_qbank instance. * * @param \stdClass $course to add the mod_qbank instance to. * @return \stdClass new qbank module */ protected function create_qbank(\stdClass $course): \stdClass { return self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); } /** * Create a question category for a qbank module. * * @param \stdClass $qbank the qbank module that new question category will be created for * @param array $categorydetails details of the category * @return \stdClass category record */ protected function create_question_category_for_a_qbank(\stdClass $qbank, array $categorydetails = []): \stdClass { $context = \context_module::instance($qbank->cmid); return $this->create_new_question_category_for_a_context($context->id, $categorydetails); } /** * Create a quiz * * @return \stdClass the new quiz */ protected function create_quiz(): \stdClass { // Quiz. $course = $this->getDataGenerator()->create_course(); return $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); } /** * Create a question category for a quiz. * * @param \stdClass $quiz the quiz that new category will be created for * @param array $categorydetails details of the category * @return \stdClass category record */ protected function create_question_category_for_a_quiz(\stdClass $quiz, array $categorydetails = []): \stdClass { $context = \context_module::instance($quiz->cmid); return $this->create_new_question_category_for_a_context($context->id, $categorydetails); } /** * Create a new question in a category * * @param string $qtype the question type * @param int $categoryid the category that new question will be created on * @return \stdClass new question */ protected function create_question_in_a_category(string $qtype, int $categoryid): \stdClass { $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); return $questiongenerator->create_question($qtype, null, ['category' => $categoryid]); } /** * Get Parent of a question category * * @param int $questioncategoryid a question category * @return int the id of the parent */ protected function get_parent_of_a_question_category(int $questioncategoryid): int { global $DB; $parent = $DB->get_field('question_categories', 'parent', ['id' => $questioncategoryid]); return $parent ?: 0; } } PK\'Xq4$ $ 8bank/managecategories/tests/question_categories_test.phpnu[. namespace qbank_managecategories; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/bank/managecategories/tests/manage_category_test_base.php'); /** * Unit tests for question_categories * * @package qbank_managecategories * @copyright 2024 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 \qbank_managecategories\question_categories */ final class question_categories_test extends manage_category_test_base { /** * Test creation of an ordered tree of categories in the constructor. */ public function test_create_order_tree(): void { $this->setAdminUser(); $this->resetAfterTest(); // Create question categories for a course. $qbank = $this->create_qbank($this->create_course()); $context = \context_module::instance($qbank->cmid); $qcat1 = question_get_default_category($context->id); $qcat2 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat1->id]); $qcat3 = $this->create_question_category_for_a_qbank($qbank); $qcat4 = $this->create_question_category_for_a_qbank($qbank, ['parent' => $qcat2->id]); // Create ordered tree. $questioncategories = new question_categories( new \moodle_url('/'), [$context], ); $items = $questioncategories->editlists[$context->id]->items; // Two top categories (1 and 3) in the course. $this->assertCount(2, $items); $this->assertArrayHasKey($qcat1->id, $items); $this->assertArrayHasKey($qcat3->id, $items); // Category 2 is the only child of Category 1. $children = $items[$qcat1->id]->children; $this->assertCount(1, $children); $this->assertArrayHasKey($qcat2->id, $children); // Category 4 is the only child of Category 2. $children = $children[$qcat2->id]->children; $this->assertCount(1, $children); $this->assertArrayHasKey($qcat4->id, $children); } } PK\ܗvL bank/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This file describes core qbank plugin changes in /question/bank/*, information provided here is intended especially for developers. === 4.4 === * Question bank actions (anything that subclasses question_action_base) should implement the method get_menu_position() to control what position in the action menu it appears it. See the comment on the base class method for more details. If you don't do this, you will get a debugging warning. === 4.3 === * The helper class in qbank_statistics has had several of its methods deprecated. If you want to display statistics in the question bank, you should now override the get_required_statistics_fields() method in your column class, and then the values you need will be available from $this->qbank->get_aggregate_statistic(...). If you are not in a question_bank_column class, you can directly access efficient statistics-loading from the core_question\local\statistics\statistics_bulk_loader class. * Question actions are now implemented as a standalone qbank plugin feature. Instead of passing the actions through get_question_columns(), plugins implementing actions will now have to use get_question_actions(). Action classes are implemented by extending the new question_action_base class, which replaces the deprecated action_column_base class, menu_action_column_base class and menuable_action interface. The shared functionality between actions and columns has now been moved to the shared view_component base class. === 4.1 === * New functions qbank_usage\helper::get_question_bank_usage_sql and qbank_usage\helper::get_question_attempt_usage_sql have been implemented. When calling a query with the SQL those methods returned, you have to be sure in the accompanying $params array are in the right order. === 4.0 === * This plugin type is new in Moodle 4.0. It is for adding features to the question bank. They key entry point is the class plugin_feature in the plugin namespace. This tells the core question bank API exactly which features this plugin implements. These features can currently be new columns in the question bank display, and bulk actions. Currently, actions on single questions are implemented as a type of column (but this should probably change in the future.) To get started, have a look at the methods in core_question\local\bank\plugin_features_base which you can override. PK\)H!4bank/deletequestion/lang/en/qbank_deletequestion.phpnu[. /** * Strings for component qbank_deletequestion, language 'en' * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Delete question'; $string['privacy:metadata'] = 'The Delete question question bank plugin does not store any personal data.'; $string['deletequestion'] = 'Delete selected question'; PK\ꋷbank/deletequestion/delete.phpnu[. /** * Delete question page. * * This code is based on question/classes/bank/view.php * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once(__DIR__ . '/../../editlib.php'); global $DB, $OUTPUT, $PAGE, $COURSE; $deleteselected = optional_param('deleteselected', false, PARAM_BOOL); $returnurl = optional_param('returnurl', 0, PARAM_LOCALURL); $cmid = required_param('cmid', PARAM_INT); $deleteall = optional_param('deleteall', false, PARAM_BOOL); if ($returnurl) { $returnurl = new moodle_url($returnurl); } \core_question\local\bank\helper::require_plugin_enabled('qbank_deletequestion'); [$module, $cm] = get_module_from_cmid($cmid); require_login($cm->course, false, $cm); $thiscontext = context_module::instance($cmid); $contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $url = new moodle_url('/question/bank/deletequestion/delete.php'); $PAGE->set_url($url); $streditingquestions = get_string('deletequestion', 'qbank_deletequestion'); $PAGE->set_title($streditingquestions); $PAGE->set_heading($COURSE->fullname); $PAGE->activityheader->disable(); $PAGE->set_secondary_active_tab("questionbank"); // Unhide a question. if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) { question_require_capability_on($unhide, 'edit'); $DB->set_field('question_versions', 'status', \core_question\local\bank\question_version_status::QUESTION_STATUS_READY, ['questionid' => $unhide]); // Purge these questions from the cache. \question_bank::notify_question_edited($unhide); redirect($returnurl); } // If user has already confirmed the action. if ($deleteselected && ($confirm = optional_param('confirm', '', PARAM_ALPHANUM)) && confirm_sesskey()) { $deleteselected = required_param('deleteselected', PARAM_RAW); if ($confirm == md5($deleteselected)) { if ($questionlist = explode(',', $deleteselected)) { \qbank_deletequestion\helper::delete_questions($questionlist, $deleteall); } redirect($returnurl); } else { throw new \moodle_exception('invalidconfirm', 'question'); } } echo $OUTPUT->header(); if ($deleteselected) { // Make a list of all the questions that are selected. $rawquestions = $_REQUEST; // This code is called by both POST forms and GET links, so cannot use data_submitted. $questionlist = ''; // Comma separated list of ids of questions to be deleted. foreach ($rawquestions as $key => $value) { // Parse input for question ids. if (preg_match('!^q([0-9]+)$!', $key, $matches)) { $key = $matches[1]; $questionlist .= $key.','; question_require_capability_on((int)$key, 'edit'); } } if (!$questionlist) { // No questions were selected. redirect($returnurl); } $questionlist = rtrim($questionlist, ','); $deleteurl = new \moodle_url( '/question/bank/deletequestion/delete.php', [ 'deleteselected' => $questionlist, 'deleteall' => $deleteall, 'confirm' => md5($questionlist), 'sesskey' => sesskey(), 'returnurl' => $returnurl->out_as_local_url(false), 'cmid' => $cmid, ], ); $continue = new \single_button($deleteurl, get_string('delete'), 'post'); $questionids = explode(',', $questionlist); [$displayoptions, $message] = qbank_deletequestion\helper::get_delete_confirmation_message($questionids, $deleteall); echo $OUTPUT->confirm($message, $continue, $returnurl, $displayoptions); } echo $OUTPUT->footer(); PK\f~bank/deletequestion/version.phpnu[. /** * Version information for qbank_deletequestion. * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_deletequestion'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\ j0bank/deletequestion/classes/privacy/provider.phpnu[. /** * Version information for qbank_deletequestion. * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_deletequestion\privacy; /** * Privacy Subsystem for qbank_deletequestion implementing null_provider. * * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\iՃ&bank/deletequestion/classes/helper.phpnu[. namespace qbank_deletequestion; /** * Class helper of qbank_deletequestion. * * @package qbank_deletequestion * @copyright 2023 The Open University * @since Moodle 4.2 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get the confirmation message of delete question. * * @param array $questionids List of id questions. * @param bool $deleteallversions Delete all question version or not. * @return array List confirmation message. */ public static function get_delete_confirmation_message(array $questionids, bool $deleteallversions): array { global $DB; $questionnames = ''; $inuse = false; $questionversions = []; $countselectedquestion = count($questionids); $versionsofeachquestionbankentry = $deleteallversions ? \question_bank::get_all_versions_of_questions($questionids) : \question_bank::get_version_of_questions($questionids); foreach ($versionsofeachquestionbankentry as $entryid => $versions) { // Re-order to the oldest first. $versionsofeachquestionbankentry[$entryid] = array_reverse($versions, true); // Flip the array to list question by question id. [ qid => version ]. $questionversions += array_flip($versions); } // Flatten an array. $questionids = array_merge(...$versionsofeachquestionbankentry); // Get the names of all the questions. $questions = $DB->get_records_list('question', 'id', $questionids, '', 'id, name'); // Build the message. foreach ($questionids as $questionid) { if (questions_in_use([$questionid])) { $questionnames .= '* '; $inuse = true; } $questionname = format_string($questions[$questionid]->name); $a = new \stdClass(); $a->name = $questionname; $a->version = $questionversions[$questionid]; $questionnames .= get_string('questionnameandquestionversion', 'question', $a) . '
    '; } // Add the in-use message if required. if ($inuse) { $questionnames .= '
    '.get_string('questionsinuse', 'question'); } // Add in the right tile and message text. $confirmtitle = [ 'confirmtitle' => $countselectedquestion > 1 ? get_string('deleteversiontitle_plural', 'question') : get_string('deleteversiontitle', 'question'), ]; $message = get_string('deleteselectedquestioncheck', 'question', $questionnames); if ($deleteallversions) { $confirmtitle = [ 'confirmtitle' => get_string('deletequestiontitle', 'question'), ]; $message = get_string('deletequestioncheck', 'question', $questionnames); if ($countselectedquestion > 1) { $confirmtitle = [ 'confirmtitle' => get_string('deletequestiontitle_plural', 'question'), ]; $message = get_string('deletequestionscheck', 'question', $questionnames); } } return [$confirmtitle, $message]; } /** * Delete questions has (single/multiple) version. * * @param array $questionids List of questionid. * @param bool $deleteallversions Delete all question version or not. */ public static function delete_questions(array $questionids, bool $deleteallversions): void { if ($deleteallversions) { // Get all the question id from multidimensional array. $listofquestions = \question_bank::get_all_versions_of_questions($questionids); // Flatten an array. $questionids = array_merge(...$listofquestions); } foreach ($questionids as $questionid) { $questionid = (int) $questionid; question_require_capability_on($questionid, 'edit'); question_delete_question($questionid); } } } PK\0bank/deletequestion/classes/hidden_condition.phpnu[. namespace qbank_deletequestion; use core\output\datafilter; use core_question\local\bank\condition; use core_question\local\bank\question_version_status; /** * This class controls whether hidden / deleted questions are hidden in the list. * * @package qbank_deletequestion * @copyright 2013 Ray Morris * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hidden_condition extends condition { public static function get_condition_key() { return 'hidden'; } /** * Build query from filter value * * @param array $filter filter properties * @return array where sql and params */ public static function build_query_from_filter(array $filter): array { $showhidden = (bool)$filter['values'][0]; $where = ""; $params = []; if (!$showhidden) { $where = "qv.status <> :hidden_condition"; $params = ['hidden_condition' => question_version_status::QUESTION_STATUS_HIDDEN]; } return [$where, $params]; } public function get_title() { return get_string('showhidden', 'core_question'); } public function get_join_list(): array { return [ datafilter::JOINTYPE_ANY, ]; } public function get_filter_class() { return 'qbank_deletequestion/datafilter/filtertypes/hidden'; } public function is_required(): bool { return true; } } PK\D'2bank/deletequestion/classes/bulk_delete_action.phpnu[. namespace qbank_deletequestion; use core_question\local\bank\view; /** * Class bulk_delete_action is the base class for delete bulk actions ui. * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class bulk_delete_action extends \core_question\local\bank\bulk_action_base { public function get_bulk_action_title(): string { return get_string('delete'); } public function get_key(): string { return 'deleteselected'; } public function get_bulk_action_url(): \moodle_url { $params = []; if (!$this->qbank->is_listing_specific_versions()) { $params['deleteall'] = 1; } return new \moodle_url('/question/bank/deletequestion/delete.php', $params); } public function get_bulk_action_capabilities(): ?array { return [ 'moodle/question:editall', ]; } } PK\!jtt-bank/deletequestion/classes/delete_action.phpnu[. /** * Action to delete (or hide) a question, or restore a previously hidden question. * * @package qbank_deletequestion * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_deletequestion; use core_question\local\bank\question_version_status; use core_question\local\bank\question_action_base; /** * Action to delete (or hide) a question, or restore a previously hidden question. * * @package qbank_deletequestion * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class delete_action extends question_action_base { /** * @var string $strdelete */ protected $strdelete; /** * @var string $strrestore */ protected $strrestore; /** * Contains the url of the delete question page. * @var \moodle_url|string */ public $deletequestionurl; /** * Array of the return parameters. * @var array $returnparams */ protected $returnparams; public function init(): void { parent::init(); $this->strdelete = get_string('delete'); $this->strrestore = get_string('restore'); $this->deletequestionurl = new \moodle_url('/question/bank/deletequestion/delete.php'); $this->returnparams['cmid'] = $this->qbank->cm->id; if (!empty($this->qbank->returnurl)) { $this->returnparams['returnurl'] = $this->qbank->returnurl; } } public function get_menu_position(): int { return 2000; } protected function get_url_icon_and_label(\stdClass $question): array { if (!question_has_capability_on($question, 'edit')) { return [null, null, null]; } if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) { $hiddenparams = array( 'unhide' => $question->id, 'sesskey' => sesskey()); $hiddenparams = array_merge($hiddenparams, $this->returnparams); $url = new \moodle_url($this->deletequestionurl, $hiddenparams); return [$url, 't/restore', $this->strrestore]; } else { $deleteparams = array( 'deleteselected' => $question->id, 'q' . $question->id => 1, 'sesskey' => sesskey()); $deleteparams = array_merge($deleteparams, $this->returnparams); if (!$this->qbank->is_listing_specific_versions()) { $deleteparams['deleteall'] = 1; } $url = new \moodle_url($this->deletequestionurl, $deleteparams); return [$url, 't/delete', $this->strdelete]; } } /** * Override method to get url and label for delete action to add the text-danger class. * * @param \stdClass $question * @return \action_menu_link|null */ public function get_action_menu_link(\stdClass $question): ?\action_menu_link { $deletelink = parent::get_action_menu_link($question); if ($deletelink !== null) { $deletelink->add_class('text-danger'); } return $deletelink; } } PK\A?f.bank/deletequestion/classes/plugin_feature.phpnu[. /** * Plugin entrypoint for columns. * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_deletequestion; use core_question\local\bank\bulk_action_base; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class columns is the entrypoint for the columns. * * @package qbank_deletequestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { public function get_question_actions($qbank): array { return [ new delete_action($qbank), ]; } /** * Initialise the bulk action. * @param view $qbank * @return bulk_delete_action[] */ public function get_bulk_actions(view $qbank): array { return [ new bulk_delete_action($qbank), ]; } public function get_question_filters(?view $qbank = null): array { return [ new hidden_condition($qbank), ]; } } PK\gk@bank/deletequestion/templates/hidden_condition_advanced.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_deletequestion/hidden_condition_advanced Example context (json): { "displaydata": [ { "checked": "checked attribute" } ] } }}
    PK\JJ<bank/deletequestion/amd/src/datafilter/filtertypes/hidden.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Filter managing hidden questions. * * @module qbank_deletequestion/datafilter/filtertypes/hidden * @author 2022 Ghaly Marc-Alexandre * @copyright 2022 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Binary from 'core/datafilter/filtertypes/binary'; export default class extends Binary { } PK\1  Fbank/deletequestion/amd/build/datafilter/filtertypes/hidden.min.js.mapnu[{"version":3,"file":"hidden.min.js","sources":["../../../src/datafilter/filtertypes/hidden.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Filter managing hidden questions.\n *\n * @module qbank_deletequestion/datafilter/filtertypes/hidden\n * @author 2022 Ghaly Marc-Alexandre \n * @copyright 2022 Catalyst IT Australia Pty Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Binary from 'core/datafilter/filtertypes/binary';\n\nexport default class extends Binary {\n}\n"],"names":["Binary"],"mappings":";;;;;;;;oKA0B6BA"}PK\+Bbank/deletequestion/amd/build/datafilter/filtertypes/hidden.min.jsnu[define("qbank_deletequestion/datafilter/filtertypes/hidden",["exports","core/datafilter/filtertypes/binary"],(function(_exports,_binary){var obj; /** * Filter managing hidden questions. * * @module qbank_deletequestion/datafilter/filtertypes/hidden * @author 2022 Ghaly Marc-Alexandre * @copyright 2022 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_binary=(obj=_binary)&&obj.__esModule?obj:{default:obj};class _default extends _binary.default{}return _exports.default=_default,_exports.default})); //# sourceMappingURL=hidden.min.js.mapPK\Gٗ )bank/deletequestion/tests/helper_test.phpnu[. namespace qbank_deletequestion; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Class containing unit tests for the helper class * * @package qbank_deletequestion * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class helper_test extends \advanced_testcase { /** * @var \context_module module context. */ protected $context; /** * @var \stdClass course object. */ protected $course; /** * @var \component_generator_base question generator. */ protected $qgenerator; /** * @var \stdClass quiz object. */ protected $quiz; /** * Called before every test. */ protected function setUp(): void { parent::setUp(); self::setAdminUser(); $this->resetAfterTest(); $datagenerator = $this->getDataGenerator(); $this->course = $datagenerator->create_course(); $this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]); $this->qgenerator = $datagenerator->get_plugin_generator('core_question'); $this->context = \context_module::instance($this->quiz->cmid); } /** * Test get a confirmation message when deleting the question in the (question bank/history) page. * * @covers \qbank_deletequestion\helper::get_delete_confirmation_message */ public function test_get_delete_confirmation_message(): void { $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id, 'name' => 'Question 1']); $questionfirstversionid = $question->id; // Verify confirmation title and confirmation message with question not in use in question bank page. $deleteallversions = true; [$title1, $message1] = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionfirstversionid], $deleteallversions); $this->assertEquals(['confirmtitle' => get_string('deletequestiontitle', 'question')], $title1); $this->assertEquals(get_string('deletequestioncheck', 'question', $question->name). ' v1' . '
    ', $message1); // Create a new version and adding it to a quiz. $question2 = $this->qgenerator->update_question($question, null, ['name' => 'Question 1']); $questionsecondversionid = $question2->id; // Verify confirmation title and confirmation message with question has multiple versions in question bank page. $listnameofquestionversion2 = $question->name . ' v1' . '
    ' . $question2->name . ' v2' .'
    '; [$title2, $message2] = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionsecondversionid], $deleteallversions); $this->assertEquals(['confirmtitle' => get_string('deletequestiontitle', 'question')], $title2); $this->assertEquals(get_string('deletequestioncheck', 'question', $listnameofquestionversion2), $message2); // Verify confirmation title and confirmation message with multiple question selected. $listnameofquestionversion3 = $question->name . ' v1' . '
    ' . $question2->name . ' v2' .'
    '; [$title3, $message3] = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionfirstversionid, $questionsecondversionid], $deleteallversions); $this->assertEquals(['confirmtitle' => get_string('deletequestiontitle_plural', 'question')], $title3); $this->assertEquals(get_string('deletequestionscheck', 'question', $listnameofquestionversion3), $message3); // Add second question version to the quiz to become question in use. quiz_add_quiz_question($questionsecondversionid, $this->quiz); // Verify confirmation message with question in use and has multiple versions in question bank page. $listnameofquestionversion4 = $question->name . ' v1' . '
    ' . '* ' . $question2->name . ' v2' . '
    '; $message4 = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionsecondversionid], $deleteallversions)[1]; $this->assertEquals(get_string('deletequestioncheck', 'question', $listnameofquestionversion4) . '
    ' . get_string('questionsinuse', 'question'), $message4); // Verify confirmation title and confirmation message in history page with one question selected. $deleteallversions = false; [$title5, $message5] = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionfirstversionid], $deleteallversions); $this->assertEquals(['confirmtitle' => get_string('deleteversiontitle', 'question')], $title5); $this->assertEquals(get_string('deleteselectedquestioncheck', 'question', $question->name) . ' v1' . '
    ', $message5); // Verify confirmation title and confirmation message in history page with multiple question selected. $listnameofquestionversion6 = 'Question 1 v1' . '
    * Question 1 v2
    '; [$title6, $message6] = \qbank_deletequestion\helper::get_delete_confirmation_message([$questionfirstversionid, $questionsecondversionid], $deleteallversions); $this->assertEquals(['confirmtitle' => get_string('deleteversiontitle_plural', 'question')], $title6); $this->assertEquals(get_string('deleteselectedquestioncheck', 'question', $listnameofquestionversion6) . '
    '. get_string('questionsinuse', 'question'), $message6); } /** * Test delete questions have single/multiple version. * * @covers \qbank_deletequestion\helper::delete_questions */ public function test_delete_question_has_multiple_version(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question1 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id, 'name' => 'Question 1 version 1']); $question1v1id = $question1->id; // Create a new version for question 1. $question1v2 = $this->qgenerator->update_question($question1, null, ['name' => 'Question 1 version 2']); $question1v2id = $question1v2->id; $question2 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id, 'name' => 'Question 2 version 1']); $question2v1id = $question2->id; $question3 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id, 'name' => 'Question 3 version 1']); $question3v1id = $question3->id; // Do. \qbank_deletequestion\helper::delete_questions([$question1v2id, $question2v1id], true); // All the versions of question1 will be deleted. $this->assertFalse($DB->record_exists('question', ['id' => $question1v1id])); $this->assertFalse($DB->record_exists('question', ['id' => $question1v2id])); // The question2 have single version will be deleted. $this->assertFalse($DB->record_exists('question', ['id' => $question2v1id])); // Check that we did not delete too much. $this->assertTrue($DB->record_exists('question', ['id' => $question3v1id])); } } PK\MT>bank/deletequestion/tests/behat/delete_question_column.featurenu[@qbank @qbank_deletequestion Feature: Use the qbank plugin manager page for deletequestion In order to check the plugin behaviour with enable and disable Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question 1 | Answer the first question | | Test questions | truefalse | Question 2 | Answer the second question | | Test questions | truefalse | Question 3 | Answer the third question | @javascript Scenario: Enable/disable delete question column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Delete question" And I click on "Disable" "link" in the "Delete question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" Then the "Delete" action should not exist for the "Question 1" question in the question bank And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Delete question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And the "Delete" action should exist for the "Question 1" question in the question bank @javascript Scenario: Enable/disable delete questions bulk action from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Delete question" And I click on "Disable" "link" in the "Delete question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I click on "With selected" "button" Then I should not see question bulk action "deleteselected" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Delete question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I click on "With selected" "button" And I should see question bulk action "deleteselected" @javascript Scenario: I should not see the deleted questions in the base view Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I click on "Question 1" "checkbox" And I click on "Question 2" "checkbox" And I click on "With selected" "button" And I click on question bulk action "deleteselected" And I click on "Delete" "button" in the "Delete questions?" "dialogue" Then I should not see "Question 1" And I should not see "Question 2" @javascript Scenario: I should be able to delete a question when filtered using tags Given I am on the "Question 1" "core_question > edit" page logged in as "admin" And I change window size to "large" And I set the following fields to these values: | Tags | foo | And I click on "Save changes" "button" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I apply question bank filter "Tag" with value "foo" And I click on "Question 1" "checkbox" And I click on "With selected" "button" And I click on question bulk action "deleteselected" When I click on "Delete" "button" in the "Delete question?" "dialogue" Then I should not see "Third question" @javascript Scenario: Questions can be bulk deleted from the question bank Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" # Select questions to be deleted. And I click on "Question 1" "checkbox" And I click on "Question 2" "checkbox" And I click on "With selected" "button" When I press "Delete" # Confirm that delete confirmation message is displayed. Then I should see "This will delete the following questions and all their versions:" # Confirm that selected questions are listed on the confirmation dialog. And I should see "Question 1 v1" And I should see "Question 2 v1" # Delete selected questions. And I press "Delete" # Confirm that selected questions are deleted while unselected questions still exist. And I should not see "Question 1" And I should not see "Question 2" And I should see "Question 3" PK\y4bank/comment/styles.cssnu[/* Comment text area size to maximum */ .question-comment-view .comment-ctrl .comment-area { max-width: none; width: 100%; } PK\;;8JJbank/comment/db/access.phpnu[. /** * Capability definitions for this module. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Controls whether users can comment their own questions. 'moodle/question:commentmine' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], 'clonepermissionsfrom' => 'moodle/question:editmine' ], // Controls whether users can comment all questions. 'moodle/question:commentall' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], 'clonepermissionsfrom' => 'moodle/question:editall' ], ]; PK\ 3\2bank/comment/db/events.phpnu[. /** * Question custom fields events * * @package qbank_comment * @copyright 2023 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 */ defined('MOODLE_INTERNAL') || die(); $observers = [ [ 'eventname' => '\core\event\question_deleted', 'callback' => '\qbank_comment\event\question_deleted_observer::delete_question_comments', ] ]; PK\`\11&bank/comment/lang/en/qbank_comment.phpnu[. /** * Strings for component qbank_comment, language 'en'. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Question comments'; $string['privacy:metadata:core_comment'] = 'The Question comments question bank plugin enables users to comment on questions.'; // Column. $string['comment'] = 'Comment'; $string['commentplural'] = 'Comments'; // Modal. $string['addcomment'] = 'Add comment'; $string['close'] = 'Close'; $string['commentheader'] = 'Question comments'; $string['commentdisabled'] = 'Comments are currently disabled on this site. Please contact your site administrator.'; // Events. $string['comment_added'] = 'The user with ID \'{$a->userid}\' added the comment with ID \'{$a->objectid}\' to the \'{$a->component}\' for the question with ID \'{$a->itemid}\'.'; $string['comment_removed'] = 'The user with ID \'{$a->userid}\' deleted the comment with ID \'{$a->objectid}\' from the \'{$a->component}\' for the question with ID \'{$a->itemid}\'.'; PK\i#Is s Abank/comment/backup/moodle2/backup_qbank_comment_plugin.class.phpnu[. /** * Provides the information to backup question comments. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qbank_comment_plugin extends \backup_qbank_plugin { /** * Returns the comment information to attach to question element. */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); $comments = new backup_nested_element('comments'); $comment = new backup_nested_element('comment', ['id'], ['component', 'commentarea', 'itemid', 'contextid', 'content', 'format', 'userid', 'timecreated']); $pluginwrapper->add_child($comments); $comments->add_child($comment); $comment->set_source_sql( "SELECT c.* FROM {comments} c WHERE c.contextid = :contextid AND c.component = 'qbank_comment' AND c.commentarea = 'question' AND c.itemid = :itemid", [ 'contextid' => backup_helper::is_sqlparam(context_system::instance()->id), 'itemid' => backup::VAR_PARENTID, ] ); $comment->annotate_ids('user', 'userid'); return $plugin; } } PK\˼TBbank/comment/backup/moodle2/restore_qbank_comment_plugin.class.phpnu[. /** * Restore plugin class that provides the necessary information needed to restore comments for questions. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qbank_comment_plugin extends restore_qbank_plugin { /** * Returns the paths to be handled by the plugin at question level. */ protected function define_question_plugin_structure() { return [ new restore_path_element('comment', $this->get_pathfor('/comments/comment')) ]; } /** * Process the question comments element. * * @param array $data The comment data to restore. */ public function process_comment($data) { global $DB, $CFG; $data = (object)$data; $newquestionid = $this->get_new_parentid('question'); $questioncreated = (bool) $this->get_mappingid('question_created', $this->get_old_parentid('question')); if (!$questioncreated) { // This question already exists in the question bank. Nothing for us to do. return; } if ($CFG->usecomments) { $data->itemid = $newquestionid; $DB->insert_record('comments', $data); } } } PK\ZA((bank/comment/lib.phpnu[. /** * Helper functions and callbacks. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot. '/comment/lib.php'); /** * Validate comment parameter before perform other comments actions. * * @param stdClass $commentparam * { * context => context the context object * courseid => int course id * cm => stdClass course module object * commentarea => string comment area * itemid => int itemid * } * @return boolean */ function qbank_comment_comment_validate($commentparam): bool { if ($commentparam->commentarea != 'question' && $commentparam->component != 'qbank_comment') { throw new comment_exception('invalidcommentarea'); } return true; } /** * Running additional permission check on plugins. * * @param stdClass $args * @return array */ function qbank_comment_comment_permissions($args): array { return ['post' => true, 'view' => true]; } /** * Validate comment data before displaying comments. * * @param array $comments * @param stdClass $args * @return array $comments */ function qbank_comment_comment_display($comments, $args): array { if ($args->commentarea != 'question' && $args->component != 'qbank_comment') { throw new comment_exception('core_question'); } return $comments; } /** * Comment content for callbacks. * * @param question_definition $question * @param int $courseid * @return string */ function qbank_comment_preview_display($question, $courseid): string { global $CFG, $PAGE; if (question_has_capability_on($question, 'comment') && $CFG->usecomments && core\plugininfo\qbank::is_plugin_enabled('qbank_comment')) { \comment::init($PAGE); $args = new \stdClass; $args->contextid = context_system::instance()->id; // Static data to bypass comment sql as context is not needed. $args->courseid = $courseid; $args->area = 'question'; $args->itemid = $question->id; $args->component = 'qbank_comment'; $args->notoggle = true; $args->autostart = true; $args->displaycancel = false; $args->linktext = get_string('commentheader', 'qbank_comment'); $comment = new \comment($args); $comment->set_view_permission(true); $comment->set_fullwidth(); return $comment->output(); } else { return ''; } } /** * Question comment fragment callback. * * @param array $args * @return string rendered output */ function qbank_comment_output_fragment_question_comment($args): string { global $PAGE, $CFG; $displaydata = []; require_once($CFG->dirroot . '/question/engine/bank.php'); $question = question_bank::load_question($args['questionid']); $displaydata['question'] = question_bank::render_preview_of_question($question); $displaydata['comment'] = qbank_comment_preview_display($question, $args['courseid']); $displaydata['commenstdisabled'] = false; if (empty($displaydata['comment']) && !$CFG->usecomments) { $displaydata['commenstdisabled'] = true; } $selector = \core_question\output\question_version_selection::make_for_question('question_comment_version_dropdown', $args['questionid']); $qbankrenderer = $PAGE->get_renderer('core_question', 'bank'); $displaydata['versionselection'] = $selector->export_for_template($qbankrenderer); return $PAGE->get_renderer('qbank_comment')->render_comment_fragment($displaydata); } PK\bmmbank/comment/version.phpnu[. /** * Version information for qbank_comment. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_comment'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\{OO-bank/comment/classes/comment_count_column.phpnu[. namespace qbank_comment; use core_question\local\bank\column_base; use question_bank; /** * A column to show the number of comments. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class comment_count_column extends column_base { /** * @var bool Comments enabled or not from config. */ protected $commentsenabled = true; /** * Load javascript module if enabled. * * @return void */ public function init(): void { parent::init(); $this->check_comments_status(); if ($this->commentsenabled) { global $PAGE; $PAGE->requires->js_call_amd('qbank_comment/comment', 'init'); } } /** * Check if comments is turned on in the system or not. */ protected function check_comments_status(): void { global $CFG; if (!$CFG->usecomments) { $this->commentsenabled = false; } } /** * Get the name of the column, used internally. * * @return string */ public function get_name(): string { return 'commentcount'; } /** * Get the title of the column that will be displayed. * * @return string */ public function get_title(): string { return get_string('commentplural', 'qbank_comment'); } /** * Generate the content to be displayed. * * @param object $question The question object. * @param string $rowclasses Classes that can be added. */ protected function display_content($question, $rowclasses): void { global $DB; $syscontext = \context_system::instance(); $args = new \stdClass; $args->contextid = $syscontext->id; $args->courseid = $this->qbank->course->id; $args->area = 'question'; $args->itemid = $question->id; $args->component = 'qbank_comment'; $params = [ 'component' => $args->component, 'commentarea' => $args->area, 'itemid' => $args->itemid, 'contextid' => $args->contextid, ]; $commentcount = $DB->count_records('comments', $params); $attributes = []; // Build up the comment object to see if we have correct permissions to post. $comment = new \comment($args); if (question_has_capability_on($question, 'comment') && $comment->can_post()) { $tag = 'a'; $target = 'questioncommentpreview_' . $question->id; $attributes = [ 'href' => '#', 'data-target' => $target, 'data-questionid' => $question->id, 'data-courseid' => $this->qbank->course->id, 'data-contextid' => $syscontext->id, ]; } else { $tag = 'span'; } echo \html_writer::tag($tag, $commentcount, $attributes); } public function get_extra_classes(): array { return ['pe-3']; } public function get_default_width(): int { return 150; } } PK\.-)bank/comment/classes/privacy/provider.phpnu[. namespace qbank_comment\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; use core_privacy\local\request\approved_userlist; /** * Privacy Subsystem for qbank_comment. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // The qbank_comment stores user provided data. \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, // The qbank_comment provides data directly to core. \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. * * @param collection $collection * @return collection */ public static function get_metadata(collection $collection): collection { return $collection->add_subsystem_link('core_comment', [], 'privacy:metadata:core_comment'); } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid * @return contextlist */ public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new contextlist(); $sql = "SELECT contextid FROM {comments} WHERE component = :component AND userid = :userid"; $params = [ 'area' => 'question', 'component' => 'qbank_comment', 'userid' => $userid ]; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Get the list of users within a specific context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { $context = $userlist->get_context(); $params = [ 'contextid' => $context->id, 'area' => 'question', 'component' => 'qbank_comment' ]; $sql = "SELECT userid as userid FROM {comments} WHERE component = :component AND contextid = :contextid"; $userlist->add_from_sql('userid', $sql, $params); } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist */ public static function export_user_data(approved_contextlist $contextlist) { $contexts = $contextlist->get_contexts(); foreach ($contexts as $context) { \core_comment\privacy\provider::export_comments( $context, 'qbank_comment', 'question', 0, [] ); } } /** * Delete all data for all users in the specified context. * * @param \context $context */ public static function delete_data_for_all_users_in_context(\context $context) { \core_comment\privacy\provider::delete_comments_for_all_users($context, 'qbank_comment'); } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { \core_comment\privacy\provider::delete_comments_for_users($userlist, 'qbank_comment'); } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist */ public static function delete_data_for_user(approved_contextlist $contextlist) { \core_comment\privacy\provider::delete_comments_for_user($contextlist, 'qbank_comment'); } } PK\8:'$$(bank/comment/classes/output/renderer.phpnu[. namespace qbank_comment\output; /** * Class renderer for comment. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render the html fragment for comment modal. * * @param array $displaydata * @return string */ public function render_comment_fragment($displaydata): string { return $this->render_from_template('qbank_comment/comment_modal', $displaydata); } } PK\R%XX8bank/comment/classes/event/question_deleted_observer.phpnu[. namespace qbank_comment\event; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/comment/lib.php'); use core\event\question_deleted; /** * Event observer for question deletion * * @package qbank_comment * @copyright 2023 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 question_deleted_observer { /** * Delete any comments for the deleted question. * * @param question_deleted $event * @return void */ public static function delete_question_comments(question_deleted $event): void { \comment::delete_comments([ 'contextid' => \context_system::instance()->id, 'component' => 'qbank_comment', 'commentarea' => 'question', 'itemid' => $event->objectid, ]); } } PK\zi AA.bank/comment/classes/event/comment_deleted.phpnu[. namespace qbank_comment\event; /** * qbank_comment comment deleted event. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class comment_deleted extends \core\event\comment_deleted { /** * Get URL related to the action, null in this case. * * @return null */ public function get_url() { return null; } /** * Returns description of what happened. * * @return string */ public function get_description() { $a = new \stdClass(); $a->userid = $this->userid; $a->objectid = $this->objectid; $a->component = $this->component; $a->itemid = $this->other['itemid']; return get_string('comment_removed', 'qbank_comment', $a); } } PK\??.bank/comment/classes/event/comment_created.phpnu[. namespace qbank_comment\event; /** * qbank_comment comment created event. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class comment_created extends \core\event\comment_created { /** * Get URL related to the action, null in this case. * * @return null */ public function get_url() { return null; } /** * Returns description of what happened. * * @return string */ public function get_description() { $a = new \stdClass(); $a->userid = $this->userid; $a->objectid = $this->objectid; $a->component = $this->component; $a->itemid = $this->other['itemid']; return get_string('comment_added', 'qbank_comment', $a); } } PK\e~~'bank/comment/classes/plugin_feature.phpnu[. namespace qbank_comment; /** * Class plugin_features is the entrypoint for the columns. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { /** * Get the columns provided by this plugin. * * @param \core_question\local\bank\view $qbank * @return comment_count_column[] */ public function get_question_columns(\core_question\local\bank\view $qbank): array { return [ new comment_count_column($qbank) ]; } } PK\F HBB-bank/comment/templates/comment_modal.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_comment/comment_modal The template that renders the modal window for adding comments to questions. * question - The html of the question. * comment - The html of the comment. * commenstdisabled - If true display the comment disabled notification. Example context (json): { "commentdata": [ { "question": "question html", "comment": "comment html", "commenstdisabled": "bool" } ] } }} {{#versionselection}} {{> core_question/question_version_selection }} {{/versionselection}}
    {{{question}}}
    {{{comment}}} {{#commenstdisabled}} {{#str}} commentdisabled, qbank_comment {{/str}} {{/commenstdisabled}}
    PK\xbank/comment/amd/src/comment.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Column selector js. * * @module qbank_comment/comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Fragment from 'core/fragment'; import {get_string as getString} from 'core/str'; import ModalEvents from 'core/modal_events'; import SaveCancelModal from 'core/modal_save_cancel'; /** * Event listeners for the module. * * @method clickEvent * @param {Number} questionId * @param {Number} courseID * @param {Number} contextId */ const commentEvent = async(questionId, courseID, contextId) => { const args = { questionid: questionId, courseid: courseID }; const modal = await SaveCancelModal.create({ title: getString('commentheader', 'qbank_comment'), body: Fragment.loadFragment('qbank_comment', 'question_comment', contextId, args), large: true, show: true, buttons: { save: getString('addcomment', 'qbank_comment'), cancel: getString('close', 'qbank_comment'), }, removeOnClose: true, }); const root = modal.getRoot(); // Don't display the default add comment link in the modal. root.on(ModalEvents.bodyRendered, function() { const submitlink = document.querySelectorAll("div.comment-area a")[0]; submitlink.style.display = 'none'; }); // Version selection event. root.on('change', '#question_comment_version_dropdown', (e) =>{ args.questionid = e.target.value; modal.setBody(Fragment.loadFragment('qbank_comment', 'question_comment', contextId, args)); }); // Reload the page when the modal is closed. root.on(ModalEvents.hidden, () => location.reload()); // Handle adding the comment when the button in the modal is clicked. root.on(ModalEvents.save, function(e) { e.preventDefault(); const submitlink = document.querySelectorAll("div.comment-area a")[0]; const textarea = document.querySelectorAll("div.comment-area textarea")[0]; // Check there is a valid comment to add, and trigger adding if there is. if (textarea.value != textarea.getAttribute('aria-label') && textarea.value != '') { submitlink.click(); } }); }; /** * Entrypoint of the js. * * @method init */ export const init = () => { const target = document.querySelector('#categoryquestions'); if (target !== null) { target.addEventListener('click', (e) => { if (e.target.dataset.target && e.target.dataset.target.includes('questioncommentpreview')) { // Call for the event listener to listed for clicks in any comment count row. commentEvent(e.target.dataset.questionid, e.target.dataset.courseid, e.target.dataset.contextid); } }); } }; PK\hĀ)bank/comment/amd/build/comment.min.js.mapnu[{"version":3,"file":"comment.min.js","sources":["../src/comment.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Column selector js.\n *\n * @module qbank_comment/comment\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Fragment from 'core/fragment';\nimport {get_string as getString} from 'core/str';\nimport ModalEvents from 'core/modal_events';\nimport SaveCancelModal from 'core/modal_save_cancel';\n\n/**\n * Event listeners for the module.\n *\n * @method clickEvent\n * @param {Number} questionId\n * @param {Number} courseID\n * @param {Number} contextId\n */\nconst commentEvent = async(questionId, courseID, contextId) => {\n const args = {\n questionid: questionId,\n courseid: courseID\n };\n const modal = await SaveCancelModal.create({\n title: getString('commentheader', 'qbank_comment'),\n body: Fragment.loadFragment('qbank_comment', 'question_comment', contextId, args),\n large: true,\n show: true,\n buttons: {\n save: getString('addcomment', 'qbank_comment'),\n cancel: getString('close', 'qbank_comment'),\n },\n removeOnClose: true,\n });\n const root = modal.getRoot();\n\n // Don't display the default add comment link in the modal.\n root.on(ModalEvents.bodyRendered, function() {\n const submitlink = document.querySelectorAll(\"div.comment-area a\")[0];\n submitlink.style.display = 'none';\n });\n\n // Version selection event.\n root.on('change', '#question_comment_version_dropdown', (e) =>{\n args.questionid = e.target.value;\n modal.setBody(Fragment.loadFragment('qbank_comment', 'question_comment', contextId, args));\n });\n\n // Reload the page when the modal is closed.\n root.on(ModalEvents.hidden, () => location.reload());\n\n // Handle adding the comment when the button in the modal is clicked.\n root.on(ModalEvents.save, function(e) {\n e.preventDefault();\n const submitlink = document.querySelectorAll(\"div.comment-area a\")[0];\n const textarea = document.querySelectorAll(\"div.comment-area textarea\")[0];\n\n // Check there is a valid comment to add, and trigger adding if there is.\n if (textarea.value != textarea.getAttribute('aria-label') && textarea.value != '') {\n submitlink.click();\n }\n\n });\n};\n\n/**\n * Entrypoint of the js.\n *\n * @method init\n */\nexport const init = () => {\n const target = document.querySelector('#categoryquestions');\n if (target !== null) {\n target.addEventListener('click', (e) => {\n if (e.target.dataset.target && e.target.dataset.target.includes('questioncommentpreview')) {\n // Call for the event listener to listed for clicks in any comment count row.\n commentEvent(e.target.dataset.questionid, e.target.dataset.courseid, e.target.dataset.contextid);\n }\n });\n }\n};\n"],"names":["target","document","querySelector","addEventListener","e","dataset","includes","async","questionId","courseID","contextId","args","questionid","courseid","modal","SaveCancelModal","create","title","body","Fragment","loadFragment","large","show","buttons","save","cancel","removeOnClose","root","getRoot","on","ModalEvents","bodyRendered","querySelectorAll","style","display","value","setBody","hidden","location","reload","preventDefault","submitlink","textarea","getAttribute","click","commentEvent","contextid"],"mappings":";;;;;;;;8PAyFoB,WACVA,OAASC,SAASC,cAAc,sBACvB,OAAXF,QACAA,OAAOG,iBAAiB,SAAUC,IAC1BA,EAAEJ,OAAOK,QAAQL,QAAUI,EAAEJ,OAAOK,QAAQL,OAAOM,SAAS,2BAxDvDC,OAAMC,WAAYC,SAAUC,mBACvCC,KAAO,CACTC,WAAYJ,WACZK,SAAUJ,UAERK,YAAcC,2BAAgBC,OAAO,CACvCC,OAAO,mBAAU,gBAAiB,iBAClCC,KAAMC,kBAASC,aAAa,gBAAiB,mBAAoBV,UAAWC,MAC5EU,OAAO,EACPC,MAAM,EACNC,QAAS,CACLC,MAAM,mBAAU,aAAc,iBAC9BC,QAAQ,mBAAU,QAAS,kBAE/BC,eAAe,IAEbC,KAAOb,MAAMc,UAGnBD,KAAKE,GAAGC,sBAAYC,cAAc,WACX9B,SAAS+B,iBAAiB,sBAAsB,GACxDC,MAAMC,QAAU,UAI/BP,KAAKE,GAAG,SAAU,sCAAuCzB,IACrDO,KAAKC,WAAaR,EAAEJ,OAAOmC,MAC3BrB,MAAMsB,QAAQjB,kBAASC,aAAa,gBAAiB,mBAAoBV,UAAWC,UAIxFgB,KAAKE,GAAGC,sBAAYO,QAAQ,IAAMC,SAASC,WAG3CZ,KAAKE,GAAGC,sBAAYN,MAAM,SAASpB,GAC/BA,EAAEoC,uBACIC,WAAaxC,SAAS+B,iBAAiB,sBAAsB,GAC7DU,SAAWzC,SAAS+B,iBAAiB,6BAA6B,GAGpEU,SAASP,OAASO,SAASC,aAAa,eAAmC,IAAlBD,SAASP,OAClEM,WAAWG,YAiBPC,CAAazC,EAAEJ,OAAOK,QAAQO,WAAYR,EAAEJ,OAAOK,QAAQQ,SAAUT,EAAEJ,OAAOK,QAAQyC"}PK\ChT;%bank/comment/amd/build/comment.min.jsnu[define("qbank_comment/comment",["exports","core/fragment","core/str","core/modal_events","core/modal_save_cancel"],(function(_exports,_fragment,_str,_modal_events,_modal_save_cancel){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Column selector js. * * @module qbank_comment/comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_fragment=_interopRequireDefault(_fragment),_modal_events=_interopRequireDefault(_modal_events),_modal_save_cancel=_interopRequireDefault(_modal_save_cancel);_exports.init=()=>{const target=document.querySelector("#categoryquestions");null!==target&&target.addEventListener("click",(e=>{e.target.dataset.target&&e.target.dataset.target.includes("questioncommentpreview")&&(async(questionId,courseID,contextId)=>{const args={questionid:questionId,courseid:courseID},modal=await _modal_save_cancel.default.create({title:(0,_str.get_string)("commentheader","qbank_comment"),body:_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args),large:!0,show:!0,buttons:{save:(0,_str.get_string)("addcomment","qbank_comment"),cancel:(0,_str.get_string)("close","qbank_comment")},removeOnClose:!0}),root=modal.getRoot();root.on(_modal_events.default.bodyRendered,(function(){document.querySelectorAll("div.comment-area a")[0].style.display="none"})),root.on("change","#question_comment_version_dropdown",(e=>{args.questionid=e.target.value,modal.setBody(_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args))})),root.on(_modal_events.default.hidden,(()=>location.reload())),root.on(_modal_events.default.save,(function(e){e.preventDefault();const submitlink=document.querySelectorAll("div.comment-area a")[0],textarea=document.querySelectorAll("div.comment-area textarea")[0];textarea.value!=textarea.getAttribute("aria-label")&&""!=textarea.value&&submitlink.click()}))})(e.target.dataset.questionid,e.target.dataset.courseid,e.target.dataset.contextid)}))}})); //# sourceMappingURL=comment.min.js.mapPK\}77,bank/comment/tests/privacy/provider_test.phpnu[. namespace qbank_comment\privacy; use comment; use context; use context_course; use core_privacy\local\metadata\collection; use qbank_comment\privacy\provider; use core_privacy\local\request\approved_userlist; use stdClass; /** * Privacy api tests. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { /** @var stdClass A teacher who is only enrolled in course1. */ protected $teacher1; /** @var stdClass A teacher who is only enrolled in course2. */ protected $teacher2; /** @var stdClass A teacher who is enrolled in both course1 and course2. */ protected $teacher3; /** @var stdClass A test course. */ protected $course1; /** @var stdClass A test course. */ protected $course2; /** * Set up function for tests in this class. */ protected function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); // Create courses. $generator = $this->getDataGenerator(); $this->course1 = $generator->create_course(); $this->course2 = $generator->create_course(); // Create and enrol teachers. $this->teacher1 = $generator->create_user(); $this->teacher2 = $generator->create_user(); $this->teacher3 = $generator->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $generator->enrol_user($this->teacher1->id, $this->course1->id, $studentrole->id); $generator->enrol_user($this->teacher2->id, $this->course2->id, $studentrole->id); $generator->enrol_user($this->teacher3->id, $this->course1->id, $studentrole->id); $generator->enrol_user($this->teacher3->id, $this->course2->id, $studentrole->id); } /** * Posts a comment on a given context. * * @param string $text The comment's text. * @param context $context The context on which we want to put the comment. */ protected function add_comment($text, context $context) { $args = new stdClass; $args->context = $context; $args->area = 'question'; $args->itemid = 0; $args->component = 'qbank_comment'; $args->linktext = get_string('commentheader', 'qbank_comment'); $args->notoggle = true; $args->autostart = true; $args->displaycancel = false; $comment = new comment($args); $comment->add($text); } /** * Test for provider::get_metadata(). */ public function test_get_metadata(): void { $collection = new collection('qbank_comment'); $newcollection = provider::get_metadata($collection); $itemcollection = $newcollection->get_collection(); $this->assertCount(1, $itemcollection); $link = reset($itemcollection); $this->assertEquals('core_comment', $link->get_name()); $this->assertEmpty($link->get_privacy_fields()); $this->assertEquals('privacy:metadata:core_comment', $link->get_summary()); } /** * Test for provider::get_contexts_for_userid() when user had not posted any comments.. */ public function test_get_contexts_for_userid_no_comment(): void { $this->setUser($this->teacher1); $coursecontext1 = context_course::instance($this->course1->id); $this->add_comment('New comment', $coursecontext1); $this->setUser($this->teacher2); $contextlist = provider::get_contexts_for_userid($this->teacher2->id); $this->assertCount(0, $contextlist); } /** * Test for provider::get_contexts_for_userid(). */ public function test_get_contexts_for_userid(): void { $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); $contextlist = provider::get_contexts_for_userid($this->teacher3->id); $this->assertCount(2, $contextlist); $contextids = $contextlist->get_contextids(); $this->assertEqualsCanonicalizing([$coursecontext1->id, $coursecontext2->id], array_values($contextids)); } /** * Test for provider::export_user_data() when the user has not posted any comments. */ public function test_export_for_context_no_comment(): void { $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher1); $this->add_comment('New comment', $coursecontext1); $this->setUser($this->teacher2); $this->setUser($this->teacher2); $this->export_context_data_for_user($this->teacher1->id, $coursecontext2, 'qbank_comment'); $writer = \core_privacy\local\request\writer::with_context($coursecontext2); $this->assertFalse($writer->has_any_data()); } /** * Test for provider::export_user_data(). */ public function test_export_for_context(): void { $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); // Export all of the data for the context. $this->export_context_data_for_user($this->teacher3->id, $coursecontext1, 'qbank_comment'); $writer = \core_privacy\local\request\writer::with_context($coursecontext1); $this->assertTrue($writer->has_any_data()); } /** * Test for provider::delete_data_for_all_users_in_context(). */ public function test_delete_data_for_all_users_in_context(): void { global $DB; $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher1); $this->add_comment('New comment', $coursecontext1); $this->setUser($this->teacher2); $this->add_comment('New comment', $coursecontext2); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); // Before deletion, we should have 3 comments in $coursecontext1 and 2 comments in $coursecontext2. $this->assertEquals( 3, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext1->id]) ); $this->assertEquals( 2, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext2->id]) ); // Delete data based on context. provider::delete_data_for_all_users_in_context($coursecontext1); // After deletion, the comments for $coursecontext1 should have been deleted. $this->assertEquals( 0, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext1->id]) ); $this->assertEquals( 2, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext2->id]) ); } /** * Test for provider::delete_data_for_user(). */ public function test_delete_data_for_user(): void { global $DB; $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher1); $this->add_comment('New comment', $coursecontext1); $this->setUser($this->teacher2); $this->add_comment('New comment', $coursecontext2); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); // Before deletion, we should have 3 comments in $coursecontext1 and 2 comments in $coursecontext2, // and 3 comments by student12 in $coursecontext1 and $coursecontext2 combined. $this->assertEquals( 3, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext1->id]) ); $this->assertEquals( 2, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext2->id]) ); $this->assertEquals( 3, $DB->count_records('comments', ['component' => 'qbank_comment', 'userid' => $this->teacher3->id]) ); $contextlist = new \core_privacy\local\request\approved_contextlist($this->teacher3, 'qbank_comment', [$coursecontext1->id, $coursecontext2->id]); provider::delete_data_for_user($contextlist); // After deletion, the comments for the student12 should have been deleted. $this->assertEquals( 1, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext1->id]) ); $this->assertEquals( 1, $DB->count_records('comments', ['component' => 'qbank_comment', 'contextid' => $coursecontext2->id]) ); $this->assertEquals( 0, $DB->count_records('comments', ['component' => 'qbank_comment', 'userid' => $this->teacher3->id]) ); } /** * Test that only users within a course context are fetched. */ public function test_get_users_in_context(): void { $component = 'qbank_comment'; $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component); provider::get_users_in_context($userlist1); $this->assertCount(0, $userlist1); $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component); provider::get_users_in_context($userlist2); $this->assertCount(0, $userlist2); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); $this->setUser($this->teacher1); $this->add_comment('New comment', $coursecontext1); // The list of users should contain teacher3 and user1. provider::get_users_in_context($userlist1); $this->assertCount(2, $userlist1); $this->assertTrue(in_array($this->teacher1->id, $userlist1->get_userids())); $this->assertTrue(in_array($this->teacher3->id, $userlist1->get_userids())); // The list of users should contain teacher3. provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); $expected = [$this->teacher3->id]; $actual = $userlist2->get_userids(); $this->assertEquals($expected, $actual); } /** * Test that data for users in approved userlist is deleted. */ public function test_delete_data_for_users(): void { $component = 'qbank_comment'; $coursecontext1 = context_course::instance($this->course1->id); $coursecontext2 = context_course::instance($this->course2->id); $this->setUser($this->teacher3); $this->add_comment('New comment', $coursecontext1); $this->add_comment('New comment', $coursecontext2); $this->setUser($this->teacher1); $this->add_comment('New comment', $coursecontext1); $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component); provider::get_users_in_context($userlist1); $this->assertCount(2, $userlist1); $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component); provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); // Convert $userlist1 into an approved_contextlist. $approvedlist1 = new approved_userlist($coursecontext1, $component, $userlist1->get_userids()); // Delete using delete_data_for_user. provider::delete_data_for_users($approvedlist1); // Re-fetch users in coursecontext1. $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component); provider::get_users_in_context($userlist1); // The user data in coursecontext1 should be deleted. $this->assertCount(0, $userlist1); // Re-fetch users in coursecontext2. $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component); provider::get_users_in_context($userlist2); // The user data in coursecontext2 should be still present. $this->assertCount(1, $userlist2); } } PK\  "bank/comment/tests/backup_test.phpnu[. namespace qbank_comment; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot. '/comment/lib.php'); /** * Question comment backup and restore unit tests. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class backup_test extends \advanced_testcase { /** * @var array Data object for generating a question. */ protected $question1data; /** * @var array Data object for generating a question. */ protected $question2data; /** * @var component_generator_base Question Generator. */ protected $qgen; /** * @var core_course_category Course category. */ protected $category; /** * @var stdClass Course object. */ protected $course; /** * Set up */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); // Set up custom fields. $data = new \stdClass(); $data->component = 'qbank_comment'; $data->area = 'question'; // Question initial set up. $this->category = $this->getDataGenerator()->create_category(); $this->course = $this->getDataGenerator()->create_course(['category' => $this->category->id]); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course->id]); $context = \context_module::instance($qbank->cmid); $this->qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcat = $this->qgen->create_question_category(['contextid' => $context->id]); $this->question1data = ['category' => $qcat->id, 'idnumber' => 'q1']; $this->question2data = ['category' => $qcat->id, 'idnumber' => 'q2']; } /** * Makes a backup of the course. * * @param \stdClass $course The course object. * @return string Unique identifier for this backup. */ protected function backup_course(\stdClass $course): string { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do backup with default settings. MODE_IMPORT means it will just // create the directory and not zip it. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); return $backupid; } /** * Restores a backup that has been made earlier. * * @param string $backupid The unique identifier of the backup. * @param string $fullname Full name of the new course that is going to be created. * @param string $shortname Short name of the new course that is going to be created. * @param int $categoryid The course category the backup is going to be restored in. * @return int The new course id. */ protected function restore_course(string $backupid, string $fullname, string $shortname, int $categoryid): int { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do restore to new course with default settings. $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $categoryid); $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); return $newcourseid; } /** * Test comments attached to questions persist * across the backup and restore process. */ public function test_backup_restore(): void { global $DB, $CFG; require_once($CFG->dirroot . '/comment/lib.php'); $this->resetAfterTest(); $this->setAdminUser(); $courseshortname = $this->course->shortname; $coursefullname = $this->course->fullname; // Create 2 questions. $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); // Add comments to the questions. $args = new \stdClass; $args->context = \context_system::instance(); $args->course = $this->course; $args->area = 'question'; $args->itemid = $question1->id; $args->component = 'qbank_comment'; $args->linktext = get_string('commentheader', 'qbank_comment'); $args->notoggle = true; $args->autostart = true; $args->displaycancel = false; // Two comments for question 1. $commentobj1 = new \comment($args); $commentobj1->add('new \comment for question 1 _ 1'); $comment1 = $commentobj1->add('new \comment for question 1 _ 2'); // One comment for question 2. $args->itemid = $question2->id; $commentobj2 = new \comment($args); $comment2 = $commentobj2->add('new \comment for question 2'); // Create a quiz and the questions to that. $quiz = $this->getDataGenerator()->create_module( 'quiz', ['course' => $this->course->id, 'name' => 'restored_quiz']); quiz_add_quiz_question($question1->id, $quiz); quiz_add_quiz_question($question2->id, $quiz); // Backup the course. $backupid = $this->backup_course($this->course); // Now delete everything. delete_course($this->course, false); question_delete_question($question1->id); question_delete_question($question2->id); // Check the comment data for the questions has also gone. $DB->record_exists('comments', ['id' => $comment1->id]); $this->assertFalse($DB->record_exists('comments', ['id' => $comment1->id])); $this->assertFalse($DB->record_exists('comments', ['id' => $comment2->id])); // Restore the backup we had made earlier into a new course. $newcategory = $this->getDataGenerator()->create_category(); $this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id); // The questions and their associated comments should have been restored. $sql = 'SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.idnumber = ?'; $newquestion1 = $DB->get_record_sql($sql, ['idnumber' => 'q1']); $args->itemid = $newquestion1->id; $commentobj = new \comment($args); $this->assertEquals($commentobj->count(), 2); $newquestion2 = $DB->get_record_sql($sql, ['idnumber' => 'q2']); $args->itemid = $newquestion2->id; $commentobj = new \comment($args); $this->assertEquals($commentobj->count(), 1); } } PK\Lbank/comment/tests/lib_test.phpnu[. namespace qbank_comment; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/bank/comment/lib.php'); /** * Comment lib unit tests. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class lib_test extends \advanced_testcase { /** * Test the comment validation callback. */ public function test_qbank_comment_comment_validate(): void { $commentparams = new \stdClass(); $commentparams->commentarea = 'question'; $commentparams->component = 'qbank_comment'; $isvalid = qbank_comment_comment_validate($commentparams); $this->assertTrue($isvalid); $this->expectException('comment_exception'); $commentparams->commentarea = 'core_comment'; $commentparams->component = 'blog_comment'; qbank_comment_comment_validate($commentparams); } /** * Test the comment display callback. */ public function test_qbank_comment_comment_display(): void { $comment = new \stdClass(); $comment->text = 'test'; $comments = [$comment]; $commentparams = new \stdClass(); $commentparams->commentarea = 'question'; $commentparams->component = 'qbank_comment'; $responses = qbank_comment_comment_display($comments, $commentparams); $this->assertEquals($comment->text, $responses[0]->text); $this->expectException('comment_exception'); $commentparams->commentarea = 'core_comment'; $commentparams->component = 'blog_comment'; qbank_comment_comment_display($comments, $commentparams); } /** * Test the comment preview callback. */ public function test_qbank_comment_preview_display(): void { $this->resetAfterTest(); global $PAGE; $PAGE->set_url('/'); // Make a test question. $category = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(['category' => $category->id]); $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $context = \context_module::instance($qbank->cmid); $qcat = $qgen->create_question_category(['contextid' => $context->id]); $question = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q1']); $result = qbank_comment_preview_display($question, $course->id); // User doesn't have perms so expecting no output. $this->assertEmpty($result); // Expect output. $this->setAdminUser(); $result = qbank_comment_preview_display($question, $course->id); $this->assertStringContainsString('comment-action-post', $result); } /** * Test the comment preview callback. */ public function test_qbank_comment_output_fragment_question_comment(): void { $this->resetAfterTest(); $this->setAdminUser(); global $PAGE; $PAGE->set_url('/'); // Make a test question. $category = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(['category' => $category->id]); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $context = \context_module::instance($qbank->cmid); $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcat = $qgen->create_question_category(['contextid' => $context->id]); $question = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q1']); $args = [ 'questionid' => $question->id, 'courseid' => $course->id, ]; $result = qbank_comment_output_fragment_question_comment($args); // Expect output. $this->assertStringContainsString('comment-action-post', $result); } } PK\qq9bank/comment/tests/event/comment_created_deleted_test.phpnu[. namespace qbank_comment\event; use advanced_testcase; use cache; use comment; use context; use context_course; use context_module; use core_question_generator; use stdClass; /** * Event tests for question comments. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class comment_created_deleted_test extends advanced_testcase { /** @var stdClass Keeps course object */ private $course; /** @var context Keeps context */ private $context; /** @var stdClass Keeps question object */ private $questiondata; /** @var stdClass Keeps comment object */ private $comment; /** * Setup test data. */ public function setUp(): void { global $CFG; require_once($CFG->dirroot . '/comment/lib.php'); parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); /** @var core_question_generator $questiongenerator */ $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course. $this->course = $generator->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course->id]); $this->context = context_module::instance($qbank->cmid); // Create a question in the default category. $contexts = new \core_question\local\bank\question_edit_contexts($this->context); $cat = question_get_default_category($contexts->lowest()->id, true); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); // Ensure the question is not in the cache. $cache = cache::make('core', 'questiondata'); $cache->delete($this->questiondata->id); // Comment on question. $args = new stdClass; $args->context = $this->context; $args->course = $this->course; $args->area = 'question'; $args->itemid = $this->questiondata->id; $args->component = 'qbank_comment'; $args->linktext = get_string('commentheader', 'qbank_comment'); $args->notoggle = true; $args->autostart = true; $args->displaycancel = false; $this->comment = new comment($args); } /** * Test comment_created event. */ public function test_comment_created(): void { // Triggering and capturing the event. $sink = $this->redirectEvents(); $this->comment->add('New comment'); $events = $sink->get_events(); $this->assertCount(1, $events); $event = reset($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\qbank_comment\event\comment_created', $event); $this->assertEquals($this->context, $event->get_context()); $this->assertStringContainsString('\'qbank_comment\' for the question with ID \''.$this->questiondata->id.'\'', $event->get_description()); } /** * Test comment_created event. */ public function test_comment_deleted(): void { // Triggering and capturing the event. $newcomment = $this->comment->add('New comment to delete'); $sink = $this->redirectEvents(); $this->comment->delete($newcomment->id); $events = $sink->get_events(); $this->assertCount(1, $events); $event = reset($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\qbank_comment\event\comment_deleted', $event); $this->assertEquals($this->context, $event->get_context()); $this->assertStringContainsString('\'qbank_comment\' for the question with ID \''.$this->questiondata->id.'\'', $event->get_description()); } } PK\1k k ;bank/comment/tests/event/question_deleted_observer_test.phpnu[. namespace qbank_comment\event; /** * Tests for question_deleted_observer * * @package qbank_comment * @copyright 2023 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 \qbank_comment\event\question_deleted_observer */ final class question_deleted_observer_test extends \advanced_testcase { /** * Deleting a question with comments should also delete the comments * * @return void */ public function test_delete_question_with_comments(): void { $this->resetAfterTest(); $this->setAdminUser(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); [, , , $questions] = $questiongenerator->setup_course_and_questions(); $question = reset($questions); $context = \context_system::instance(); $commentgenerator = $this->getDataGenerator()->get_plugin_generator('core_comment'); /** @var \comment $comment */ $comment = $commentgenerator->create_comment([ 'context' => $context, 'component' => 'qbank_comment', 'area' => 'question', 'itemid' => $question->id, 'content' => random_string(), ]); $this->assertEquals(1, $comment->count()); question_delete_question($question->id); $newcomment = new \comment((object)[ 'context' => $context, 'component' => 'qbank_comment', 'area' => 'question', 'itemid' => $question->id, ]); $this->assertEquals(0, $newcomment->count()); } } PK\8bank/comment/tests/behat/question_comment_column.featurenu[@qbank @qbank_comment Feature: Use the qbank plugin manager page for comment In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | @javascript Scenario: Enable/disable comment column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question comments" And I click on "Disable" "link" in the "Question comments" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" Then "#categoryquestions .header.commentcount" "css_element" should not be visible And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question comments" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And "#categoryquestions .header.commentcount" "css_element" should be visible PK\& ;bank/comment/tests/behat/behat_qbank_comment_deprecated.phpnu[. require_once(__DIR__ . '/../../../../../lib/behat/behat_deprecated_base.php'); /** * Deprecated behat steps for qbank_comment * * @package qbank_comment * @copyright 2024 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 behat_qbank_comment_deprecated extends behat_deprecated_base { /** * Looks for the appropriate hyperlink comment count in the column. * * @Then I should see :arg1 on the comments column * @param string $linkdata * @deprecated Since Moodle 5.0 MDL-79122 in favour of the "qbank_comment > Comment count link" named selector. * @todo Final removal in Moodle 6.0 MDL-82413. */ public function i_should_see_on_the_column(string $linkdata): void { $this->deprecated_message("Use '\"{$linkdata}\" \"qbank_comment > Comment count link\" should exist'"); $this->execute('behat_general::should_exist', [$linkdata, 'qbank_comment > Comment count link']); } /** * Looks for a table, then looks for a row that contains the given text. * Once it finds the right row, it clicks a link in that row. * * @When I click :arg1 on the row on the comments column * @param string $linkname * @deprecated Since Moodle 5.0 MDL-79122 in favour of the "qbank_comment > Comment count link" named selector. * @todo Final removal in Moodle 6.0 MDL-82413. */ public function i_click_on_the_row_containing(string $linkname): void { $this->deprecated_message("Use 'I click on \"{$linkname}\" \"qbank_comment > Comment count link\"'"); $this->execute('behat_general::i_click_on', [$linkname, 'qbank_comment > Comment count link']); } } PK\ӗw0bank/comment/tests/behat/behat_qbank_comment.phpnu[. /** * Commenting system steps definitions for question. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/../../../../tests/behat/behat_question_base.php'); use Behat\Mink\Exception\ExpectationException as ExpectationException, Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; /** * Steps definitions to deal with the commenting system in question. * * @package qbank_comment * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qbank_comment extends behat_question_base { /** * Adds the specified option to the question comments of the current modal. * * @Then I add :arg1 comment to question * @param string $comment */ public function i_add_comment_to_question($comment) { // Getting the textarea and setting the provided value. $exception = new ElementNotFoundException($this->getSession(), 'Question '); if ($this->running_javascript()) { $commentstextarea = $this->find('css', '.modal-dialog .question-comment-view .comment-area textarea', $exception); $commentstextarea->setValue($comment); // We delay 1 second which is all we need. $this->getSession()->wait(1000); } else { throw new ExpectationException('JavaScript not running', $this->getSession()); } } /** * Adds the specified option to the question comments of the question preview. * * @Then I add :arg1 comment to question preview * @param string $comment */ public function i_add_comment_to_question_preview($comment) { // Getting the textarea and setting the provided value. $exception = new ElementNotFoundException($this->getSession(), 'Question '); if ($this->running_javascript()) { $commentstextarea = $this->find('css', '.comment-area textarea', $exception); $commentstextarea->setValue($comment); // We delay 1 second which is all we need. $this->getSession()->wait(1000); } else { throw new ExpectationException('JavaScript not running', $this->getSession()); } } /** * Deletes the specified comment from the current question comment preview. * * @Then I delete :arg comment from question preview * @param string $comment */ public function i_delete_comment_from_question_preview($comment) { $exception = new ElementNotFoundException($this->getSession(), '"' . $comment . '" comment '); // Using xpath liternal to avoid possible problems with comments containing quotes. $commentliteral = behat_context_helper::escape($comment); $commentxpath = "//*[contains(concat(' ', normalize-space(@class), ' '), ' comment-ctrl ')]" . "/descendant::div[@class='comment-message'][contains(., $commentliteral)]"; // Click on delete icon. $this->execute('behat_general::i_click_on_in_the', ["Delete comment posted by", "icon", $this->escape($commentxpath), "xpath_element"] ); // Wait for the animation to finish, in theory is just 1 sec, adding 4 just in case. $this->getSession()->wait(4 * 1000); } /** * Deletes the specified comment from the current question comment modal. * * @Then I delete :arg comment from question * @param string $comment */ public function i_delete_comment_from_question($comment) { $exception = new ElementNotFoundException($this->getSession(), '"' . $comment . '" comment '); // Using xpath liternal to avoid possible problems with comments containing quotes. $commentliteral = behat_context_helper::escape($comment); $commentxpath = "//*[contains(concat(' ', normalize-space(@class), ' '), ' question-comment-view ')]" . "/descendant::div[@class='comment-message'][contains(., $commentliteral)]"; // Click on delete icon. $this->execute('behat_general::i_click_on_in_the', ["Delete comment posted by", "icon", $this->escape($commentxpath), "xpath_element"] ); // Wait for the animation to finish, in theory is just 1 sec, adding 4 just in case. $this->getSession()->wait(4 * 1000); } /** * Define named selectors for the comments column. * * Supported selectors are: * - "qbank_comment > Comment count link" a comment count displayed as a link. * - "qbank_comment > Comment count text" a comment count displayed as un-linked text. * * @return behat_component_named_selector[] */ public static function get_exact_named_selectors(): array { $commentcountxpath = "//table/tbody/tr/td[contains(@class, 'commentcount')]/%s[text() = %%locator%%]"; return [ new behat_component_named_selector( 'Comment count link', [sprintf($commentcountxpath, 'a')] ), new behat_component_named_selector( 'Comment count text', [sprintf($commentcountxpath, 'span')] ), ]; } } PK\O#q"q"1bank/comment/tests/behat/question_comment.featurenu[@qbank @qbank_comment @javascript Feature: A Teacher can comment in a question Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | T1 | Teacher1 | teacher1@example.com | | teacher2 | T2 | Teacher2 | teacher2@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | teacher2 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | @javascript Scenario: Add a comment in question Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And "0" "qbank_comment > Comment count link" should exist And "1" "qbank_comment > Comment count link" should not exist And I click on "0" "qbank_comment > Comment count link" And I add "Super test comment 01" comment to question And I click on "Add comment" "button" in the ".modal-dialog" "css_element" And I should see "Super test comment 01" And I click on "Close" "button" in the ".modal-dialog" "css_element" And "1" "qbank_comment > Comment count link" should exist And "0" "qbank_comment > Comment count link" should not exist @javascript Scenario: Delete a comment from question Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And "0" "qbank_comment > Comment count link" should exist And "1" "qbank_comment > Comment count link" should not exist And I click on "0" "qbank_comment > Comment count link" And I add "Super test comment 01 to be deleted" comment to question And I click on "Add comment" "button" in the ".modal-dialog" "css_element" And I should see "Super test comment 01 to be deleted" And I click on "Close" "button" in the ".modal-dialog" "css_element" And "1" "qbank_comment > Comment count link" should exist And "0" "qbank_comment > Comment count link" should not exist And I click on "1" "qbank_comment > Comment count link" And I delete "Super test comment 01 to be deleted" comment from question And I should not see "Super test comment 01 to be deleted" And I click on "Close" "button" in the ".modal-dialog" "css_element" And "0" "qbank_comment > Comment count link" should exist And "1" "qbank_comment > Comment count link" should not exist @javascript Scenario: Preview question with comments Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I choose "Preview" action for "First question" in the question bank And I click on "Comments" "link" Then I should see "Save comment" And I add "Super test comment 01" comment to question preview And I click on "Save comment" "link" And I wait "1" seconds Then I should see "Super test comment 01" And I click on "Close preview" "button" And "1" "qbank_comment > Comment count link" should exist And "0" "qbank_comment > Comment count link" should not exist And I choose "Preview" action for "First question" in the question bank And I click on "Comments" "link" And I delete "Super test comment 01" comment from question preview And I should not see "Super test comment 01" And I click on "Close preview" "button" And "0" "qbank_comment > Comment count link" should exist And "1" "qbank_comment > Comment count link" should not exist @javascript Scenario: Teacher with comment permissions for their own questions but not others questions Given the following "role capability" exists: | role | editingteacher | | moodle/question:commentmine | allow | | moodle/question:commentall | prevent | And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I choose "Preview" action for "First question" in the question bank Then I should not see "Save comment" And I click on "Close preview" "button" Then I click on "Create a new question ..." "button" And I set the field "item_qtype_essay" to "1" And I press "submitbutton" Then I should see "Adding an Essay question" And I set the field "Question name" to "Essay 01 new" And I set the field "Question text" to "Please write 200 words about Essay 01" And I press "id_submitbutton" Then I should see "Essay 01 new" And I choose "Preview" action for "Essay 01 new" in the question bank And I click on "Comments" "link" Then I should see "Save comment" And I log out And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher2" And I apply question bank filter "Category" with value "Test questions" And I choose "Preview" action for "First question" in the question bank Then I should not see "Save comment" And I click on "Close preview" "button" And I choose "Preview" action for "Essay 01 new" in the question bank Then I should not see "Save comment" And I click on "Close preview" "button" @javascript Scenario: Comments added from the quiz page are visible Given I am on the "Test quiz" "mod_quiz > edit" page logged in as "teacher1" And I press "Add" And I follow "from question bank" And I apply question bank filter "Category" with value "Test questions" And I click on "Select" "checkbox" in the "First question" "table_row" And I click on "Add selected questions to the quiz" "button" And I click on "Preview question" "link" And I switch to "questionpreview" window And I press "Comments" And I set the field "content" to "Some new comment" And I click on "Save comment" "link" And I should see "Some new comment" And I switch to the main window And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I choose "Preview" action for "First question" in the question bank And I click on "Comments" "link" And I should see "Some new comment" And I should see "T1 Teacher1" And I delete "Some new comment" comment from question preview And I should not see "Some new comment" And I am on the "Test quiz" "mod_quiz > edit" page And I click on "Preview question" "link" And I switch to "questionpreview" window And I press "Comments" Then I should not see "Some new comment" @javascript Scenario: Comments modal can change the version using dropdown Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I should see "First question" And I choose "Edit question" action for "First question" in the question bank And I set the field "id_name" to "Renamed question v2" And I set the field "id_questiontext" to "edited question" And I press "id_submitbutton" And I should not see "First question" And I should see "Renamed question v2" And I click on "0" "qbank_comment > Comment count link" And I should see "Version 2" Then I should see "edited question" And I should see "Version 1" And I set the field "question_version_dropdown" to "Version 1" And I should see "Answer the first question" @javascript Scenario: User without system moodle/comment:post capability cannot post comments on question Given the following "role capability" exists: | role | user | | moodle/comment:post | prohibit | Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And "0" "qbank_comment > Comment count text" should exist And "0" "qbank_comment > Comment count link" should not exist PK\~OXXbank/viewquestiontext/save.phpnu[. /** * Synchronously save the question text display preference, and redirect back to the previous page. * * This is progressively enhanced by question_text_format.js, but this remains as a fallback. * * @package qbank_viewquestiontext * @copyright 2023 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 */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot . '/question/editlib.php'); $format = required_param('format', PARAM_INT); $returnurl = required_param('returnurl', PARAM_LOCALURL); require_login(); require_sesskey(); $validformats = [ \qbank_viewquestiontext\output\question_text_format::OFF, \qbank_viewquestiontext\output\question_text_format::PLAIN, \qbank_viewquestiontext\output\question_text_format::FULL, ]; if (!in_array($format, $validformats)) { throw new \invalid_parameter_exception('$format must be one of question_text_format::OFF, ::PLAIN or ::FULL.'); } question_set_or_get_user_preference('qbshowtext', $format, 0, new \moodle_url('/')); redirect(new moodle_url($returnurl)); PK\G8LL bank/viewquestiontext/styles.cssnu[.jsenabled .question-text-format .input-group button { display: none; } PK\>QRR%bank/viewquestiontext/db/services.phpnu[. /** * qbank_viewquestiontext external functions and service definitions. * * @package qbank_viewquestiontext * @category webservice * @copyright 2023 Catalyst IT Europe Ltd * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'qbank_viewquestiontext_set_question_text_format' => [ 'classname' => 'qbank_viewquestiontext\external\set_question_text_format', 'description' => 'Sets the preference for displaying and formatting the question text', 'type' => 'write', 'ajax' => true, ], ]; PK\z8bank/viewquestiontext/lang/en/qbank_viewquestiontext.phpnu[. /** * Strings for component qbank_viewquestiontext, language 'en' * * @package qbank_viewquestiontext * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'View question text'; $string['privacy:metadata'] = 'The View question text question bank plugin does not store any personal data.'; $string['questiontext_condition'] = 'Question text and general feedback'; PK\܈!bank/viewquestiontext/version.phpnu[. /** * Version information for qbank_viewquestiontext. * * @package qbank_viewquestiontext * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_viewquestiontext'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\2' Cbank/viewquestiontext/classes/external/set_question_text_format.phpnu[. namespace qbank_viewquestiontext\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; use qbank_viewquestiontext\output\question_text_format; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/editlib.php'); /** * External function for setting the question text format. * * @package qbank_viewquestiontext * @copyright 2023 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 set_question_text_format extends external_api { /** * Returns description of method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'format' => new external_value(PARAM_INT, 'Format for the question text', VALUE_REQUIRED), ]); } /** * Returns description of method result value. */ public static function execute_returns(): void { } /** * Save the question text format preference for the current user. * * @param int $format Format for the question text. */ public static function execute(int $format): void { [ 'format' => $format, ] = self::validate_parameters( self::execute_parameters(), [ 'format' => $format, ] ); if (!in_array($format, [question_text_format::OFF, question_text_format::PLAIN, question_text_format::FULL])) { throw new \invalid_parameter_exception('$format must be one of question_text_format::OFF, ::PLAIN or ::FULL.'); } $context = context_system::instance(); self::validate_context($context); \question_set_or_get_user_preference('qbshowtext', $format, 0, new \moodle_url('/')); } } PK\%w2bank/viewquestiontext/classes/privacy/provider.phpnu[. namespace qbank_viewquestiontext\privacy; /** * Privacy Subsystem for qbank_viewquestiontext implementing null_provider. * * @package qbank_viewquestiontext * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\5 3bank/viewquestiontext/classes/question_text_row.phpnu[. namespace qbank_viewquestiontext; use core_question\local\bank\row_base; use qbank_viewquestiontext\output\question_text_format; use question_utils; /** * A column type for the name of the question name. * * @package qbank_viewquestiontext * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_text_row extends row_base { /** @var int if true, we will show the question text reduced to plain text, else it is fully rendered. */ protected $preference; /** @var \stdClass $formatoptions options used when displaying the question text as HTML. */ protected $formatoptions; protected function init(): void { // Cannot use $this->get_preference because of PHP type hints. $this->preference = (int)question_get_display_preference($this->get_preference_key(), 0, PARAM_INT, new \moodle_url('')); $this->formatoptions = new \stdClass(); $this->formatoptions->noclean = true; $this->formatoptions->para = false; } public function get_name(): string { return 'questiontext'; } public function get_title(): string { return get_string('questiontext', 'question'); } protected function display_content($question, $rowclasses): void { // Access 'showtext' filter from pagevars. if ($this->preference !== question_text_format::OFF) { $text = ''; if ($this->preference === question_text_format::PLAIN) { $text = s(question_utils::to_plain_text($question->questiontext, $question->questiontextformat, ['noclean' => true, 'para' => false, 'filter' => false])); } else if ($this->preference === question_text_format::FULL) { $text = question_rewrite_question_preview_urls($question->questiontext, $question->id, $question->contextid, 'question', 'questiontext', $question->id, $question->contextid, 'core_question'); $text = format_text($text, $question->questiontextformat, $this->formatoptions); } if ($text == '') { $text = ' '; } echo $text; } } public function get_required_fields(): array { return ['q.questiontext', 'q.questiontextformat']; } public function has_preference(): bool { return false; } public function get_preference_key(): string { return 'qbshowtext'; } } PK\| | 8bank/viewquestiontext/classes/questiontext_condition.phpnu[. namespace qbank_viewquestiontext; use core\output\datafilter; use core_question\local\bank\condition; /** * Filter for question text and question feedback text. * * @package qbank_viewquestiontext * @copyright 2025 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Conn Warwicker */ class questiontext_condition extends condition { #[\Override] public function get_title(): string { return get_string('questiontext_condition', 'qbank_viewquestiontext'); } #[\Override] public static function get_condition_key(): string { return 'questiontext'; } #[\Override] public function get_filter_class() { return 'core/datafilter/filtertypes/keyword'; } #[\Override] public static function build_query_from_filter(array $filter): array { global $DB; // Are we inverting the search to look for where NOT like? $notlike = ($filter['jointype'] === datafilter::JOINTYPE_NONE); [$params, $conditions] = [[], []]; // Loop through the values we've sent from the filter. foreach ($filter['values'] as $key => $value) { $params['questiontext_' . $key] = '%' . $value . '%'; $params['questiontext_f_' . $key] = '%' . $value . '%'; $condition = $DB->sql_like( 'q.questiontext', ':questiontext_' . $key, false, true, $notlike ); $condition .= ($notlike) ? ' AND ' : ' OR '; $condition .= $DB->sql_like( 'q.generalfeedback', ':questiontext_f_' . $key, false, true, $notlike ); $conditions[] = '(' . $condition . ')'; } $delimiter = ($filter['jointype'] === datafilter::JOINTYPE_ANY) ? ' OR ' : ' AND '; return [ implode($delimiter, $conditions), $params, ]; } } PK\n`҆ =bank/viewquestiontext/classes/output/question_text_format.phpnu[. namespace qbank_viewquestiontext\output; use core_question\local\bank\view; use qbank_viewquestiontext\question_text_row; use renderer_base; /** * Question text format selector. * * @package qbank_viewquestiontext * @copyright 2023 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 question_text_format implements \renderable, \templatable { /** * @var int Question text is off. */ const OFF = 0; /** * @var int Question text is displayed in plain text mode. */ const PLAIN = 1; /** * @var int Question text is displayed fully rendered. */ const FULL = 2; /** @var int|mixed The current display preference value. */ protected int $preference; /** * @var \moodle_url The return URL for redirecting back to the current question bank page. */ protected \moodle_url $returnurl; /** * Store the returnurl and the current preference value. * * @param view $qbank * @throws \moodle_exception */ public function __construct(view $qbank) { $row = new question_text_row($qbank); $this->returnurl = new \moodle_url($qbank->returnurl); $this->preference = question_get_display_preference($row->get_preference_key(), 0, PARAM_INT, new \moodle_url('')); } public function export_for_template(renderer_base $output): array { return [ 'formaction' => new \moodle_url('/question/bank/viewquestiontext/save.php'), 'sesskey' => sesskey(), 'returnurl' => $this->returnurl->out(false), 'options' => [ (object)[ 'label' => get_string('showquestiontext_off', 'question'), 'value' => self::OFF, 'selected' => $this->preference === self::OFF, ], (object)[ 'label' => get_string('showquestiontext_plain', 'question'), 'value' => self::PLAIN, 'selected' => $this->preference === self::PLAIN, ], (object)[ 'label' => get_string('showquestiontext_full', 'question'), 'value' => self::FULL, 'selected' => $this->preference === self::FULL, ], ], 'label' => get_string('showquestiontext', 'core_question'), ]; } } PK\WYgg0bank/viewquestiontext/classes/plugin_feature.phpnu[. namespace qbank_viewquestiontext; use core\context; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; use qbank_viewquestiontext\output\question_text_format; /** * Class columns is the entrypoint for the columns. * * @package qbank_viewquestiontext * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { /** * Return an additional row for displaying the question text, if user has a preference set to display it. * * @param view $qbank * @return array */ public function get_question_columns(view $qbank): array { $row = new question_text_row($qbank); $preference = (int)question_get_display_preference($row->get_preference_key(), '0', PARAM_INT, new \moodle_url('/')); if ($preference != question_text_format::OFF) { return [ $row, ]; } return []; } public function get_question_bank_controls(view $qbank, context $context, int $categoryid): array { return [ 400 => new question_text_format($qbank), ]; } #[\Override] public function get_question_filters(?view $qbank = null): array { return [ new questiontext_condition($qbank), ]; } } PK\A=bank/viewquestiontext/templates/question_text_format.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_viewquestiontext/question_text_format Example context (json): { "label": "Question text format", "options": [ { "value": 1, "label": "Plain text", "selected": true }, { "value": 2, "label": "Fully rendered", "selected": false } ] } }}
    {{#js}} require(['qbank_viewquestiontext/question_text_format'], QuestionTextFormat => { QuestionTextFormat.init('questionscontainer'); }); {{/js}} PK\W+bank/viewquestiontext/amd/src/repository.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * External function calls for qbank_columnsortorder * * @module qbank_viewquestiontext/repository * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {call as fetchMany} from 'core/ajax'; /** * Set the question text format. * * @param {Number} format The question text format. * @return {Promise} */ export const setQuestionTextFormat = (format) => fetchMany([{ methodname: 'qbank_viewquestiontext_set_question_text_format', args: { format, }, }])[0]; PK\Z5bank/viewquestiontext/amd/src/question_text_format.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Javascript for question_text_format question bank control. * * @module qbank_viewquestiontext/question_text_format * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as repository from 'qbank_viewquestiontext/repository'; import RefreshUi from 'core_question/refresh_ui'; import Notification from 'core/notification'; const SELECTORS = { formatSelectId: 'question-text-format', returnUrl: '[name=returnurl]', }; let uiRoot; /** * Save the selected format via a web service call, and refresh the UI. * * @param {Event} e Select field change event. * @return {Promise} */ const handleFormatChange = async(e) => { const value = e.target.value; try { await repository.setQuestionTextFormat(value); const returnUrlInput = e.target.closest('form').querySelector(SELECTORS.returnUrl); const returnUrl = new URL(returnUrlInput.value); await RefreshUi.refresh(uiRoot, returnUrl); } catch (ex) { Notification.exception(ex); } }; /** * Initialise question text format widget. * * Find the uiRoot element and attach a change listener to the question text format selector. * * @param {String} uiRootId */ export const init = (uiRootId) => { uiRoot = document.getElementById(uiRootId); const select = document.getElementById(SELECTORS.formatSelectId); select.addEventListener('change', handleFormatChange); }; PK\ʍFzz1bank/viewquestiontext/amd/build/repository.min.jsnu[define("qbank_viewquestiontext/repository",["exports","core/ajax"],(function(_exports,_ajax){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setQuestionTextFormat=void 0;_exports.setQuestionTextFormat=format=>(0,_ajax.call)([{methodname:"qbank_viewquestiontext_set_question_text_format",args:{format:format}}])[0]})); //# sourceMappingURL=repository.min.js.mapPK\8}@5bank/viewquestiontext/amd/build/repository.min.js.mapnu[{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * External function calls for qbank_columnsortorder\n *\n * @module qbank_viewquestiontext/repository\n * @copyright 2023 Catalyst IT Europe Ltd.\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\n\n/**\n * Set the question text format.\n *\n * @param {Number} format The question text format.\n * @return {Promise}\n */\nexport const setQuestionTextFormat = (format) => fetchMany([{\n methodname: 'qbank_viewquestiontext_set_question_text_format',\n args: {\n format,\n },\n}])[0];\n"],"names":["format","methodname","args"],"mappings":"0NAgCsCA,SAAW,cAAU,CAAC,CACxDC,WAAY,kDACZC,KAAM,CACFF,OAAAA,WAEJ"}PK\)(Y Y ;bank/viewquestiontext/amd/build/question_text_format.min.jsnu[define("qbank_viewquestiontext/question_text_format",["exports","qbank_viewquestiontext/repository","core_question/refresh_ui","core/notification"],(function(_exports,repository,_refresh_ui,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj} /** * Javascript for question_text_format question bank control. * * @module qbank_viewquestiontext/question_text_format * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */(repository),_refresh_ui=_interopRequireDefault(_refresh_ui),_notification=_interopRequireDefault(_notification);const SELECTORS_formatSelectId="question-text-format",SELECTORS_returnUrl="[name=returnurl]";let uiRoot;const handleFormatChange=async e=>{const value=e.target.value;try{await repository.setQuestionTextFormat(value);const returnUrlInput=e.target.closest("form").querySelector(SELECTORS_returnUrl),returnUrl=new URL(returnUrlInput.value);await _refresh_ui.default.refresh(uiRoot,returnUrl)}catch(ex){_notification.default.exception(ex)}};_exports.init=uiRootId=>{uiRoot=document.getElementById(uiRootId);document.getElementById(SELECTORS_formatSelectId).addEventListener("change",handleFormatChange)}})); //# sourceMappingURL=question_text_format.min.js.mapPK\#u ?bank/viewquestiontext/amd/build/question_text_format.min.js.mapnu[{"version":3,"file":"question_text_format.min.js","sources":["../src/question_text_format.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for question_text_format question bank control.\n *\n * @module qbank_viewquestiontext/question_text_format\n * @copyright 2023 Catalyst IT Europe Ltd.\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as repository from 'qbank_viewquestiontext/repository';\nimport RefreshUi from 'core_question/refresh_ui';\nimport Notification from 'core/notification';\n\nconst SELECTORS = {\n formatSelectId: 'question-text-format',\n returnUrl: '[name=returnurl]',\n};\n\nlet uiRoot;\n\n/**\n * Save the selected format via a web service call, and refresh the UI.\n *\n * @param {Event} e Select field change event.\n * @return {Promise}\n */\nconst handleFormatChange = async(e) => {\n const value = e.target.value;\n try {\n await repository.setQuestionTextFormat(value);\n const returnUrlInput = e.target.closest('form').querySelector(SELECTORS.returnUrl);\n const returnUrl = new URL(returnUrlInput.value);\n await RefreshUi.refresh(uiRoot, returnUrl);\n } catch (ex) {\n Notification.exception(ex);\n }\n};\n\n/**\n * Initialise question text format widget.\n *\n * Find the uiRoot element and attach a change listener to the question text format selector.\n *\n * @param {String} uiRootId\n */\nexport const init = (uiRootId) => {\n uiRoot = document.getElementById(uiRootId);\n const select = document.getElementById(SELECTORS.formatSelectId);\n select.addEventListener('change', handleFormatChange);\n};\n"],"names":["SELECTORS","uiRoot","handleFormatChange","async","value","e","target","repository","setQuestionTextFormat","returnUrlInput","closest","querySelector","returnUrl","URL","RefreshUi","refresh","ex","exception","uiRootId","document","getElementById","addEventListener"],"mappings":";;;;;;;;4HA4BMA,yBACc,uBADdA,oBAES,uBAGXC,aAQEC,mBAAqBC,MAAAA,UACjBC,MAAQC,EAAEC,OAAOF,gBAEbG,WAAWC,sBAAsBJ,aACjCK,eAAiBJ,EAAEC,OAAOI,QAAQ,QAAQC,cAAcX,qBACxDY,UAAY,IAAIC,IAAIJ,eAAeL,aACnCU,oBAAUC,QAAQd,OAAQW,WAClC,MAAOI,0BACQC,UAAUD,oBAWVE,WACjBjB,OAASkB,SAASC,eAAeF,UAClBC,SAASC,eAAepB,0BAChCqB,iBAAiB,SAAUnB"}PK\`x/ Cbank/viewquestiontext/tests/behat/view_question_text_column.featurenu[@qbank @qbank_viewquestiontext Feature: Use the qbank plugin manager page for viewquestiontext In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first <question> | @javascript Scenario: Display of plain question text can be turned on and off When I am on the "Test quiz" "mod_quiz > question bank" page logged in as admin And I apply question bank filter "Category" with value "Test questions" And I set the field "Show question text in the question list?" to "text only" Then I should see "Answer the first " And ".totestforhtml" "css_element" should not exist in the "Answer the first " "table_row" And I set the field "Show question text in the question list?" to "No" And I should not see "Answer the first " @javascript Scenario: Display of full question text can be turned on and off When I am on the "Test quiz" "mod_quiz > question bank" page logged in as admin And I apply question bank filter "Category" with value "Test questions" And I set the field "Show question text in the question list?" to "with images" Then I should see "Answer the first " And ".totestforhtml" "css_element" should exist in the "Answer the first " "table_row" And I set the field "Show question text in the question list?" to "No" And I should not see "Answer the first " @javascript Scenario: Option does not show if the plugin is disabled Given the following config values are set as admin: | disabled | 1 | qbank_viewquestiontext | When I am on the "Test quiz" "mod_quiz > question bank" page logged in as admin Then I should not see "Show question text in the question list" @javascript Scenario: Enable/disable viewquestiontext column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "View question text" When I click on "Disable" "link" in the "View question text" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should not see "Show question text in the question list" Then "#categoryquestions .questiontext" "css_element" should not be visible And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View question text" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" When I set the field "Show question text in the question list?" to "text only" And I should see "Answer the first " PK\EV Hbank/viewquestiontext/tests/behat/filter_condition_question_text.featurenu[@qbank @qbank_viewquestiontext @javascript Feature: Filter questions by text found in the question text or general feedback As a teacher In order to organise my questions I want to filter the list of questions by text content Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | generalfeedback | | Test questions | truefalse | Question 1 | ABC | EFG | | Test questions | truefalse | Question 2 | CDE | ABC | | Test questions | truefalse | Question 3 | EFGZ | IJK | | Test questions | truefalse | Question 4 | GHI | CDEZ | | Test questions | truefalse | Question 5 | IJK | GHI | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "Question 1" And I should see "Question 2" And I should see "Question 3" And I should see "Question 4" And I should see "Question 5" Scenario: Filter by a single piece of text When I apply question bank filter "Question text and general feedback" with value "A" Then I should see "Question 1" And I should see "Question 2" And I should not see "Question 3" And I should not see "Question 4" And I should not see "Question 5" Scenario: Filter by any piece of text When I apply question bank filter "Question text and general feedback" with value "A, K" Then I should see "Question 1" And I should see "Question 2" And I should see "Question 3" And I should see "Question 5" And I should not see "Question 4" Scenario: Filter by all pieces of text When I add question bank filter "Question text and general feedback" And I set the field "Question text and general feedback" to "F, G" And I set the field "Match" in the "Filter 3" "fieldset" to "All" And I press "Apply filters" Then I should see "Question 1" Then I should see "Question 3" Then I should not see "Question 2" Then I should not see "Question 4" Then I should not see "Question 5" Scenario: Exclude text by filter When I add question bank filter "Question text and general feedback" And I set the field "Question text and general feedback" to "A, Z" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should see "Question 5" Then I should not see "Question 1" Then I should not see "Question 2" Then I should not see "Question 3" Then I should not see "Question 4" PK\${08bank/viewquestiontype/lang/en/qbank_viewquestiontype.phpnu[. /** * Strings for component qbank_viewquestiontype, language 'en' * * @package qbank_viewquestiontype * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['typefilter'] = 'Type'; $string['pluginname'] = 'View question type'; $string['privacy:metadata'] = 'The View question type question bank plugin does not store any personal data.'; PK\$È!bank/viewquestiontype/version.phpnu[. /** * Version information for qbank_viewquestiontype. * * @package qbank_viewquestiontype * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_viewquestiontype'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\\6bank/viewquestiontype/classes/question_type_column.phpnu[. namespace qbank_viewquestiontype; use core_question\local\bank\column_base; /** * A column with a type of question for each question with name q{questionid}. * * @package qbank_viewquestiontype * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_type_column extends column_base { public function get_name(): string { return 'qtype'; } public function get_title(): string { return get_string('qtypeveryshort', 'question'); } public function get_title_tip(): string { return get_string('questiontype', 'question'); } protected function display_content($question, $rowclasses): void { echo print_question_icon($question); } public function get_required_fields(): array { return ['q.qtype']; } public function is_sortable() { return 'q.qtype'; } public function get_default_width(): int { return 45; } } PK\S:œ 0bank/viewquestiontype/classes/type_condition.phpnu[. namespace qbank_viewquestiontype; use question_bank; use core\output\datafilter; use core_question\local\bank\condition; /** * Filter condition for question type * * @package qbank_viewquestiontype * @copyright 2024 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 type_condition extends condition { #[\Override] public static function get_condition_key() { return 'qtype'; } #[\Override] public function get_title() { return get_string('typefilter', 'qbank_viewquestiontype'); } #[\Override] public function get_filter_class() { return 'qbank_viewquestiontype/datafilter/filtertype/type'; } #[\Override] public function allow_custom() { return false; } /** * Get the list of available joins for the filter. * * Questions can be ANY or NONE of the selected types. Since questions cannot have multiple types, * allowing "ALL" does not make sense here. * * @return array */ public function get_join_list(): array { return [ datafilter::JOINTYPE_NONE, datafilter::JOINTYPE_ANY, ]; } /** * Build a list of the available question types. * * @return array */ public function get_initial_values(): array { $types = question_bank::get_all_qtypes(); $values = []; foreach ($types as $plugin => $type) { $values[] = [ 'value' => $plugin, 'title' => get_string('pluginname', 'qtype_' . $plugin), ]; } usort($values, fn($a, $b) => $a['title'] <=> $b['title']); return $values; } /** * Build a WHERE condition to filter the q.qtype column by the selected question types. * * @param array $filter * @return array * @throws \coding_exception * @throws \dml_exception */ public static function build_query_from_filter(array $filter): array { global $DB; // Remove empty string. $filter['values'] = array_filter($filter['values']); $selectedtypes = $filter['values'] ?? []; $params = []; $where = ''; $jointype = $filter['jointype'] ?? self::JOINTYPE_DEFAULT; if ($selectedtypes) { // If we are matching NONE rather than ANY, exclude the selected types instead. $equal = !($jointype === datafilter::JOINTYPE_NONE); [$typesql, $params] = $DB->get_in_or_equal($selectedtypes, SQL_PARAMS_NAMED, 'param', $equal); $where = "q.qtype $typesql"; } return [$where, $params]; } } PK\Ӏų2bank/viewquestiontype/classes/privacy/provider.phpnu[. namespace qbank_viewquestiontype\privacy; /** * Privacy Subsystem for qbank_viewquestiontype implementing null_provider. * * @package qbank_viewquestiontype * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\׻p0bank/viewquestiontype/classes/plugin_feature.phpnu[. namespace qbank_viewquestiontype; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_viewquestiontype * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { #[\Override] public function get_question_columns($qbank): array { return [ new question_type_column($qbank), ]; } #[\Override] public function get_question_filters(?view $qbank = null): array { return [ new type_condition($qbank), ]; } } PK\%\\;bank/viewquestiontype/amd/src/datafilter/filtertype/type.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Filter managing hidden questions. * * @module qbank_viewquestiontype/datafilter/filtertypes/type * @author Mark Johnson * @copyright 2024 Catalyst IT Europe Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Filter from 'core/datafilter/filtertype'; export default class extends Filter { /** * Get the list of values for this filter type. * * Overrides the default behaviour of running parseInt on the raw values, since we have textual * plugin identifiers and not numeric IDs. * * @returns {Array} */ get values() { return this.rawValues; } } PK\q[hAbank/viewquestiontype/amd/build/datafilter/filtertype/type.min.jsnu[define("qbank_viewquestiontype/datafilter/filtertype/type",["exports","core/datafilter/filtertype"],(function(_exports,_filtertype){var obj; /** * Filter managing hidden questions. * * @module qbank_viewquestiontype/datafilter/filtertypes/type * @author Mark Johnson * @copyright 2024 Catalyst IT Europe Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_filtertype=(obj=_filtertype)&&obj.__esModule?obj:{default:obj};class _default extends _filtertype.default{get values(){return this.rawValues}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=type.min.js.mapPK\NVVEbank/viewquestiontype/amd/build/datafilter/filtertype/type.min.js.mapnu[{"version":3,"file":"type.min.js","sources":["../../../src/datafilter/filtertype/type.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Filter managing hidden questions.\n *\n * @module qbank_viewquestiontype/datafilter/filtertypes/type\n * @author Mark Johnson \n * @copyright 2024 Catalyst IT Europe Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Filter from 'core/datafilter/filtertype';\n\nexport default class extends Filter {\n /**\n * Get the list of values for this filter type.\n *\n * Overrides the default behaviour of running parseInt on the raw values, since we have textual\n * plugin identifiers and not numeric IDs.\n *\n * @returns {Array}\n */\n get values() {\n return this.rawValues;\n }\n}\n"],"names":["Filter","values","this","rawValues"],"mappings":";;;;;;;;4KA0B6BA,oBASrBC,oBACOC,KAAKC"}PK\uHbank/viewquestiontype/tests/behat/filter_condition_question_type.featurenu[@qbank @qbank_viewquestiontype @javascript Feature: Filter questions by type As a teacher In order to organise my questions I want to filter the list of questions by type Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Qbank 1 | truefalse | First question | Answer the first question | | Default for Qbank 1 | numerical | Second question | Answer the second question | | Default for Qbank 1 | essay | Third question | Answer the third question | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" Scenario: Filter by a single type When I apply question bank filter "Type" with value "True/False" Then I should see "First question" And I should not see "Second question" And I should not see "Third question" Scenario: Filter by multiple types When I apply question bank filter "Type" with value "True/False, Essay" Then I should see "First question" And I should not see "Second question" And I should see "Third question" Scenario: Exclude types by filter When I add question bank filter "Type" And I set the field "Type" to "True/False, Essay" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should not see "Third question" PK\CCbank/viewquestiontype/tests/behat/view_question_type_column.featurenu[@qbank @qbank_viewquestiontype Feature: Use the qbank plugin manager page for viewquestiontype In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | @javascript Scenario: Enable/disable viewquestiontype column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "View question type" And I click on "Disable" "link" in the "View question type" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" Then "#categoryquestions .header.qtype" "css_element" should not be visible And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View question type" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And "#categoryquestions .header.qtype" "css_element" should be visible PK\Nk=bank/customfields/db/access.phpnu[. /** * Plugin capabilities are defined here. * * @package qbank_customfields * @category access * @copyright 2021 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Controls if a user can can change the value of a locked custom field. 'qbank/customfields:changelockedcustomfields' => [ 'riskbitmask' => RISK_SPAM, 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'manager' => CAP_ALLOW ], ], // Controls if a user can configure question custom fields. 'qbank/customfields:configurecustomfields' => [ 'riskbitmask' => RISK_SPAM, 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, 'clonepermissionsfrom' => 'moodle/site:config' ], // Controls if a user can view the content of hidden custom fields. 'qbank/customfields:viewhiddencustomfields' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], ], ]; PK\!;bank/customfields/db/events.phpnu[. /** * Question custom fields events * * @package qbank_customfields * @copyright 2023 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 */ defined('MOODLE_INTERNAL') || die(); $observers = [ [ 'eventname' => '\core\event\question_deleted', 'callback' => '\qbank_customfields\event\question_deleted_observer::delete_question_customfields' ] ]; PK\%9x880bank/customfields/lang/en/qbank_customfields.phpnu[. /** * Strings for component qbank_customfields, language 'en'. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Question custom fields'; $string['privacy:metadata'] = 'The Question custom fields question bank plugin does not store any personal data.'; $string['customfield'] = 'Question custom fields'; $string['customfield_islocked'] = 'Locked'; $string['customfield_islocked_help'] = 'If the field is locked, only users with the capability to change locked question custom fields (users with the default role of manager) will be able to change it in the question settings.'; $string['customfield_notvisible'] = 'Nobody'; $string['customfield_visibility'] = 'Visible to'; $string['customfield_visibility_help'] = 'This setting determines who can view the custom field name and value.'; $string['customfield_visibletoall'] = 'Everyone'; $string['customfield_visibletoteachers'] = 'Teachers'; $string['customfieldsettings'] = 'Common question custom fields settings'; $string['customfields:changelockedcustomfields'] = 'Change locked question custom fields'; $string['customfields:configurecustomfields'] = 'Configure custom fields'; $string['customfields:viewhiddencustomfields'] = 'View hidden question custom fields'; PK\8 Kbank/customfields/backup/moodle2/backup_qbank_customfields_plugin.class.phpnu[. /** * Provides the information to backup question custom field. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qbank_customfields_plugin extends \backup_qbank_plugin { /** * Returns the comment information to attach to question element. * * @return backup_plugin_element The backup plugin element */ protected function define_question_plugin_structure(): backup_plugin_element { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); $customfields = new backup_nested_element('customfields'); $customfield = new backup_nested_element('customfield', ['id'], ['shortname', 'type', 'value', 'valueformat', 'valuetrust']); $pluginwrapper->add_child($customfields); $customfields->add_child($customfield); $customfield->set_source_sql("SELECT cfd.id, cff.shortname, cff.type, cfd.value, cfd.valueformat, cfd.valuetrust FROM {customfield_data} cfd JOIN {customfield_field} cff ON cff.id = cfd.fieldid JOIN {customfield_category} cfc ON cfc.id = cff.categoryid WHERE cfc.component = 'qbank_customfields' AND cfc.area = 'question' AND cfd.instanceid = ?", [ backup::VAR_PARENTID ]); // Don't need to annotate ids nor files. return $plugin; } } PK\0qh h Lbank/customfields/backup/moodle2/restore_qbank_customfields_plugin.class.phpnu[. /** * Restore plugin class that provides the necessary information needed to restore comments for questions. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qbank_customfields_plugin extends restore_qbank_plugin { /** @var stdClass|null a fieldset object. */ protected $cachedcategory; /** * Returns the paths to be handled by the plugin at question level. * * @return restore_path_element[] The restore path element array. */ protected function define_question_plugin_structure(): array { return [new restore_path_element('customfield', $this->get_pathfor('/customfields/customfield'))]; } /** * Process the question custom field element. * * @param array $data The custom field data to restore. */ public function process_customfield(array $data) { global $DB; $newquestion = $this->get_new_parentid('question'); if (!empty($data->contextid) && $newcontextid = $this->get_mappingid('context', $data->contextid)) { $fieldcontextid = $newcontextid; } else { // Get the category, so we can then later get the context. $categoryid = $this->get_new_parentid('question_category'); if (empty($this->cachedcategory) || $this->cachedcategory->id != $categoryid) { $this->cachedcategory = $DB->get_record('question_categories', ['id' => $categoryid]); } $fieldcontextid = $this->cachedcategory->contextid; } $data['newquestion'] = $newquestion; $data['fieldcontextid'] = $fieldcontextid; $customfieldhandler = qbank_customfields\customfield\question_handler::create(); $customfieldhandler->restore_instance_data_from_backup($this->task, $data); } } PK\U۞!bank/customfields/customfield.phpnu[. /** * Manage question custom fields * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once('../../..//config.php'); require_once($CFG->libdir.'/adminlib.php'); admin_externalpage_setup('qbank_customfields'); $output = $PAGE->get_renderer('core_customfield'); $customfieldhandler = qbank_customfields\customfield\question_handler::create(); $outputpage = new \core_customfield\output\management($customfieldhandler); echo $output->header(), $output->heading(new lang_string('pluginname', 'qbank_customfields')), $output->render($outputpage), $output->footer(); PK\62bank/customfields/lib.phpnu[. /** * Helper functions and callbacks. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Comment content for callbacks. * * @param question_definition $question * @param int $courseid * @return string */ function qbank_customfields_preview_display(question_definition $question, int $courseid): string { // Prepare custom fields data. $customfieldhandler = qbank_customfields\customfield\question_handler::create(); $catfielddata = $customfieldhandler->get_categories_fields_data($question->id); return $customfieldhandler->display_custom_categories_fields($catfielddata); } PK\'Nzzbank/customfields/version.phpnu[. /** * Version information for qbank_customfields. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_customfields'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\G^1^1:bank/customfields/classes/customfield/question_handler.phpnu[. namespace qbank_customfields\customfield; use core_customfield\api; use core_customfield\field_controller; use core_customfield\output\field_data; /** * Question handler for custom fields. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_handler extends \core_customfield\handler { /** * @var question_handler */ static protected $singleton; /** * @var \context */ protected $parentcontext; /** @var int Field is displayed in the question display and question preview, visible to everybody */ const VISIBLETOALL = 2; /** @var int Field is displayed in the question display and question preview but only for "teachers" */ const VISIBLETOTEACHERS = 1; /** @var int Field is not displayed in the question display and question preview */ const NOTVISIBLE = 0; /** * Creates the custom field handler and returns a singleton. * Itemid is always zero as the custom fields are the same * for every question across the system. * * @param int $itemid Always zero. * @return \qbank_customfields\customfield\question_handler */ public static function create(int $itemid = 0): \core_customfield\handler { if (static::$singleton === null) { self::$singleton = new static(0); } return self::$singleton; } /** * Run reset code after unit tests to reset the singleton usage. */ public static function reset_caches(): void { if (!PHPUNIT_TEST) { throw new \coding_exception('This feature is only intended for use in unit tests'); } static::$singleton = null; } /** * The current user can configure custom fields on this component. * * @return bool true if the current can configure custom fields, false otherwise */ public function can_configure(): bool { return has_capability('qbank/customfields:configurecustomfields', $this->get_configuration_context()); } /** * The current user can edit custom fields for the given question. * * @param field_controller $field * @param int $instanceid id of the question to test edit permission * @return bool true if the current can edit custom fields, false otherwise */ public function can_edit(field_controller $field, int $instanceid = 0): bool { if ($instanceid) { $context = $this->get_instance_context($instanceid); } else { $context = $this->get_parent_context(); } return (!$field->get_configdata_property('locked') || has_capability('qbank/customfields:changelockedcustomfields', $context)); } /** * The current user can view custom fields for the given question. * * @param field_controller $field * @param int $instanceid id of the question to test edit permission * @return bool true if the current can edit custom fields, false otherwise */ public function can_view(field_controller $field, int $instanceid): bool { $visibility = $field->get_configdata_property('visibility'); if ($visibility == self::NOTVISIBLE) { return false; } else if ($visibility == self::VISIBLETOTEACHERS) { return has_capability('qbank/customfields:viewhiddencustomfields', $this->get_instance_context($instanceid)); } else { return true; } } /** * Determine if the current user can view custom field in their given context. * This determines if the user can see the field at all not just the field for * a particular instance. * Used primarily in showing or not the field in the question bank table. * * @param field_controller $field The field trying to be viewed. * @param \context $context The context the field is being displayed in. * @return bool true if the current can edit custom fields, false otherwise. */ public function can_view_type(field_controller $field, \context $context): bool { $visibility = $field->get_configdata_property('visibility'); if ($visibility == self::NOTVISIBLE) { return false; } else if ($visibility == self::VISIBLETOTEACHERS) { return has_capability('qbank/customfields:viewhiddencustomfields', $context); } else { return true; } } /** * Sets parent context for the question. * * This may be needed when question is being created, there is no question context but we need to check capabilities * * @param \context $context */ public function set_parent_context(\context $context): void { $this->parentcontext = $context; } /** * Returns the parent context for the question. * * @return \context */ protected function get_parent_context(): \context { if ($this->parentcontext) { return $this->parentcontext; } else { return \context_system::instance(); } } /** * Context that should be used for new categories created by this handler. * * @return \context the context for configuration */ public function get_configuration_context(): \context { return \context_system::instance(); } /** * URL for configuration page for the fields for the question custom fields. * * @return \moodle_url The URL to configure custom fields for this component */ public function get_configuration_url(): \moodle_url { return new \moodle_url('/question/customfield.php'); } /** * Returns the context for the data associated with the given instanceid. * * @param int $instanceid id of the record to get the context for * @return \context the context for the given record * @throws \coding_exception */ public function get_instance_context(int $instanceid = 0): \context { if ($instanceid > 0) { $questiondata = \question_bank::load_question_data($instanceid); $contextid = $questiondata->contextid; $context = \context::instance_by_id($contextid); return $context; } else { throw new \coding_exception('Instance id must be provided.'); } } /** * Given a field and instance id get all the filed data. * * @param field_controller $field The field to get the data for. * @param int $instanceid The instance id to get the data for. * @return \core_customfield\data_controller The fetched data. */ public function get_field_data(\core_customfield\field_controller $field, int $instanceid): \core_customfield\data_controller { $fields = [$field->get('id') => $field]; $fieldsdata = api::get_instance_fields_data($fields, $instanceid); return $fieldsdata[$field->get('id')]; } /** * For a given instance id (question id) get the categories and the * fields with any data. Return an array of categories containing an * array of field names and values that is ready to be passed to a renderer. * * @param int $instanceid The instance id to get the data for. * @return array $cfdata The fetched data */ public function get_categories_fields_data(int $instanceid): array { // Prepare custom fields data. $instancedata = $this->get_instance_data($instanceid); $cfdata = []; foreach ($instancedata as $instance) { $field = $instance->get_field(); if ($this->can_view($field, $instanceid)) { $category = $instance->get_field()->get_category()->get('name'); $fieldname = $field->get_formatted_name(); $fieldvalue = $this->get_field_data($field, $instanceid)->export_value(); $cfdata[$category][] = ['name' => $fieldname, 'value' => $fieldvalue]; } } return $cfdata; } /** * Get the custom data for the given field * and render HTML ready for display in question table. * * @param object $fielddata The field data used for display. * @return string The HTML to display in the table column. */ public function display_custom_field_table(object $fielddata): string { global $PAGE; $output = $PAGE->get_renderer('qbank_customfields'); $outputdata = new field_data($fielddata); return $output->render_for_table($outputdata); } /** * Render the custom field category and filed data as HTML ready for display. * * @param array $catfielddata Array of categories and field names and values. * @return string The HTML to display. */ public function display_custom_categories_fields(array $catfielddata): string { global $PAGE; $output = $PAGE->get_renderer('qbank_customfields'); return $output->render_for_preview($catfielddata); } /** * Add custom controls to the field configuration form that will be saved. * * @param \MoodleQuickForm $mform The form to add the custom fields to. */ public function config_form_definition(\MoodleQuickForm $mform): void { $mform->addElement('header', 'question_handler_header', get_string('customfieldsettings', 'qbank_customfields')); $mform->setExpanded('question_handler_header', true); // If field is locked. $mform->addElement('selectyesno', 'configdata[locked]', get_string('customfield_islocked', 'qbank_customfields')); $mform->addHelpButton('configdata[locked]', 'customfield_islocked', 'qbank_customfields'); // Field data visibility. $visibilityoptions = [ self::VISIBLETOALL => get_string('customfield_visibletoall', 'qbank_customfields'), self::VISIBLETOTEACHERS => get_string('customfield_visibletoteachers', 'qbank_customfields'), self::NOTVISIBLE => get_string('customfield_notvisible', 'qbank_customfields') ]; $mform->addElement('select', 'configdata[visibility]', get_string('customfield_visibility', 'qbank_customfields'), $visibilityoptions); $mform->addHelpButton( 'configdata[visibility]', 'customfield_visibility', 'qbank_customfields'); } /** * Creates or updates the question custom field data when restoring from a backup. * * @param \restore_task $task * @param array $data * * @return int|void Conditionally returns the ID of the created or updated record. */ public function restore_instance_data_from_backup(\restore_task $task, array $data) { $editablefields = $this->get_editable_fields($data['newquestion']); $records = api::get_instance_fields_data($editablefields, $data['newquestion']); $target = $task->get_target(); $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING); foreach ($records as $d) { $field = $d->get_field(); if ($field->get('shortname') === $data['shortname'] && $field->get('type') === $data['type']) { if (!$d->get('id') || $override) { $d->set($d->datafield(), $data['value']); $d->set('value', $data['value']); $d->set('valueformat', $data['valueformat']); $d->set('valuetrust', !empty($data['valuetrust'])); $d->set('contextid', $data['fieldcontextid']); $d->save(); } return $d->get('id'); } } } } PK\DNN.bank/customfields/classes/privacy/provider.phpnu[. namespace qbank_customfields\privacy; /** * Privacy Subsystem for qbank_customfields implementing null_provider. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\0$  1bank/customfields/classes/custom_field_column.phpnu[. namespace qbank_customfields; use core_question\local\bank\column_base; use core_question\local\bank\view; use qbank_customfields\customfield\question_handler; /** * A column type for the name of the question creator. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class custom_field_column extends column_base { /** @var \core_customfield\field_controller The custom field this column is displaying. */ protected $field; /** * Constructor. * * @param view $qbank the question bank view we are helping to render. * @param \core_customfield\field_controller $field The custom field this column is displaying. */ public function __construct(\core_question\local\bank\view $qbank, \core_customfield\field_controller $field) { parent::__construct($qbank); $this->field = $field; } public static function from_column_name(view $view, string $columnname, bool $ingoremissing = false): ?custom_field_column { $handler = question_handler::create(); foreach ($handler->get_fields() as $field) { if ($field->get('shortname') == $columnname) { return new static($view, $field); } } if ($ingoremissing) { return null; } else { throw new \coding_exception('Custom field ' . $columnname . ' does not exist.'); } } /** * Get the internal name for this column. Used as a CSS class name, * and to store information about the current sort. Must match PARAM_ALPHA. * * @return string column name. */ public function get_name(): string { return 'customfield'; } /** * Get the name of this column. This must be unique. * When using the inherited class to make many columns from one parent, * ensure each instance returns a unique value. * * @return string The unique name; */ public function get_column_name(): string { return $this->field->get('shortname'); } /** * Title for this column. Not used if is_sortable returns an array. * * @return string */ public function get_title(): string { return $this->field->get_formatted_name(); } /** * Output the contents of this column. * * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ protected function display_content($question, $rowclasses): void { $fieldhandler = $this->field->get_handler(); if ($fieldhandler->can_view($this->field, $question->id)) { $fielddata = $fieldhandler->get_field_data($this->field, $question->id); echo $fieldhandler->display_custom_field_table($fielddata); } else { echo ''; } } public function get_extra_classes(): array { return ['pe-3']; } } PK\\P-bank/customfields/classes/output/renderer.phpnu[. namespace qbank_customfields\output; /** * Class renderer. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render custom field data for table display. * * @param object $fielddata The field data to display. * @return string The rendered HTML. */ public function render_for_table(object $fielddata): string { $context = $fielddata->export_for_template($this); return $this->render_from_template('qbank_customfields/table_display', $context); } /** * Render custom filed data for table display. * * @param array $catfielddata The category and field data. * @return string The rendered HTML. */ public function render_for_preview(array $catfielddata): string { $context = ['categories' => []]; foreach ($catfielddata as $key => $value) { $context['categories'][] = [ 'catname' => $key, 'fields' => $value ]; } return $this->render_from_template('qbank_customfields/preview_display', $context); } } PK\u =bank/customfields/classes/event/question_deleted_observer.phpnu[. namespace qbank_customfields\event; use core\event\question_deleted; use qbank_customfields\customfield\question_handler; /** * Event observer for question deletion * * @package qbank_customfields * @copyright 2023 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 question_deleted_observer { /** * Delete any custom field data for the deleted question. * * @param question_deleted $event * @return void */ public static function delete_question_customfields(question_deleted $event): void { question_handler::create()->delete_instance($event->objectid); } } PK\S>Ѻ,bank/customfields/classes/plugin_feature.phpnu[. namespace qbank_customfields; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; use qbank_customfields\customfield\question_handler; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Ghaly Marc-Alexandre * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { /** * This method will return the array of objects to be rendered as a prt of question bank columns/actions. * * @param view $qbank * @return array */ public function get_question_columns(view $qbank): array { // We make a column for each custom field and load the data into it. $columns = []; // First get all the available question custom fields. $customfieldhandler = question_handler::create(); $fields = $customfieldhandler->get_fields(); $context = $qbank->get_most_specific_context(); // Iterate through the fields initialising a column for each. // We don't need to know the values that questions have at this stage. foreach ($fields as $field) { if ($customfieldhandler->can_view_type($field, $context)) { $customfieldcolumn = new custom_field_column($qbank, $field); $columns[] = $customfieldcolumn; } } return $columns; } } PK\SW9~~4bank/customfields/templates/preview_display.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_customfields/preview_display Example context (json): {"categories":[ { "catname":"Other fields", "fields":[ {"name":"checkit", "value":"No"}, {"name":"datetime", "value":"1 October 2021"} ] } ]} }}

    {{#str}} customfield, qbank_customfields {{/str}}

    {{#categories}}

    {{catname}}

    {{#fields}}
    {{{name}}}: {{{value}}}
    {{/fields}} {{/categories}} PK\>-2bank/customfields/templates/table_display.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_customfields/table_display Example context (json): { "ftype" : "text", "shortname" : "nickname", "value" : "Star Lord" } }} {{#hasvalue}}
    {{{value}}}
    {{/hasvalue}} PK\-a1bank/customfields/tests/question_handler_test.phpnu[. namespace qbank_customfields; /** * Class qbank_customfields_question_handler_testcase * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_handler_test extends \advanced_testcase { /** * Question setup helper method. * * @return int The question id. * @throws coding_exception */ protected function setup_question(): int { $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $context = \context_module::instance($qbank->cmid); $questioncategory = $questiongenerator->create_question_category(['contextid' => $context->id]); $questiondata = ['category' => $questioncategory->id, 'idnumber' => 'q1']; $question = $questiongenerator->create_question('shortanswer', null, $questiondata); return $question->id; } /** * Test custom field data. */ public function test_get_field_data(): void { $this->resetAfterTest(); $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); $instanceid = $this->setup_question(); $fieldvalue = 'test field text'; $categorydata = new \stdClass(); $categorydata->component = 'qbank_customfields'; $categorydata->area = 'question'; $categorydata->name = 'test category'; $customfieldcatid = $fieldgenerator->create_category($categorydata)->get('id'); $field = $fieldgenerator->create_field(['categoryid' => $customfieldcatid, 'type' => 'text', 'shortname' => 'f1']); $fieldgenerator->add_instance_data($field, $instanceid, $fieldvalue); // Get the field data. $customfieldhandler = customfield\question_handler::create(); $fieldinstancedata = $customfieldhandler->get_field_data($field, $instanceid); $this->assertEquals($categorydata->name, $fieldinstancedata->get_field()->get_category()->get('name')); $this->assertEquals($fieldvalue, $fieldinstancedata->get_value()); } /** * Test getting custom field data for table display. */ public function test_display_custom_field_table(): void { $this->resetAfterTest(); $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); $instanceid = $this->setup_question(); $fieldvalue = 'test field text'; $categorydata = new \stdClass(); $categorydata->component = 'qbank_customfields'; $categorydata->area = 'question'; $categorydata->name = 'test category'; $customfieldcatid = $fieldgenerator->create_category($categorydata)->get('id'); $field = $fieldgenerator->create_field(['categoryid' => $customfieldcatid, 'type' => 'text', 'shortname' => 'f1']); $fieldgenerator->add_instance_data($field, $instanceid, $fieldvalue); // Get the field data. $customfieldhandler = customfield\question_handler::create(); $fieldinstancedata = $customfieldhandler->get_field_data($field, $instanceid); $output = $customfieldhandler->display_custom_field_table($fieldinstancedata); $this->assertStringContainsString($fieldvalue, $output); } /** * Test getting categories and field data for a specific instance. */ public function test_get_categories_fields_data(): void { $this->resetAfterTest(); $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); $instanceid = $this->setup_question(); $field1value = 'first field text'; $field2value = 'second field text'; $field3value = 'third field text'; $categorydata = new \stdClass(); $categorydata->component = 'qbank_customfields'; $categorydata->area = 'question'; $categorydata->name = 'test category'; $customfieldcat1id = $fieldgenerator->create_category($categorydata)->get('id'); $categorydata->name = 'test category two'; $customfieldcat2id = $fieldgenerator->create_category($categorydata)->get('id'); $field1 = $fieldgenerator->create_field(['categoryid' => $customfieldcat1id, 'type' => 'text', 'shortname' => 'f1']); $fieldgenerator->add_instance_data($field1, $instanceid, $field1value); $field2 = $fieldgenerator->create_field(['categoryid' => $customfieldcat1id, 'type' => 'text', 'shortname' => 'f2']); $fieldgenerator->add_instance_data($field2, $instanceid, $field2value); $field3 = $fieldgenerator->create_field(['categoryid' => $customfieldcat2id, 'type' => 'text', 'shortname' => 'f3']); $fieldgenerator->add_instance_data($field3, $instanceid, $field3value); // Get the field data. $customfieldhandler = customfield\question_handler::create(); $outputdata = $customfieldhandler->get_categories_fields_data($instanceid); $this->assertEquals($field1value, $outputdata['test category'][0]['value']); $this->assertEquals($field2value, $outputdata['test category'][1]['value']); $this->assertEquals($field3value, $outputdata['test category two'][0]['value']); // While we're here, lets test the rendering of this data. $outputhtml = $customfieldhandler->display_custom_categories_fields($outputdata); $this->assertStringContainsString($field1value, $outputhtml); $this->assertStringContainsString($field2value, $outputhtml); $this->assertStringContainsString($field3value, $outputhtml); } } PK\Xj j @bank/customfields/tests/event/question_deleted_observer_test.phpnu[. namespace qbank_customfields\event; /** * Tests for question_deleted_observer * * @package qbank_customfields * @copyright 2023 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 \qbank_customfields\event\question_deleted_observer */ final class question_deleted_observer_test extends \advanced_testcase { /** * Deleting a question with customfield data should also delete the data. * * @return void */ public function test_delete_question_with_customfields(): void { $this->resetAfterTest(); $generator = self::getDataGenerator(); $data = [ 'component' => 'qbank_customfields', 'area' => 'question' ]; $categoryid = $generator->create_custom_field_category($data)->get('id'); $generator->create_custom_field(['categoryid' => $categoryid, 'type' => 'text', 'shortname' => 'f1']); $questiongenerator = $generator->get_plugin_generator('core_question'); [, , , $questions] = $questiongenerator->setup_course_and_questions(); $question = reset($questions); $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); $questiondata = (object)[ 'id' => $question->id, 'customfield_f1' => random_string() ]; $customfieldhandler->instance_form_save($questiondata); $customdata = $customfieldhandler->get_instance_data($question->id); $this->assertCount(1, $customdata); $this->assertEquals($questiondata->customfield_f1, reset($customdata)->get_value()); question_delete_question($question->id); $customdata = $customfieldhandler->get_instance_data($question->id); $this->assertCount(1, $customdata); $this->assertEmpty(reset($customdata)->get_value()); } } PK\J 5 5,bank/customfields/tests/customfield_test.phpnu[. namespace qbank_customfields; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); /** * Class qbank_customfields_customfield_testcase * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class customfield_test extends \advanced_testcase { /** * @var array Data object for generating a question. */ protected $question1data; /** * @var array Data object for generating a question. */ protected $question2data; /** * @var component_generator_base Question Generator. */ protected $qgen; /** * @var core_course_category Course category. */ protected $category; /** * @var stdClass Course object. */ protected $course; /** * @var int Timestamp to use in tests. */ protected $testnow = 1632278491; /** * Helper to assist with setting up custom fields. * This is creating custom field category and the fields, not adding instance field data. */ protected function setup_custom_fields(): void { $dg = self::getDataGenerator(); $data = new \stdClass(); $data->component = 'qbank_customfields'; $data->area = 'question'; $catid = $dg->create_custom_field_category($data)->get('id'); $dg->create_custom_field(['categoryid' => $catid, 'type' => 'text', 'shortname' => 'f1']); $dg->create_custom_field(['categoryid' => $catid, 'type' => 'checkbox', 'shortname' => 'f2']); $dg->create_custom_field(['categoryid' => $catid, 'type' => 'date', 'shortname' => 'f3', 'configdata' => ['startyear' => 2000, 'endyear' => 3000, 'includetime' => 1]]); $dg->create_custom_field(['categoryid' => $catid, 'type' => 'select', 'shortname' => 'f4', 'configdata' => ['options' => "a\nb\nc"]]); $dg->create_custom_field(['categoryid' => $catid, 'type' => 'textarea', 'shortname' => 'f5']); } /** * Helper to assist with setting up questions used in tests. */ protected function setup_questions(): void { // Question initial set up. $this->category = $this->getDataGenerator()->create_category(); $this->course = $this->getDataGenerator()->create_course(['category' => $this->category->id]); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course->id]); $context = \context_module::instance($qbank->cmid); $this->qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcat = $this->qgen->create_question_category(['contextid' => $context->id]); $this->question1data = [ 'category' => $qcat->id, 'idnumber' => 'q1', 'customfield_f1' => 'some text', 'customfield_f2' => 1, 'customfield_f3' => $this->testnow, 'customfield_f4' => 2, 'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]; $this->question2data = [ 'category' => $qcat->id, 'idnumber' => 'q2', 'customfield_f1' => 'some more text', 'customfield_f2' => 0, 'customfield_f3' => $this->testnow, 'customfield_f4' => 1, 'customfield_f5_editor' => ['text' => 'test text', 'format' => FORMAT_HTML]]; } /** * Makes a backup of the course. * * @param \stdClass $course The course object. * @return string Unique identifier for this backup. */ protected function backup_course(\stdClass $course): string { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do backup with default settings. MODE_IMPORT means it will just // create the directory and not zip it. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); return $backupid; } /** * Restores a backup that has been made earlier. * * @param string $backupid The unique identifier of the backup. * @param string $fullname Full name of the new course that is going to be created. * @param string $shortname Short name of the new course that is going to be created. * @param int $categoryid The course category the backup is going to be restored in. * @return int The new course id. */ protected function restore_course(string $backupid, string $fullname, string $shortname, int $categoryid): int { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do restore to new course with default settings. $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $categoryid); $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); return $newcourseid; } /** * Test creating questions with custom fields. */ public function test_create_question(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->setup_custom_fields(); $this->setup_questions(); // Create 2 questions. $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); // Explicitly save the custom field data for the questions, like a form would. $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); $this->question1data['id'] = $question1->id; $this->question2data['id'] = $question2->id; $customfieldhandler->instance_form_save((object)$this->question1data); $customfieldhandler->instance_form_save((object)$this->question2data); // Get the custom field data associated with these question ids. $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); $this->assertEquals('some text', $q1cfdata->f1); $this->assertEquals('Yes', $q1cfdata->f2); $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $q1cfdata->f3); $this->assertEquals('b', $q1cfdata->f4); $this->assertEquals('test', $q1cfdata->f5); $this->assertEquals('some more text', $q2cfdata->f1); $this->assertEquals('No', $q2cfdata->f2); $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $q2cfdata->f3); $this->assertEquals('a', $q2cfdata->f4); $this->assertEquals('test text', $q2cfdata->f5); } /** * Test deleting questions with custom fields. */ public function test_delete_question(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->setup_custom_fields(); $this->setup_questions(); // Create 2 questions. $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); // Explicitly save the custom field data for the questions, like a form would. $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); $this->question1data['id'] = $question1->id; $this->question2data['id'] = $question2->id; $customfieldhandler->instance_form_save((object)$this->question1data); $customfieldhandler->instance_form_save((object)$this->question2data); // Get the custom field data associated with these question ids. $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); // Quick check that we have data for the custom fields. $this->assertEquals('some text', $q1cfdata->f1); $this->assertEquals('some more text', $q2cfdata->f1); // Delete the questions. question_delete_question($question1->id); question_delete_question($question2->id); // Check the custom field data for the questions has also gone. $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); $this->assertEmpty($q1cfdata->f1); $this->assertEmpty($q2cfdata->f1); } /** * Test custom fields attached to questions persist * across the backup and restore process. */ public function test_backup_restore(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $this->setup_custom_fields(); $this->setup_questions(); $courseshortname = $this->course->shortname; $coursefullname = $this->course->fullname; // Create 2 questions. $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); // Explicitly save the custom field data for the questions, like a form would. $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); $this->question1data['id'] = $question1->id; $this->question2data['id'] = $question2->id; $customfieldhandler->instance_form_save((object)$this->question1data); $customfieldhandler->instance_form_save((object)$this->question2data); // Create a quiz and the questions to that. $quiz = $this->getDataGenerator()->create_module( 'quiz', ['course' => $this->course->id, 'name' => 'restored_quiz']); quiz_add_quiz_question($question1->id, $quiz); quiz_add_quiz_question($question2->id, $quiz); // Backup the course. $backupid = $this->backup_course($this->course); // Now delete everything. delete_course($this->course, false); question_delete_question($question1->id); question_delete_question($question2->id); // Check the custom field data for the questions has also gone. $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); $this->assertEmpty($q1cfdata->f1); $this->assertEmpty($q2cfdata->f1); // Restore the backup we had made earlier into a new course. $newcategory = $this->getDataGenerator()->create_category(); $this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id); // The questions and their associated custom fields should have been restored. $sql = 'SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.idnumber = ?'; $newquestion1 = $DB->get_record_sql($sql, ['q1']); $newquestion1cfdata = $customfieldhandler->export_instance_data_object($newquestion1->id); $this->assertEquals('some text', $newquestion1cfdata->f1); $this->assertEquals('Yes', $newquestion1cfdata->f2); $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $newquestion1cfdata->f3); $this->assertEquals('b', $newquestion1cfdata->f4); $this->assertEquals('test', $newquestion1cfdata->f5); $newquestion2 = $DB->get_record_sql($sql, ['q2']); $newquestion2cfdata = $customfieldhandler->export_instance_data_object($newquestion2->id); $this->assertEquals('some more text', $newquestion2cfdata->f1); $this->assertEquals('No', $newquestion2cfdata->f2); $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $newquestion2cfdata->f3); $this->assertEquals('a', $newquestion2cfdata->f4); $this->assertEquals('test text', $newquestion2cfdata->f5); } } PK\ ڿ :bank/customfields/tests/behat/customfield_question.featurenu[@core @core_question @core_customfield @qbank_customfields @javascript Feature: A teacher can edit question with custom fields In order to improve my questions As a teacher I need to be able to edit questions and add extra metadata via custom fields Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | | Field 1 | Category for test | text | f1 | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | 00001 | | name | Test quiz name | | intro | Test quiz description | | section | 1 | | grade | 10 | And I log in as "teacher1" And I am on "Course 1" course homepage with editing mode on And I add a "True/False" question to the "Test quiz name" quiz with: | Question name | First question | | Question text | Answer the first question | | General feedback | Thank you, this is the general feedback | | Correct answer | False | | Feedback for the response 'True'. | So you think it is true | | Feedback for the response 'False'. | So you think it is false | And I am on the "Test quiz name" "mod_quiz > question bank" page Scenario: Edit a previously created question and see the custom field in the overview table and in the question preview. When I am on the "First question" "core_question > edit" page Then I should see "Category for test" And I expand all fieldsets And I should see "Field 1" And I set the following fields to these values: | Question name | First question edited | | Field 1 | custom field text | And I press "id_submitbutton" And I should see "First question" And I should see "custom field text" And I am on the "First question edited" "core_question > preview" page And I should see "Field 1" And I should see "custom field text" Scenario: Preview a previously created question with custom fields set with empty values When I am on the "First question" "core_question > preview" page Then I should see "Field 1" And I should not see "custom field text" PK\L?__@bank/customfields/tests/behat/customfield_question_setup.featurenu[@core @core_question @core_customfield @qbank_customfields @javascript Feature: Site administrators can manage categories for question custom fields In order to have additional data in questions As a site site administrator I need to create, edit, remove and sort question custom field categories Scenario: Create a category for custom question fields Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I press "Add a new category" And I wait until the page is ready Then I should see "Other fields" in the "#customfield_catlist" "css_element" Scenario: Edit a category name for custom question fields Given the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I set the field "Edit category name" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element" to "Good fields" Then I should not see "Category for test" in the "#customfield_catlist" "css_element" And "New value for Category for test" "field" should not exist And I should see "Good fields" in the "#customfield_catlist" "css_element" Scenario: Delete a category for custom question fields Given the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | | Field 1 | Category for test | text | f1 | And I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And I click on "[data-role='deletecategory']" "css_element" And I click on "Yes" "button" in the "Confirm" "dialogue" And I wait until the page is ready And I wait until "Test category" "text" does not exist Then I should not see "Test category" in the "#customfield_catlist" "css_element" Scenario: Move field in the question custom fields to another category Given the following "custom field categories" exist: | name | component | area | itemid | | Category1 | qbank_customfields | question | 0 | | Category2 | qbank_customfields | question | 0 | | Category3 | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | | Field1 | Category1 | text | f1 | | Field2 | Category2 | text | f2 | When I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration Then "Field1" "text" should appear after "Category1" "text" And "Category2" "text" should appear after "Field1" "text" And "Field2" "text" should appear after "Category2" "text" And "Category3" "text" should appear after "Field2" "text" And I press "Move \"Field1\"" And I follow "To the top of category Category2" And "Category2" "text" should appear after "Category1" "text" And "Field1" "text" should appear after "Category2" "text" And "Field2" "text" should appear after "Field1" "text" And "Category3" "text" should appear after "Field2" "text" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And "Category2" "text" should appear after "Category1" "text" And "Field1" "text" should appear after "Category2" "text" And "Field2" "text" should appear after "Field1" "text" And "Category3" "text" should appear after "Field2" "text" And I press "Move \"Field1\"" And I follow "After field Field2" And "Field1" "text" should appear after "Field2" "text" Scenario: Reorder question custom field categories Given the following "custom field categories" exist: | name | component | area | itemid | | Category1 | qbank_customfields | question | 0 | | Category2 | qbank_customfields | question | 0 | | Category3 | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | | Field1 | Category1 | text | f1 | When I log in as "admin" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration Then "Field1" "text" should appear after "Category1" "text" And "Category2" "text" should appear after "Field1" "text" And "Category3" "text" should appear after "Category2" "text" And I press "Move \"Category2\"" And I follow "After \"Category3\"" And "Field1" "text" should appear after "Category1" "text" And "Category3" "text" should appear after "Field1" "text" And "Category2" "text" should appear after "Category3" "text" And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration And "Field1" "text" should appear after "Category1" "text" And "Category3" "text" should appear after "Field1" "text" And "Category2" "text" should appear after "Category3" "text" And I press "Move \"Category2\"" And I follow "After \"Category1\"" And "Field1" "text" should appear after "Category1" "text" And "Category2" "text" should appear after "Field1" "text" And "Category3" "text" should appear after "Category2" "text" PK\H Dbank/customfields/tests/behat/customfield_question_visbility.featurenu[@core @core_quiz @core_customfield @qbank_customfields @javascript Feature: The visibility of question custom fields control where they are displayed In order to display custom fields in a quiz Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Terry1 | Teacher1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "custom field categories" exist: | name | component | area | itemid | | Category for test | qbank_customfields | question | 0 | And the following "custom fields" exist: | name | category | type | shortname | configdata | | Field 1 | Category for test | text | f1 | {"visibility":"2"} | | Field 2 | Category for test | text | f2 | {"visibility":"2"} | | Field 3 | Category for test | text | f3 | {"visibility":"2","defaultvalue":"secret"} | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | 00001 | | name | Test quiz name | | intro | Test quiz description | | section | 1 | | grade | 10 | When I log in as "teacher1" And I am on "Course 1" course homepage with editing mode on And I add a "True/False" question to the "Test quiz name" quiz with: | Question name | First question | | Question text | Answer the first question | | General feedback | Thank you, this is the general feedback | | Correct answer | False | | Feedback for the response 'True'. | So you think it is true | | Feedback for the response 'False'. | So you think it is false | @javascript Scenario: Display custom question fields to teachers based on their visibility. When I am on the "First question" "core_question > edit" page Then I should see "Category for test" And I expand all fieldsets And I should see "Field 1" And I should see "Field 2" And I should see "Field 3" And I set the following fields to these values: | Field 1 | custom field text one| | Field 2 | custom field text two| And I press "id_submitbutton" And I should see "Field 1" And I should see "custom field text one" And I should see "Field 2" And I should see "custom field text two" And I should see "Field 3" And I should see "secret" And I choose "Preview" action for "First question" in the question bank And I should see "Field 1" And I should see "custom field text one" And I should see "Field 2" And I should see "custom field text two" And I should see "Field 3" And I should see "secret" PK\~}+bank/customfields/settings.phpnu[. /** * Plugin administration pages are defined here. * * @package qbank_customfields * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = ['qbank/customfields:configurecustomfields']; if ($hassiteconfig || has_any_capability($capabilities, core\context\system::instance())) { // Settings for question custom fields. $settings = null; $ADMIN->add('qbanksettings', new admin_externalpage('qbank_customfields', new lang_string('pluginname', 'qbank_customfields'), $CFG->wwwroot . '/question/bank/customfields/customfield.php', ['qbank/customfields:configurecustomfields'] ) ); } PK\W3'&bank/history/lang/en/qbank_history.phpnu[. /** * Strings for component qbank_history, language 'en'. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['allquestionversionsdeleted'] = 'All versions of this question have been deleted.'; $string['close_history'] = 'Close'; $string['history_action'] = 'History'; $string['history_header'] = 'Question history'; $string['pluginname'] = 'Question history'; $string['privacy:metadata'] = 'The Question history question bank plugin does not store any personal data.'; $string['questionversionnumber'] = 'Version'; $string['questionversiondata'] = 'v{$a}'; PK\mmbank/history/version.phpnu[. /** * Version information for qbank_history. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_history'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\x$^D)bank/history/classes/privacy/provider.phpnu[. namespace qbank_history\privacy; /** * Privacy Subsystem for qbank_history implementing null_provider. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\)YY.bank/history/classes/version_number_column.phpnu[. namespace qbank_history; use core_question\local\bank\column_base; /** * Question bank column for the question version number. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class version_number_column extends column_base { public function get_name(): string { return 'questionversionnumber'; } public function get_title(): string { return get_string('questionversionnumber', 'qbank_history'); } protected function display_content($question, $rowclasses): void { print_string('questionversiondata', 'qbank_history', $question->version); } public function get_extra_classes(): array { return ['pe-3']; } #[\Override] public function is_sortable() { return 'qv.version'; } } PK\j3 bank/history/classes/helper.phpnu[. namespace qbank_history; /** * Helper class for question history. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get the question history url. * * @param int $entryid id of the question entry * @param string $returnrul url of the page to return to * @param int $courseid id of the course * @param ?string $filter filter param to pass to the History view * @return \moodle_url * @deprecated since Moodle 5.0. * @todo MDL-82413 Final deprecation in Moodle 6.0. */ #[\core\attribute\deprecated(replacement: 'qbank_history\helper::get_question_history_url', since: '5.0', mdl: 'MDL-71378')] public static function question_history_url(int $entryid, string $returnrul, int $courseid, ?string $filter): \moodle_url { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); $params = [ 'entryid' => $entryid, 'returnurl' => $returnrul, 'courseid' => $courseid ]; if (!is_null($filter)) { $params['filter'] = $filter; } return new \moodle_url('/question/bank/history/history.php', $params); } /** * Get the question history url. * * @param int $entryid id of the question entry * @param string $returnrul url of the page to return to * @param int $cmid id of the coursemodule holding the question bank. * @param ?string $filter filter param to pass to the History view * @return \moodle_url */ public static function get_question_history_url(int $entryid, string $returnrul, int $cmid, ?string $filter) { $params = [ 'entryid' => $entryid, 'returnurl' => $returnrul, 'cmid' => $cmid, ]; if (!is_null($filter)) { $params['filter'] = $filter; } return new \moodle_url('/question/bank/history/history.php', $params); } } PK\]NN(bank/history/classes/output/renderer.phpnu[. namespace qbank_history\output; /** * Class renderer for rendering question history. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render the history header. * * @param array $historydata data to be passed in the mustache * @return string */ public function render_history_header(array $historydata): string { return $this->render_from_template('qbank_history/history_header', $historydata); } } PK\ͩ\ 'bank/history/classes/history_action.phpnu[. namespace qbank_history; use core_question\local\bank\filter_condition_manager; use core_question\local\bank\question_action_base; /** * Question bank column for the history action icon. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class history_action extends question_action_base { // Store this lang string for performance. protected $strpreview; public function init(): void { parent::init(); $this->strpreview = get_string('history_action', 'qbank_history'); } public function get_menu_position(): int { return 500; } protected function get_url_icon_and_label(\stdClass $question): array { if (!\question_bank::is_qtype_installed($question->qtype)) { // It sometimes happens that people end up with junk questions // in their question bank of a type that is no longer installed. // We cannot do most actions on them, because that leads to errors. return [null, null, null]; } if (question_has_capability_on($question, 'use')) { $currentfilter = $this->qbank->base_url()->param('filter'); if ($currentfilter) { $currentfilter = filter_condition_manager::update_filter_param_to_category( $currentfilter, $question->categoryid); } else if (empty($question->isdummy)) { $currentfilter = json_encode(filter_condition_manager::get_default_filter( $question->categoryid . ',' . $question->contextid)); } $url = helper::get_question_history_url( $question->questionbankentryid, $this->qbank->returnurl, $this->qbank->cm->id, $currentfilter, ); return [$url, 't/log', $this->strpreview]; } return [null, null, null]; } } PK\w z#.bank/history/classes/question_history_view.phpnu[. namespace qbank_history; use core_question\local\bank\question_edit_contexts; use core_question\local\bank\view; use moodle_url; use stdClass; /** * Custom view class for the history page. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_history_view extends view { /** * Entry id to get the versions * * @var int $entryid */ protected $entryid; /** * Base url for the return. * * @var \moodle_url $basereturnurl */ protected $basereturnurl; /** * Constructor for the history. * @param question_edit_contexts $contexts the contexts of api call * @param moodle_url $pageurl url of the page * @param stdClass $course course settings * @param stdClass|null $cm (optional) activity settings. * @param array $params the parameters required to initialize the api. * @param array $extraparams any extra parameters need to initialized if the api is extended, it will be passed to js. * @throws \moodle_exception */ public function __construct( question_edit_contexts $contexts, moodle_url $pageurl, stdClass $course, ?stdClass $cm = null, array $params = [], array $extraparams = [], ) { if ($cm === null) { debugging('$cm is now a required field', DEBUG_DEVELOPER); } $this->entryid = $extraparams['entryid']; $this->basereturnurl = new \moodle_url($extraparams['returnurl']); parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams); } protected function init_question_actions(): void { parent::init_question_actions(); unset($this->questionactions['qbank_history\history_action']); } protected function wanted_columns(): array { $this->requiredcolumns = []; $questionbankcolumns = $this->get_question_bank_plugins(); foreach ($questionbankcolumns as $classobject) { if (empty($classobject)) { continue; } $this->requiredcolumns[$classobject->get_column_name()] = $classobject; } return $this->requiredcolumns; } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('filtering objects', since: '4.3', mdl: 'MDL-72321', final: true)] protected function display_advanced_search_form($advancedsearch): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } public function allow_add_questions(): bool { // As we dont want to create questions in this page. return false; } #[\Override] protected function default_sort(): array { return ['qbank_history__version_number_column' => SORT_ASC]; } protected function build_query(): void { // Get the required tables and fields. [$fields, $joins] = $this->get_component_requirements(array_merge($this->requiredcolumns, $this->questionactions)); // Build the order by clause. $sorts = []; foreach ($this->sort as $sortname => $sortorder) { list($colname, $subsort) = $this->parse_subsort($sortname); $sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort); } // Build the where clause. $entryid = "qbe.id = $this->entryid"; // Changes done here to get the questions only for the passed entryid. $tests = ['q.parent = 0', $entryid]; $this->sqlparams = []; foreach ($this->searchconditions as $searchcondition) { if ($searchcondition->where()) { $tests[] = '((' . $searchcondition->where() .'))'; } if ($searchcondition->params()) { $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params()); } } // Build the SQL. $sql = ' FROM {question} q ' . implode(' ', $joins); $sql .= ' WHERE ' . implode(' AND ', $tests); $this->countsql = 'SELECT count(1)' . $sql; $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts); } /** * Display the header for the question bank in the history page to include question name and type. */ public function display_question_bank_header(): void { global $PAGE, $DB, $OUTPUT; $sql = 'SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id) AND qbe.id = ?'; $latestquestiondata = $DB->get_record_sql($sql, [$this->entryid]); if ($latestquestiondata) { $historydata = [ 'questionname' => $latestquestiondata->name, 'returnurl' => $this->basereturnurl, 'questionicon' => print_question_icon($latestquestiondata) ]; // Header for the page before the actual form from the api. echo $PAGE->get_renderer('qbank_history')->render_history_header($historydata); } else { // Continue when all the question versions are deleted. echo $OUTPUT->notification(get_string('allquestionversionsdeleted', 'qbank_history'), 'notifysuccess'); echo $OUTPUT->continue_button($this->basereturnurl); } } public function is_listing_specific_versions(): bool { return true; } /** * Override wanted_filters so that we apply the filters provided by the URL, but don't display the filter UI. * * @return void */ public function wanted_filters(): void { $this->display_question_bank_header(); // Add search conditions. $this->add_standard_search_conditions(); } } PK\,Lnn'bank/history/classes/plugin_feature.phpnu[. namespace qbank_history; use core_question\local\bank\view; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { public function get_question_columns($qbank): array { return [ new version_number_column($qbank), ]; } public function get_question_actions(view $qbank): array { return [ new history_action($qbank), ]; } } PK\::.bank/history/templates/history_header.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_history/history_header The header of the history page. * returnurl - The url of the page to return to, usually the base qbank page * questionincon - The icon of the question type * questionname - The name of the latest question version Example context (json): { "returnurl": "https://url/courseid=1", "questionicon": "", "questionname": "Question 1" } }}

    {{{questionicon}}} {{questionname}}

    PK\c"bank/history/tests/helper_test.phpnu[. namespace qbank_history; use question_bank; /** * Helper class test. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_history\helper */ final class helper_test extends \advanced_testcase { /** * @var bool|\context|\context_course $context */ public $context; /** * @var object $questiondata; */ public $questiondata; /** * @var \moodle_url $returnurl */ public $returnurl; /** * @var int $courseid */ public $courseid; /** * Test set up. * * This is executed before running any test in this file. */ public function setUp(): void { parent::setUp(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course. $course = $generator->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $this->courseid = $course->id; $this->context = \context_module::instance($qbank->cmid); // Create a question in the default category. $contexts = new \core_question\local\bank\question_edit_contexts($this->context); $cat = question_get_default_category($contexts->lowest()->id, true); $question = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); $this->questiondata = question_bank::load_question($question->id); $this->returnurl = new \moodle_url('/question/edit.php'); } /** * Test the history action url from the helper class. * * @covers ::question_history_url */ public function test_question_history_url(): void { $this->resetAfterTest(); $filter = urlencode('filters[]'); $actionurl = helper::get_question_history_url( $this->questiondata->questionbankentryid, $this->returnurl, $this->context->instanceid, $filter, ); $params = [ 'entryid' => $this->questiondata->questionbankentryid, 'returnurl' => $this->returnurl, 'cmid' => $this->context->instanceid, 'filter' => $filter, ]; $expectedurl = new \moodle_url('/question/bank/history/history.php', $params); $this->assertEquals($expectedurl, $actionurl); } /** * Test the history action url when the filter parameter is null. * * @covers ::question_history_url */ public function test_question_history_url_null_filter(): void { $this->resetAfterTest(); $actionurl = helper::get_question_history_url( $this->questiondata->questionbankentryid, $this->returnurl, $this->context->instanceid, null, ); $params = [ 'entryid' => $this->questiondata->questionbankentryid, 'returnurl' => $this->returnurl, 'cmid' => $this->context->instanceid, ]; $expectedurl = new \moodle_url('/question/bank/history/history.php', $params); $this->assertEquals($expectedurl, $actionurl); } } PK\}.V[[1bank/history/tests/question_history_view_test.phpnu[. namespace qbank_history; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); /** * Custom history view - qbank api test. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_history\question_history_view */ final class question_history_view_test extends \advanced_testcase { /** * Test that the history page shows all the versions of a question. * * @covers ::display */ public function test_question_history_shows_all_versions(): void { $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a course. $course = $generator->create_course(); $qbank = $generator->create_module('qbank', ['course' => $course->id]); $cm = get_coursemodule_from_id('qbank', $qbank->cmid); $context = \context_module::instance($qbank->cmid); // Create a question in the default category. $contexts = new \core_question\local\bank\question_edit_contexts($context); $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $questiondata1 = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); // Create a new version. $questiondata2 = $questiongenerator->update_question($questiondata1, null, ['name' => 'Example question second version']); $entry = get_question_bank_entry($questiondata1->id); $pagevars = [ 'qpage' => 0, 'qperpage' => DEFAULT_QUESTIONS_PER_PAGE, 'cat' => $cat->id . ',' . $cat->contextid, 'tabname' => 'questions' ]; // Generate the view. $viewclass = \qbank_history\question_history_view::class; $extraparams = [ 'view' => $viewclass, 'entryid' => $entry->id, 'returnurl' => "/", ]; $view = new $viewclass($contexts, new \moodle_url('/'), $course, $cm, $pagevars, $extraparams); ob_start(); $view->display(); $html = ob_get_clean(); // Verify the output includes the first version. $this->assertStringContainsString($questiondata1->name, $html); // Verify the output includes the second version. $this->assertStringContainsString($questiondata2->name, $html); } /** * Test that the question bank header in the history page shows the latest question. * * @covers ::display_question_bank_header */ public function test_display_question_bank_header(): void { $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a course. $course = $generator->create_course(); $qbank = $generator->create_module('qbank', ['course' => $course->id]); $cm = get_coursemodule_from_id('qbank', $qbank->cmid); $context = \context_module::instance($cm->id); // Create a question in the default category. $contexts = new \core_question\local\bank\question_edit_contexts($context); $cat = $questiongenerator->create_question_category(); $questiondata1 = $questiongenerator->create_question('numerical', null, ['name' => 'First version', 'category' => $cat->id]); $entry = get_question_bank_entry($questiondata1->id); $pagevars = [ 'qpage' => 0, 'qperpage' => DEFAULT_QUESTIONS_PER_PAGE, 'cat' => $cat->id . ',' . $cat->contextid, 'tabname' => 'questions' ]; // Generate the view. $viewclass = \qbank_history\question_history_view::class; $extraparams = [ 'view' => $viewclass, 'entryid' => $entry->id, 'returnurl' => "/", ]; $view = new $viewclass($contexts, new \moodle_url('/'), $course, $cm, $pagevars, $extraparams); ob_start(); $view->display_question_bank_header(); $headerhtml = ob_get_clean(); // Verify the output includes the latest version. $this->assertStringContainsString($questiondata1->name, $headerhtml); $questiondata2 = $questiongenerator->update_question($questiondata1, null, ['name' => 'Second version']); $view = new $viewclass($contexts, new \moodle_url('/'), $course, $cm, $pagevars, $extraparams); ob_start(); $view->display_question_bank_header(); $headerhtml = ob_get_clean(); $this->assertStringContainsString($questiondata2->name, $headerhtml); } } PK\wR % % 8bank/history/tests/behat/question_version_column.featurenu[@qbank @qbank_history @javascript Feature: Use the qbank plugin manager page for version column In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | | Test questions | truefalse | First Question | And the following "core_question > updated questions" exist: | questioncategory | question | name | | Test questions | First Question | First Edit | | Test questions | First Question | Edit the Second | | Test questions | First Question | The Final Edit | Scenario: Enable/disable version column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question history" When I click on "Disable" "link" in the "Question history" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" Then I should not see "Version" in the "region-main" "region" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question history" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I should see "Version" in the "region-main" "region" Scenario: Sort the question versions Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" When I choose "History" action for "The Final Edit" in the question bank Then "Edit the Second" "checkbox" should appear before "The Final Edit" "checkbox" And "First Edit" "checkbox" should appear before "Edit the Second" "checkbox" And "First Question" "checkbox" should appear before "First Edit" "checkbox" And I follow "Sort by Version descending" And "The Final Edit" "checkbox" should appear before "Edit the Second" "checkbox" And "Edit the Second" "checkbox" should appear before "First Edit" "checkbox" And "First Edit" "checkbox" should appear before "First Question" "checkbox" And I follow "Sort by Version ascending" And "Edit the Second" "checkbox" should appear before "The Final Edit" "checkbox" And "First Edit" "checkbox" should appear before "Edit the Second" "checkbox" And "First Question" "checkbox" should appear before "First Edit" "checkbox" PK\ۨ8bank/history/tests/behat/question_history_action.featurenu[@qbank @qbank_history Feature: Use the qbank plugin manager page for question history In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Test quiz | | C1 | quiz1 | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | | qbank | Qbank 2 | Question bank 2 | C1 | qbank2 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | | Activity module | qbank1 | Share questions | | Activity module | qbank2 | Share questions 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | | Default for Qbank 1 | essay | Test question to be edited | Write about whatever you want | Scenario: Enable/disable question history column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question history" And I click on "Disable" "link" in the "Question history" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then the "History" action should not exist for the "First question" question in the question bank And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question history" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then the "History" action should exist for the "First question" question in the question bank Scenario: History page shows only the specified features and questions Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I choose "History" action for "First question" in the question bank And I should see "Question" And I should see "Actions" And I should see "Status" And I should see "Version" And I should see "Created by" And I should see "First question" And the "History" action should not exist for the "First question" question in the question bank @javascript Scenario: Viewing history for a question in a non-default category Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions 2 | truefalse | Second question | Answer the second question | And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions 2" And I choose "History" action for "Second question" in the question bank Then I should see "Question history" And "Filter 1" "fieldset" should not exist And I should see "Second question" And "Second question" "table_row" should exist Scenario: Viewing history for a Question in a Subcategory Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Questions Category 1 | And the following "question categories" exist: | contextlevel | reference | name | questioncategory | | Activity module | quiz1 | Subcategory 1 | Questions Category 1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Subcategory 1 | truefalse | First question (v1) | Question version 1 | When I am on the "Test quiz" "mod_quiz > question categories" page logged in as "admin" And I should see "Subcategory 1" And I click on "Subcategory 1" "link" Then I should see "First question (v1)" And I choose "Edit question" action for "First question (v1)" in the question bank And I set the following fields to these values: | Question name | First question (v2) | | Question text | Question version 2 | And I press "id_submitbutton" And I choose "History" action for "First question (v2)" in the question bank And "First question (v1)" "table_row" should exist And "First question (v2)" "table_row" should exist @javascript Scenario: Delete question from the history using Edit question menu Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And I choose "History" action for "First question" in the question bank When I choose "Delete" action for "First question" in the question bank And I press "Delete" And I should not see "First question" Then I should see "All versions of this question have been deleted." And I click on "Continue" "button" And I should see "Question bank" And I should not see "First question" Scenario: Resetting the columns in the question history view will return it to its default setting. Given the following "user preferences" exist: | user | preference | value | | admin | qbank_columnsortorder_hiddencols | qbank_usage\question_last_used_column-question_last_used_column | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | Second question | Answer the second question | When I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" And "Last used" "qbank_columnsortorder > column header" should not exist Then I should see "First question" And I should see "Second question" And I choose "History" action for "First question" in the question bank And "First question" "table_row" should exist And "Second question" "table_row" should not exist And "Last used" "qbank_columnsortorder > column header" should not exist And I follow "Reset columns" And "Last used" "qbank_columnsortorder > column header" should exist And "First question" "table_row" should exist And "Second question" "table_row" should not exist Scenario: Go History page in edit question page. Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin" When I choose "Edit question" action for "First question" in the question bank And I click on "History" "link" Then I should see "First question" And I follow "Close" And the following fields match these values: | Question text | Answer the first question | PK\_ҷ bank/history/history.phpnu[. /** * Question history preview. * * @package qbank_history * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_login(); core_question\local\bank\helper::require_plugin_enabled('qbank_history'); $entryid = required_param('entryid', PARAM_INT); $returnurl = required_param('returnurl', PARAM_LOCALURL); list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) = question_edit_setup('questions', '/question/bank/history/history.php'); $pagevars['entryid'] = $entryid; $pagevars['returnurl'] = $returnurl; $url = new moodle_url( $thispageurl, [ 'entryid' => $entryid, 'returnurl' => $returnurl, 'view' => '\qbank_history\question_history_view', 'extraparams' => json_encode(['entryid' => $entryid, 'returnurl' => $returnurl]), ], ); $PAGE->set_url($url); // Additional param to differentiate with other question bank view. $extraparams['entryid'] = $entryid; $extraparams['returnurl'] = $returnurl; $questionbank = new \qbank_history\question_history_view($contexts, $url, $COURSE, $cm, $pagevars, $extraparams); $streditingquestions = get_string('history_header', 'qbank_history'); $PAGE->set_title($streditingquestions); $PAGE->set_heading($streditingquestions); $context = $contexts->lowest(); $PAGE->set_context($context); $PAGE->navbar->add(get_string('question'), new moodle_url($returnurl)); $PAGE->navbar->add($streditingquestions, $url); echo $OUTPUT->header(); // Print the question area. $questionbank->display(); $questionbank->init_bulk_actions_js(); echo $OUTPUT->footer(); PK\Hjvvbank/UPGRADING.mdnu[# qbank (plugin type) Upgrade notes ## 5.0 ### Removed - Final deprecation of: - qbank_managecategories\output\renderer::class - qbank_statistics\helper::calculate_average_question_discriminative_efficiency() - qbank_statistics\helper::calculate_average_question_discrimination_index() - qbank_statistics\helper::get_all_places_where_questions_were_attempted() - qbank_statistics\helper::calculate_average_question_stats_item() - qbank_statistics\helper::calculate_average_question_facility() - qbank_statistics\helper::load_statistics_for_place() - qbank_statistics\helper::extract_item_value() - template qbank_managecategories/category_condition_advanced - template qbank_managecategories/category_condition - template qbank_managecategories/listitem For more information see [MDL-78090](https://tracker.moodle.org/browse/MDL-78090) PK\X``.bank/exporttoxml/lang/en/qbank_exporttoxml.phpnu[. /** * Strings for component qbank_exporttoxml, language 'en' * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Export to XML'; $string['privacy:metadata'] = 'The Export to xml question bank plugin does not store any personal data.'; PK\``bank/exporttoxml/lib.phpnu[. /** * Helper functions and callbacks. * * @package qbank_exporttoxml * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\plugininfo\qbank; use qbank_exporttoxml\helper; /** * Callback to add content to the question preview screen. * * @param question_definition $question the question being previewed. * @param int $courseid the course id. * @return string HTML to add to the question preview screen. */ function qbank_exporttoxml_preview_display(question_definition $question, int $courseid): string { if (!qbank::is_plugin_enabled('qbank_exporttoxml')) { return ''; } if (!question_has_capability_on($question, 'view')) { return ''; } $exporturl = helper::question_get_export_single_question_url($question); return html_writer::div(html_writer::link($exporturl, get_string('exportonequestion', 'question'))); } PK\驭=yybank/exporttoxml/version.phpnu[. /** * Version information for qbank_exporttoxml. * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_exporttoxml'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\HtE-bank/exporttoxml/classes/privacy/provider.phpnu[. namespace qbank_exporttoxml\privacy; /** * Privacy Subsystem for qbank_exporttoxml implementing null_provider. * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\#bank/exporttoxml/classes/helper.phpnu[. namespace qbank_exporttoxml; /** * Class helper for export plugin. * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get the URL to export a single question (exportone.php). * * @param \stdClass|\question_definition $question the question definition as obtained from * question_bank::load_question_data() or question_bank::make_question(). * (Only ->id and ->contextid are used.) * @return \moodle_url the requested URL. */ public static function question_get_export_single_question_url($question): \moodle_url { $context = \context::instance_by_id($question->contextid); return new \moodle_url('/question/bank/exporttoxml/exportone.php', ['id' => $question->id, 'sesskey' => sesskey(), 'cmid' => $context->instanceid] ); } } PK\! |77.bank/exporttoxml/classes/export_xml_action.phpnu[. namespace qbank_exporttoxml; use core_question\local\bank\question_action_base; /** * Question bank column export the question in Moodle XML format. * * @package qbank_exporttoxml * @copyright 2019 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class export_xml_action extends question_action_base { /** @var string avoids repeated calls to get_string('duplicate'). */ protected $strexportasxml; public function init(): void { parent::init(); $this->strexportasxml = get_string('exportasxml', 'question'); } public function get_menu_position(): int { return 600; } protected function get_url_icon_and_label(\stdClass $question): array { if (!\question_bank::is_qtype_installed($question->qtype)) { // It sometimes happens that people end up with junk questions // in their question bank of a type that is no longer installed. // We cannot do most actions on them, because that leads to errors. return [null, null, null]; } if (!question_has_capability_on($question, 'view')) { return [null, null, null]; } return [helper::question_get_export_single_question_url($question), 't/download', $this->strexportasxml]; } } PK\8A+bank/exporttoxml/classes/plugin_feature.phpnu[. namespace qbank_exporttoxml; use core_question\local\bank\plugin_features_base; /** * Class plugin_feature is the entrypoint for the columns. * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { public function get_question_actions($qbank): array { return [ new export_xml_action($qbank) ]; } } PK\TH &bank/exporttoxml/tests/helper_test.phpnu[. namespace qbank_exporttoxml; use context_course; use context_module; use moodle_url; use question_bank; /** * Class helper unit tests. * * @package qbank_exporttoxml * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_exporttoxml\helper */ final class helper_test extends \advanced_testcase { /** * Test the export single question url. * * @covers ::question_get_export_single_question_url */ public function test_question_get_export_single_question_url(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); // Create a course and an activity. $course = $generator->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbankcontext = \context_module::instance($qbank->cmid); $quiz = $generator->create_module('quiz', ['course' => $course->id]); // Create a question in each place. $questiongenerator = $generator->get_plugin_generator('core_question'); $qbankqcat = $questiongenerator->create_question_category(['contextid' => $qbankcontext->id]); $qbankq = $questiongenerator->create_question('truefalse', null, ['category' => $qbankqcat->id]); $quizqcat = $questiongenerator->create_question_category(['contextid' => context_module::instance($quiz->cmid)->id]); $quizq = $questiongenerator->create_question('truefalse', null, ['category' => $quizqcat->id]); // Verify some URLs. $this->assertEquals(new moodle_url('/question/bank/exporttoxml/exportone.php', ['id' => $qbankq->id, 'cmid' => $qbank->cmid, 'sesskey' => sesskey()]), helper::question_get_export_single_question_url( question_bank::load_question_data($qbankq->id))); $this->assertEquals(new moodle_url('/question/bank/exporttoxml/exportone.php', ['id' => $quizq->id, 'cmid' => $quiz->cmid, 'sesskey' => sesskey()]), helper::question_get_export_single_question_url( question_bank::load_question($quizq->id))); } } PK\چtHKK5bank/exporttoxml/tests/behat/preview_question.featurenu[@qbank @qbank_previewquestion Feature: A teacher can export a question as XML from the preview question screen To help reuse questions As a teacher I can easily export the question I am previewing Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | | Test questions | numerical | Test question to be previewed | Scenario: Question preview shows the question and other information When I am on the "Test question to be previewed" "core_question > preview" page logged in as teacher Then the state of "What is pi to two d.p.?" question is shown as "Not yet answered" And "Download this question in Moodle XML format" "link" should exist PK\屠9bank/exporttoxml/tests/behat/export_to_xml_action.featurenu[@qbank @qbank_exporttoxml Feature: Use the qbank plugin manager page for exporttoxml In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | Scenario: Enable/disable exporttoxml column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Export to XML" And I click on "Disable" "link" in the "Export to XML" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then the "Export as Moodle XML" action should not exist for the "First question" question in the question bank And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Export to XML" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And the "Export as Moodle XML" action should exist for the "First question" question in the question bank PK\M4 bank/exporttoxml/exportone.phpnu[. /** * Script to download the export of a single question. * * @package qbank_exporttoxml * @copyright 2015 the Open University * @author Safat Shahin * @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_once($CFG->dirroot . '/question/format/xml/format.php'); // Get the parameters from the URL. $questionid = required_param('id', PARAM_INT); $cmid = required_param('cmid', PARAM_INT); $urlparams = ['id' => $questionid, 'sesskey' => sesskey()]; \core_question\local\bank\helper::require_plugin_enabled('qbank_exporttoxml'); $cm = get_coursemodule_from_id(null, $cmid); require_login($cm->course, false, $cm); $thiscontext = context_module::instance($cmid); $urlparams['cmid'] = $cmid; require_sesskey(); // Load the necessary data. $contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $questiondata = question_bank::load_question_data($questionid); // Check permissions. question_require_capability_on($questiondata, 'view'); // Initialise $PAGE. Nothing is output, so this does not really matter. Just avoids notices. $nexturl = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams); $PAGE->set_url('/question/bank/exporttoxml/exportone.php', $urlparams); $PAGE->set_heading($COURSE->fullname); $PAGE->set_pagelayout('admin'); // Set up the export format. $qformat = new qformat_xml(); $filename = question_default_export_filename($COURSE, $questiondata) . $qformat->export_file_extension(); $qformat->setContexts($contexts->having_one_edit_tab_cap('export')); $qformat->setCourse($COURSE); $qformat->setQuestions([$questiondata]); $qformat->setCattofile(false); $qformat->setContexttofile(false); // Do the export. if (!$qformat->exportpreprocess()) { send_file_not_found(); } if (!$content = $qformat->exportprocess(true)) { send_file_not_found(); } send_file($content, $filename, 0, 0, true, true, $qformat->mime_type()); PK\8 ww8bank/viewquestionname/lang/en/qbank_viewquestionname.phpnu[. /** * Strings for component qbank_viewquestionname, language 'en' * * @package qbank_viewquestionname * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'View question name'; $string['privacy:metadata'] = 'The View question name question bank plugin does not store any personal data.'; $string['questionidnumbercondition'] = 'Question ID number'; $string['questionnamecondition'] = 'Question name'; // In place editing. $string['edit_question_name_hint'] = 'Edit question name'; $string['edit_question_name_label'] = 'New value for {$a->name}'; PK\*. /** * Callback and other methods for viewquestionname plugin. * * @package qbank_viewquestionname * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_external\external_api; use core_external\external_description; use core_external\external_files; use core_external\external_format_value; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_settings; use core_external\external_single_structure; use core_external\external_value; use core_external\external_warnings; use core_external\restricted_context_exception; use core_external\util; use core_external\util as external_util; /** * In place editing callback for question name. * * @param string $itemtype type of the item, questionname for this instance * @param int $itemid question id to change the title * @param string $newvalue the changed question title * @return \core\output\inplace_editable */ function qbank_viewquestionname_inplace_editable ($itemtype, $itemid, $newvalue): \core\output\inplace_editable { if ($itemtype === 'questionname') { global $CFG, $DB; require_once($CFG->libdir . '/questionlib.php'); // Get the question data and to confirm any invalud itemid is not passed. $record = $DB->get_record('question', ['id' => $itemid], '*', MUST_EXIST); // Load question data from question engine. $question = question_bank::load_question($record->id); question_require_capability_on($question, 'edit'); // Context validation. external_api::validate_context(context::instance_by_id($question->contextid)); // Now update the question data. $record->name = $newvalue; $DB->update_record('question', $record); // Trigger events. question_bank::notify_question_edited($record->id); $event = \core\event\question_updated::create_from_question_instance($question); $event->trigger(); // Prepare the element for the output. return new \qbank_viewquestionname\output\questionname($record); } } PK\7#!bank/viewquestionname/version.phpnu[. /** * Version information for qbank_viewquestionname. * * @package qbank_viewquestionname * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_viewquestionname'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\,q2bank/viewquestionname/classes/privacy/provider.phpnu[. namespace qbank_viewquestionname\privacy; /** * Privacy Subsystem for qbank_viewquestionname implementing null_provider. * * @package qbank_viewquestionname * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\opNZ Z =bank/viewquestionname/classes/question_idnumber_condition.phpnu[. namespace qbank_viewquestionname; use core\output\datafilter; use core_question\local\bank\condition; /** * Filter condition for filtering on the question idnumber * * @package qbank_viewquestionname * @copyright 2024 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 question_idnumber_condition extends condition { #[\Override] public function get_title() { return get_string('questionidnumbercondition', 'qbank_viewquestionname'); } #[\Override] public static function get_condition_key() { return 'questionidnumber'; } #[\Override] public function get_filter_class() { return 'core/datafilter/filtertypes/keyword'; } /** * Return an SQL condition and parameters for filtering on q.idnumber. * * This will search for the terms provided anywhere in the name. * * @param array $filter * @return array */ public static function build_query_from_filter(array $filter): array { global $DB; $conditions = []; $params = []; $notlike = $filter['jointype'] === datafilter::JOINTYPE_NONE; foreach ($filter['values'] as $key => $value) { $params["idnumber{$key}"] = "%{$value}%"; $conditions[] = $DB->sql_like('qbe.idnumber', ":idnumber{$key}", casesensitive: false, notlike: $notlike); } $delimiter = $filter['jointype'] === datafilter::JOINTYPE_ANY ? ' OR ' : ' AND '; return [ implode($delimiter, $conditions), $params, ]; } } PK\p))5bank/viewquestionname/classes/output/questionname.phpnu[. namespace qbank_viewquestionname\output; use core\output\inplace_editable; use core\output\named_templatable; use renderable; /** * Question in place editing api call. * * @package qbank_viewquestionname * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class questionname extends inplace_editable implements named_templatable, renderable { public function __construct(\stdClass $question) { parent::__construct( 'qbank_viewquestionname', 'questionname', $question->id, question_has_capability_on($question, 'edit'), format_string($question->name), $question->name, get_string('edit_question_name_hint', 'qbank_viewquestionname'), get_string('edit_question_name_label', 'qbank_viewquestionname', (object) [ 'name' => $question->name, ]) ); } public function get_template_name(\renderer_base $renderer): string { return 'core/inplace_editable'; } } PK\:=: : 9bank/viewquestionname/classes/question_name_condition.phpnu[. namespace qbank_viewquestionname; use core\output\datafilter; /** * Filter condition for filtering on the question name * * @package qbank_viewquestionname * @copyright 2024 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 question_name_condition extends \core_question\local\bank\condition { #[\Override] public function get_title() { return get_string('questionnamecondition', 'qbank_viewquestionname'); } #[\Override] public static function get_condition_key() { return 'questionname'; } #[\Override] public function get_filter_class() { return 'core/datafilter/filtertypes/keyword'; } /** * Return an SQL condition and parameters for filtering on q.name. * * This will search for the terms provided anywhere in the name. * * @param array $filter * @return array */ public static function build_query_from_filter(array $filter): array { global $DB; $conditions = []; $params = []; $notlike = $filter['jointype'] === datafilter::JOINTYPE_NONE; foreach ($filter['values'] as $key => $value) { $params["questionname{$key}"] = "%{$value}%"; $conditions[] = $DB->sql_like('q.name', ":questionname{$key}", casesensitive: false, notlike: $notlike); } $delimiter = $filter['jointype'] === datafilter::JOINTYPE_ANY ? ' OR ' : ' AND '; return [ implode($delimiter, $conditions), $params, ]; } } PK\$jo@bank/viewquestionname/classes/viewquestionname_column_helper.phpnu[. namespace qbank_viewquestionname; use core_question\local\bank\column_base; /** * A column type for the name of the question name. * * @package qbank_viewquestionname * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class viewquestionname_column_helper extends column_base { /** * @var null $checkboxespresent */ protected $checkboxespresent = null; public function get_name(): string { return 'questionname'; } public function get_title(): string { return get_string('question'); } protected function label_for($question): string { if (is_null($this->checkboxespresent)) { $this->checkboxespresent = $this->qbank->has_column('checkbox_column'); } if ($this->checkboxespresent) { return 'checkq' . $question->id; } return ''; } protected function display_content($question, $rowclasses): void { $labelfor = $this->label_for($question); if ($labelfor) { echo \html_writer::start_tag('label', array('for' => $labelfor)); } echo format_string($question->name); if ($labelfor) { echo \html_writer::end_tag('label'); } } public function get_required_fields(): array { return ['q.id', 'q.name']; } public function is_sortable() { return 'q.name'; } public function get_default_width(): int { return 250; } } PK\'Dbank/viewquestionname/classes/question_name_idnumber_tags_column.phpnu[. namespace qbank_viewquestionname; /** * A question bank column showing the question name with idnumber and tags. * * @package qbank_viewquestionname * @copyright 2019 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_name_idnumber_tags_column extends viewquestionname_column_helper { public function get_name(): string { return 'qnameidnumbertags'; } protected function display_content($question, $rowclasses): void { global $OUTPUT; echo \html_writer::start_tag('div', ['class' => 'd-inline-flex flex-nowrap overflow-hidden w-100']); $questiondisplay = $OUTPUT->render(new \qbank_viewquestionname\output\questionname($question)); $labelfor = $this->label_for($question); if ($labelfor) { echo \html_writer::tag('label', $questiondisplay, [ 'for' => $labelfor, ]); } else { echo \html_writer::start_span('questionname flex-grow-1 flex-shrink-1 text-truncate'); echo $questiondisplay; echo \html_writer::end_span(); } // Question idnumber. // The non-breaking space ' ' is used in html to fix MDL-75051 (browser issues caused by chrome and Edge). if ($question->idnumber !== null && $question->idnumber !== '') { echo ' ' . \html_writer::span( \html_writer::span(get_string('idnumber', 'question') . ' ', 'accesshide') . \html_writer::span(s($question->idnumber), 'badge bg-primary text-white'), 'ms-1'); } // Question tags. if (!empty($question->tags)) { $tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id); echo $OUTPUT->tag_list($tags, null, 'd-inline flex-shrink-1 text-truncate ms-1', 0, null, true); } echo \html_writer::end_tag('div'); // If the question is invalid, show a warning badge. if (!\question_bank::is_qtype_usable($question->qtype)) { echo \html_writer::span(get_string('invalidquestiontype', 'question', $question->qtype), 'badge bg-danger text-white'); } } public function get_required_fields(): array { $fields = parent::get_required_fields(); $fields[] = 'qbe.idnumber'; return $fields; } public function is_sortable(): array { return [ 'name' => ['field' => 'q.name', 'title' => get_string('questionname', 'question')], 'idnumber' => ['field' => 'qbe.idnumber', 'title' => get_string('idnumber', 'question')], ]; } public function load_additional_data(array $questions): void { parent::load_additional_data($questions); parent::load_question_tags($questions); } public function get_extra_classes(): array { return ['pe-3']; } } PK\90bank/viewquestionname/classes/plugin_feature.phpnu[. namespace qbank_viewquestionname; use core_question\local\bank\plugin_features_base; use core_question\local\bank\view; /** * Plugin entrypoint for columns. * * @package qbank_viewquestionname * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends plugin_features_base { #[\Override] public function get_question_columns($qbank): array { return [ new question_name_idnumber_tags_column($qbank) ]; } #[\Override] public function get_question_filters(?view $qbank = null): array { return [ new question_name_condition($qbank), new question_idnumber_condition($qbank), ]; } } PK\Kz* * Hbank/viewquestionname/tests/behat/filter_condition_question_name.featurenu[@qbank @qbank_viewquestionnname @javascript Feature: Filter questions by name As a teacher In order to organise my questions I want to filter the list of questions by name Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Qbank 1 | truefalse | First question | Answer the first question | | Default for Qbank 1 | numerical | Second question | Answer the second question | | Default for Qbank 1 | essay | Third Question | Answer the third question | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I should see "First question" And I should see "Second question" And I should see "Third Question" Scenario: Filter by a single word When I apply question bank filter "Question name" with value "First" Then I should see "First question" And I should not see "Second question" And I should not see "Third Question" Scenario: Filter by any word When I apply question bank filter "Question name" with value "First, Third" Then I should see "First question" And I should not see "Second question" And I should see "Third Question" Scenario: Filter by all words When I add question bank filter "Question name" And I set the field "Question name" to "question, d" And I set the field "Match" in the "Filter 3" "fieldset" to "All" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" # Filter should be case-insensitive. And I should see "Third Question" Scenario: Exclude names by filter When I add question bank filter "Question name" And I set the field "Question name" to "First, Third" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should not see "First question" And I should see "Second question" And I should not see "Third Question" PK\CooCbank/viewquestionname/tests/behat/view_question_name_column.featurenu[@qbank @qbank_viewquestionname Feature: Use the qbank plugin manager page for viewquestionname In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | Scenario: Enable/disable viewquestionname column from the base view Given I log in as "admin" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "View question name" When I click on "Disable" "link" in the "View question name" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "First question" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View question name" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should see "First question" PK\TTCbank/viewquestionname/tests/behat/question_in_place_editing.featurenu[@qbank @qbank_viewquestionname @javascript Feature: Use the qbank view page to edit question title using in place edit feature Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | T1 | Teacher1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | Scenario: Question title can be changed from the question bank view Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" When I set the field "Edit question name" in the "First question" "table_row" to "Edited question" Then I should not see "First question" And I should see "Edited question" Scenario: Teacher without permission can not change the title from question bank view Given the following "role capability" exists: | role | editingteacher | | moodle/question:editall | prevent | And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" And I set the field "Filter type" to "Category" And I set the field "Category" to "Test questions" And I press "Apply filters" And I should see "First question" And "Edit question name" "field" should not exist PK\d|1 Lbank/viewquestionname/tests/behat/filter_condition_question_idnumber.featurenu[@qbank @qbank_viewquestionnname @javascript Feature: Filter questions by idnumber As a teacher In order to organise my questions I want to filter the list of questions by idnumber Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Test questions | truefalse | a | Answer the first question | q_01_ab | | Test questions | numerical | b | Answer the second question | q_02_bc | | Test questions | essay | c | Answer the third question | Q_21_ca | And I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" And I apply question bank filter "Category" with value "Test questions" And I should see "q_01_ab" And I should see "q_02_bc" And I should see "Q_21_ca" Scenario: Filter by a single term When I apply question bank filter "Question ID number" with value "ab" Then I should see "q_01_ab" And I should not see "q_02_bc" And I should not see "Q_21_ca" Scenario: Filter by any term When I apply question bank filter "Question ID number" with value "ab, ca" Then I should see "q_01_ab" And I should not see "q_02_bc" And I should see "Q_21_ca" Scenario: Filter by all terms When I add question bank filter "Question ID number" And I set the field "Question ID number" to "q, c" And I set the field "Match" in the "Filter 3" "fieldset" to "All" And I press "Apply filters" Then I should not see "q_01_ab" And I should see "q_02_bc" # Filter should be case-insensitive. And I should see "Q_21_ca" Scenario: Exclude idnumbers by filter When I add question bank filter "Question ID number" And I set the field "Question ID number" to "ab, ca" And I set the field "Match" in the "Filter 3" "fieldset" to "None" And I press "Apply filters" Then I should not see "q_01_ab" And I should see "q_02_bc" And I should not see "Q_21_ca" PK\+{6bank/importquestions/lang/en/qbank_importquestions.phpnu[. /** * Strings for component 'qbank_importquestions', language 'en' * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Marc-Alexandre Ghaly * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Import questions'; $string['privacy:metadata'] = 'The Import questions question bank plugin imports questions from a file according to the selected file format.'; PK\F bank/importquestions/version.phpnu[. /** * Plugin version and other meta-data are defined here. * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Marc-Alexandre Ghaly * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_importquestions'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\Fs~:bank/importquestions/classes/form/question_import_form.phpnu[. /** * Defines the import questions form. * * @package qbank_importquestions * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_importquestions\form; defined('MOODLE_INTERNAL') || die(); use moodle_exception; use moodleform; use stdClass; require_once($CFG->libdir . '/formslib.php'); /** * Form to import questions into the question bank. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_import_form extends moodleform { /** * Build the form definition. * * This adds all the form fields that the manage categories feature needs. * @throws \coding_exception */ protected function definition() { global $OUTPUT; $mform = $this->_form; $defaultcategory = $this->_customdata['defaultcategory']; $contexts = $this->_customdata['contexts']; // Choice of import format, with help icons. $mform->addElement('header', 'fileformat', get_string('fileformat', 'question')); $fileformatnames = get_import_export_formats('import'); $radioarray = []; $separators = []; foreach ($fileformatnames as $shortname => $fileformatname) { $radioarray[] = $mform->createElement('radio', 'format', '', $fileformatname, $shortname); $separator = ''; if (get_string_manager()->string_exists('pluginname_help', 'qformat_' . $shortname)) { $separator .= $OUTPUT->help_icon('pluginname', 'qformat_' . $shortname); } $separator .= '
    '; $separators[] = $separator; } $radioarray[] = $mform->createElement('static', 'makelasthelpiconshowup', ''); $mform->addGroup($radioarray, "formatchoices", '', $separators, false); $mform->addRule("formatchoices", null, 'required', null, 'client'); // Import options. $mform->addElement('header', 'general', get_string('general', 'form')); $mform->addElement('questioncategory', 'category', get_string('importcategory', 'question'), compact('contexts')); $mform->setDefault('category', $defaultcategory); $mform->addHelpButton('category', 'importcategory', 'question'); $categorygroup = []; $categorygroup[] = $mform->createElement('checkbox', 'catfromfile', '', get_string('getcategoryfromfile', 'question')); $categorygroup[] = $mform->createElement('checkbox', 'contextfromfile', '', get_string('getcontextfromfile', 'question')); $mform->addGroup($categorygroup, 'categorygroup', '', '', false); $mform->disabledIf('categorygroup', 'catfromfile', 'notchecked'); $mform->setDefault('catfromfile', 1); $mform->setDefault('contextfromfile', 1); $matchgrades = []; $matchgrades['error'] = get_string('matchgradeserror', 'question'); $matchgrades['nearest'] = get_string('matchgradesnearest', 'question'); $mform->addElement('select', 'matchgrades', get_string('matchgrades', 'question'), $matchgrades); $mform->addHelpButton('matchgrades', 'matchgrades', 'question'); $mform->setDefault('matchgrades', 'error'); $mform->addElement('selectyesno', 'stoponerror', get_string('stoponerror', 'question')); $mform->setDefault('stoponerror', 1); $mform->addHelpButton('stoponerror', 'stoponerror', 'question'); // The file to import. $mform->addElement('header', 'importfileupload', get_string('importquestions', 'question')); $mform->addElement('filepicker', 'newfile', get_string('import')); $mform->addRule('newfile', null, 'required', null, 'client'); // Submit button. $mform->addElement('submit', 'submitbutton', get_string('import')); // Set a template for the format select elements. $renderer = $mform->defaultRenderer(); $template = "{help} {element}\n"; $renderer->setGroupElementTemplate($template, 'format'); } /** * Checks that a file has been uploaded, and that it is of a plausible type. * @param array $data the submitted data. * @param array $errors the errors so far. * @return array the updated errors. * @throws moodle_exception */ protected function validate_uploaded_file($data, $errors) { global $CFG; if (empty($data['newfile'])) { $errors['newfile'] = get_string('required'); return $errors; } $files = $this->get_draft_files('newfile'); if (!is_array($files) || count($files) < 1) { $errors['newfile'] = get_string('required'); return $errors; } if (empty($data['format'])) { $errors['format'] = get_string('required'); return $errors; } $formatfile = $CFG->dirroot . '/question/format/' . $data['format'] . '/format.php'; if (!is_readable($formatfile)) { throw new moodle_exception('formatnotfound', 'question', '', $data['format']); } require_once($formatfile); $classname = 'qformat_' . $data['format']; $qformat = new $classname(); $file = reset($files); if (!$qformat->can_import_file($file)) { $a = new stdClass(); $a->actualtype = $file->get_mimetype(); $a->expectedtype = $qformat->mime_type(); $errors['newfile'] = get_string('importwrongfiletype', 'question', $a); return $errors; } $fileerrors = $qformat->validate_file($file); if ($fileerrors) { $errors['newfile'] = $fileerrors; } return $errors; } /** * Validation. * * @param array $data * @param array $files * @return array the errors that were found * @throws \dml_exception|\coding_exception|moodle_exception */ public function validation($data, $files) { $errors = parent::validation($data, $files); $errors = $this->validate_uploaded_file($data, $errors); return $errors; } } PK\qrr1bank/importquestions/classes/privacy/provider.phpnu[. namespace qbank_importquestions\privacy; /** * Privacy Subsystem implementation for qbank_importquestions. * * @package qbank_importquestions * @category privacy * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Marc-Alexandre Ghaly * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\,Wpp+bank/importquestions/classes/navigation.phpnu[. /** * Plugin entrypoint for navigation. * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_importquestions; /** * Class navigation. * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation extends \core_question\local\bank\navigation_node_base { public function get_navigation_title(): string { return get_string('import', 'question'); } public function get_navigation_key(): string { return 'import'; } public function get_navigation_url(): \moodle_url { return new \moodle_url('/question/bank/importquestions/import.php'); } } PK\h-/bank/importquestions/classes/plugin_feature.phpnu[. /** * Plugin entrypoint for qbank. * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbank_importquestions; use core_question\local\bank\navigation_node_base; /** * Class plugin_feature. * * @package qbank_importquestions * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { public function get_navigation_node(): ?navigation_node_base { return new navigation(); } } PK\=Tuubank/importquestions/import.phpnu[. /** * Defines the import questions form. * * @package qbank_importquestions * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/renderer.php'); use qbank_importquestions\form\question_import_form; require_login(); core_question\local\bank\helper::require_plugin_enabled('qbank_importquestions'); list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) = question_edit_setup('import', '/question/bank/importquestions/import.php'); // Get display strings. $txt = new stdClass(); $txt->importerror = get_string('importerror', 'question'); $txt->importquestions = get_string('importquestions', 'question'); list($catid, $catcontext) = explode(',', $pagevars['cat']); if (!$category = $DB->get_record("question_categories", ['id' => $catid])) { throw new moodle_exception('nocategory', 'question'); } $categorycontext = context::instance_by_id($category->contextid); $category->context = $categorycontext; // This page can be called without cmid in which case. // We get the context from the category object. if ($contexts === null) { // Need to get the course from the chosen category. $contexts = new core_question\local\bank\question_edit_contexts($categorycontext); $thiscontext = $contexts->lowest(); [$module, $cm] = get_module_from_cmid($thiscontext->instanceid); require_login($cm->course, false, $cm); $contexts->require_one_edit_tab_cap($edittab); } $PAGE->set_url($thispageurl); $importform = new question_import_form($thispageurl, ['contexts' => $contexts->having_one_edit_tab_cap('import'), 'defaultcategory' => $pagevars['cat']]); if ($importform->is_cancelled()) { redirect($thispageurl); } // Page header. $PAGE->set_title($txt->importquestions); $PAGE->set_heading($COURSE->fullname); $PAGE->activityheader->disable(); echo $OUTPUT->header(); // Print horizontal nav if needed. $renderer = $PAGE->get_renderer('core_question', 'bank'); $qbankaction = new \core_question\output\qbank_action_menu($thispageurl); echo $renderer->render($qbankaction); // File upload form submitted. if ($form = $importform->get_data()) { // File checks out ok. $fileisgood = false; // Work out if this is an uploaded file. // Or one from the filesarea. $realfilename = $importform->get_new_filename('newfile'); $importfile = make_request_directory() . "/{$realfilename}"; if (!$result = $importform->save_file('newfile', $importfile, true)) { throw new moodle_exception('uploadproblem'); } $formatfile = $CFG->dirroot . '/question/format/' . $form->format . '/format.php'; if (!is_readable($formatfile)) { throw new moodle_exception('formatnotfound', 'question', '', $form->format); } require_once($formatfile); $classname = 'qformat_' . $form->format; $qformat = new $classname(); // Load data into class. $qformat->setCategory($category); $qformat->setContexts($contexts->having_one_edit_tab_cap('import')); $qformat->setCourse($COURSE); $qformat->setFilename($importfile); $qformat->setRealfilename($realfilename); $qformat->setMatchgrades($form->matchgrades); $qformat->setCatfromfile(!empty($form->catfromfile)); $qformat->setContextfromfile(!empty($form->contextfromfile)); $qformat->setStoponerror($form->stoponerror); // Do anything before that we need to. if (!$qformat->importpreprocess()) { throw new moodle_exception('cannotimport', '', $thispageurl->out()); } // Process the uploaded file. if (!$qformat->importprocess()) { throw new moodle_exception('cannotimport', '', $thispageurl->out()); } // In case anything needs to be done after. if (!$qformat->importpostprocess()) { throw new moodle_exception('cannotimport', '', $thispageurl->out()); } // Log the import into this category. $eventparams = [ 'contextid' => $qformat->category->contextid, 'other' => ['format' => $form->format, 'categoryid' => $qformat->category->id], ]; $event = \core\event\questions_imported::create($eventparams); $event->trigger(); $params = $thispageurl->params() + ['category' => $qformat->category->id . ',' . $qformat->category->contextid]; echo $OUTPUT->continue_button(new moodle_url('/question/edit.php', $params)); echo $OUTPUT->footer(); exit; } echo $OUTPUT->heading_with_help($txt->importquestions, 'importquestions', 'question'); // Print upload form. $importform->display(); echo $OUTPUT->footer(); PK\ۤ6bank/previewquestion/lang/en/qbank_previewquestion.phpnu[. /** * Strings for component qbank_previewquestion, language 'en' * * @package qbank_previewquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['alwayslatest'] = 'Always latest'; $string['newerversion'] = 'This preview is using an older version of the question. {$a->restartbutton}'; $string['pluginname'] = 'Preview question'; $string['privacy:metadata'] = 'The Preview question question bank plugin does not store any personal data.'; // Tag related errors. $string['tagclosebutton'] = 'Close'; $string['tagerror'] = 'No question was found with following tags: {$a}. Please change or remove tags filtering.'; $string['tagsnotfound'] = 'Tags not found'; // Form string(s). $string['previewoptions'] = 'Preview options'; $string['questionversion'] = 'Question version'; $string['restartnow'] = 'Use latest version'; $string['theoptionsyouselectonlyaffectthepreview'] = 'These settings are for testing the question. The options you select only affect the preview.'; // Preview title. $string['versiontitle'] = 'Version {$a}'; $string['versiontitlelatest'] = 'Version {$a} (latest)'; PK\KKbank/previewquestion/lib.phpnu[. /** * Callback methods for preview question component * * @package qbank_previewquestion * @copyright 2023 Paul Holden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); /** * Get the current user preferences that are available * * @return array[] */ function qbank_previewquestion_user_preferences(): array { return [ 'core_question_preview_techinfo_collapsed' => [ 'type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED, 'default' => true, 'permissioncallback' => [core_user::class, 'is_current_user'], ], ]; } PK\0  bank/previewquestion/version.phpnu[. /** * Version information for qbank_previewquestion. * * @package qbank_previewquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_previewquestion'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\pE??:bank/previewquestion/classes/form/preview_options_form.phpnu[. namespace qbank_previewquestion\form; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); use moodleform; use question_display_options; use question_engine; use qbank_previewquestion\question_preview_options; /** * Settings form for the preview options. * * @package qbank_previewquestion * @copyright 2009 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class preview_options_form extends moodleform { public function definition() { $mform = $this->_form; $hiddenorvisible = [ question_display_options::HIDDEN => get_string('notshown', 'question'), question_display_options::VISIBLE => get_string('shown', 'question'), ]; $mform->addElement('header', 'attemptoptionsheader', get_string('previewoptions', 'qbank_previewquestion')); $mform->setExpanded('attemptoptionsheader', false); // Add html element with class to display long text in single line. $mform->addElement('html', \html_writer::div(get_string('theoptionsyouselectonlyaffectthepreview', 'qbank_previewquestion'), "col-md-12 row d-flex col-form-label mb-3")); $versions = $this->_customdata['versions']; $currentversion = $this->_customdata['restartversion']; $select = $mform->addElement('select', 'restartversion', get_string('questionversion', 'qbank_previewquestion'), $versions); $select->setSelected($currentversion); $behaviours = question_engine::get_behaviour_options( $this->_customdata['quba']->get_preferred_behaviour()); $mform->addElement('select', 'behaviour', get_string('howquestionsbehave', 'question'), $behaviours); $mform->addHelpButton('behaviour', 'howquestionsbehave', 'question'); $mform->addElement('float', 'maxmark', get_string('markedoutof', 'question'), ['size' => '5']); if ($this->_customdata['maxvariant'] > 1) { $variants = range(1, $this->_customdata['maxvariant']); $mform->addElement('select', 'variant', get_string('questionvariant', 'question'), array_combine($variants, $variants)); } $mform->setType('variant', PARAM_INT); $mform->addElement('submit', 'saverestart', get_string('restartwiththeseoptions', 'question')); $mform->addElement('header', 'displayoptionsheader', get_string('displayoptions', 'question')); $mform->setExpanded('displayoptionsheader', false); $mform->addElement('select', 'correctness', get_string('whethercorrect', 'question'), $hiddenorvisible); $marksoptions = [ question_display_options::HIDDEN => get_string('notshown', 'question'), question_display_options::MAX_ONLY => get_string('showmaxmarkonly', 'question'), question_display_options::MARK_AND_MAX => get_string('showmarkandmax', 'question'), ]; $mform->addElement('select', 'marks', get_string('marks', 'question'), $marksoptions); $mform->addElement('select', 'markdp', get_string('decimalplacesingrades', 'question'), question_engine::get_dp_options()); $mform->addElement('select', 'feedback', get_string('specificfeedback', 'question'), $hiddenorvisible); $mform->addElement('select', 'generalfeedback', get_string('generalfeedback', 'question'), $hiddenorvisible); $mform->addElement('select', 'rightanswer', get_string('rightanswer', 'question'), $hiddenorvisible); $mform->addElement('select', 'history', get_string('responsehistory', 'question'), $hiddenorvisible); $mform->addElement('submit', 'saveupdate', get_string('updatedisplayoptions', 'question')); } } PK\i1bank/previewquestion/classes/privacy/provider.phpnu[. namespace qbank_previewquestion\privacy; /** * Privacy Subsystem for qbank_previewquestion implementing null_provider. * * @package qbank_previewquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { public static function get_reason(): string { return 'privacy:metadata'; } } PK\`UN44'bank/previewquestion/classes/helper.phpnu[. namespace qbank_previewquestion; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/editlib.php'); use action_menu; use comment; use context_module; use context; use core\plugininfo\qbank; use core_question\local\bank\edit_menu_column; use core_question\local\bank\view; use core_question\local\bank\question_edit_contexts; use moodle_url; use question_bank; use question_definition; use question_display_options; use question_engine; use stdClass; /** * Class helper contains all the helper functions. * * @package qbank_previewquestion * @copyright 2010 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Called via pluginfile.php -> question_pluginfile to serve files belonging to * a question in a question_attempt when that attempt is a preview. * * @param stdClass $course course settings object * @param stdClass $context context object * @param string $component the name of the component we are serving files for. * @param string $filearea the name of the file area. * @param int $qubaid the question_usage this image belongs to. * @param int $slot the relevant slot within the usage. * @param array $args the remaining bits of the file path. * @param bool $forcedownload whether the user must be forced to download the file. * @param array $fileoptions options for the stored files * @return void false if file not found, does not return if found - justsend the file */ public static function question_preview_question_pluginfile($course, $context, $component, $filearea, $qubaid, $slot, $args, $forcedownload, $fileoptions): void { global $USER, $DB, $CFG; list($context, $course, $cm) = get_context_info_array($context->id); require_login($course, false, $cm); $quba = question_engine::load_questions_usage_by_activity($qubaid); if (!question_has_capability_on($quba->get_question($slot, false), 'use')) { send_file_not_found(); } $options = new question_display_options(); $options->feedback = question_display_options::VISIBLE; $options->numpartscorrect = question_display_options::VISIBLE; $options->generalfeedback = question_display_options::VISIBLE; $options->rightanswer = question_display_options::VISIBLE; $options->manualcomment = question_display_options::VISIBLE; $options->history = question_display_options::VISIBLE; if (!$quba->check_file_access($slot, $options, $component, $filearea, $args, $forcedownload)) { send_file_not_found(); } $fs = get_file_storage(); $relativepath = implode('/', $args); $fullpath = "/{$context->id}/{$component}/{$filearea}/{$relativepath}"; if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) { send_file_not_found(); } send_stored_file($file, 0, 0, $forcedownload, $fileoptions); } /** * The the URL to use for actions relating to this preview. * * @param int $questionid the question being previewed * @param int $qubaid the id of the question usage for this preview * @param question_preview_options $options the options in use * @param context $context module context for the question preview * @param moodle_url $returnurl url of the page to return to * @param int|null $restartversion version of the question to use when next restarting the preview. * @return moodle_url */ public static function question_preview_action_url($questionid, $qubaid, question_preview_options $options, $context, $returnurl = null, $restartversion = null): moodle_url { $params = [ 'id' => $questionid, 'previewid' => $qubaid, ]; if ($context->contextlevel !== CONTEXT_MODULE) { debugging("Invalid contextlevel: {$context->contextlevel} must be CONTEXT_MODULE"); } $params['cmid'] = $context->instanceid; if ($returnurl !== null) { $params['returnurl'] = $returnurl; } if ($restartversion !== null) { $params['restartversion'] = $restartversion; } $params = array_merge($params, $options->get_url_params()); return new moodle_url('/question/bank/previewquestion/preview.php', $params); } /** * The the URL to use for actions relating to this preview. * * @param int $questionid the question being previewed * @param context $context the current moodle module context * @param int $previewid optional previewid to sign post saved previewed answers * @param moodle_url $returnurl url of the page to return to * @return moodle_url */ public static function question_preview_form_url($questionid, $context, $previewid = null, $returnurl = null): moodle_url { $params = [ 'id' => $questionid, ]; if ($context->contextlevel !== CONTEXT_MODULE) { debugging("Invalid contextlevel: {$context->contextlevel} must be CONTEXT_MODULE"); } $params['cmid'] = $context->instanceid; if ($previewid) { $params['previewid'] = $previewid; } if ($returnurl !== null) { $params['returnurl'] = $returnurl; } return new moodle_url('/question/bank/previewquestion/preview.php', $params); } /** * Delete the current preview, if any, and redirect to start a new preview. * * @param int $previewid id of the preview while restarting it * @param int $questionid id of the question in preview * @param object $displayoptions display options for the question in preview * @param object $context context of the question for preview * @param moodle_url $returnurl url of the page to return to * @param int|null $restartversion version of the question to use when next restarting the preview. * @return void */ public static function restart_preview($previewid, $questionid, $displayoptions, $context, $returnurl = null, $restartversion = null): void { global $DB; if ($previewid) { $transaction = $DB->start_delegated_transaction(); question_engine::delete_questions_usage_by_activity($previewid); $transaction->allow_commit(); } redirect(self::question_preview_url($questionid, $displayoptions->behaviour, $displayoptions->maxmark, $displayoptions, $displayoptions->variant, $context, $returnurl, $restartversion)); } /** * Generate the URL for starting a new preview of a given question with the given options. * * @param integer $questionid the question to preview * @param string $preferredbehaviour the behaviour to use for the preview * @param float $maxmark the maximum to mark the question out of * @param question_display_options $displayoptions the display options to use * @param int $variant the variant of the question to preview. If null, one will * be picked randomly * @param object $context context to run the preview in (affects things like * filter settings, theme, lang, etc.) Defaults to $PAGE->context * @param moodle_url $returnurl url of the page to return to * @param int $restartversion The version of the question to use when restarting the preview. * @return moodle_url the URL */ public static function question_preview_url($questionid, $preferredbehaviour = null, $maxmark = null, $displayoptions = null, $variant = null, $context = null, $returnurl = null, $restartversion = null): moodle_url { $params = ['id' => $questionid]; if (!is_null($restartversion)) { $params['restartversion'] = $restartversion; } if (is_null($context)) { global $PAGE; $context = $PAGE->context; } if ($context->contextlevel == CONTEXT_MODULE) { $params['cmid'] = $context->instanceid; } else if ($context->contextlevel == CONTEXT_COURSE) { $params['courseid'] = $context->instanceid; } if (!is_null($preferredbehaviour)) { $params['behaviour'] = $preferredbehaviour; } if (!is_null($maxmark)) { $params['maxmark'] = format_float($maxmark, -1); } if (!is_null($displayoptions)) { $params['correctness'] = $displayoptions->correctness; $params['marks'] = $displayoptions->marks; $params['markdp'] = $displayoptions->markdp; $params['feedback'] = (bool) $displayoptions->feedback; $params['generalfeedback'] = (bool) $displayoptions->generalfeedback; $params['rightanswer'] = (bool) $displayoptions->rightanswer; $params['history'] = (bool) $displayoptions->history; } if (!is_null($returnurl)) { $params['returnurl'] = $returnurl; } if ($variant) { $params['variant'] = $variant; } return new moodle_url('/question/bank/previewquestion/preview.php', $params); } /** * Popup params for the question preview. * * @return array that can be passed as $params to the {@see popup_action} constructor. */ public static function question_preview_popup_params(): array { return [ 'height' => 600, 'width' => 800, ]; } /** * Get the extra elements for preview from qbank plugins. * * @param question_definition $question question definition object * @param int $courseid id of the course * @return array */ public static function get_preview_extra_elements(question_definition $question, int $courseid): array { $plugins = get_plugin_list_with_function('qbank', 'preview_display'); $comment = ''; $extrahtml = []; foreach ($plugins as $componentname => $plugin) { $pluginhtml = component_callback($componentname, 'preview_display', [$question, $courseid]); if ($componentname === 'qbank_comment') { $comment = $pluginhtml; continue; } $extrahtml[] = $pluginhtml; } return [$comment, $extrahtml]; } /** * Checks if question is the latest version. * * @param string $version Question version to check * @param string $questionbankentryid Entry to check against * @return bool */ public static function is_latest(string $version, string $questionbankentryid): bool { global $DB; $sql = 'SELECT MAX(version) AS max FROM {question_versions} WHERE questionbankentryid = ?'; $latestversion = $DB->get_record_sql($sql, [$questionbankentryid]); if (isset($latestversion->max)) { return ($version === $latestversion->max) ? true : false; } return false; } /** * Loads question version ids for current question. * * @param string $questionbankentryid Question bank entry id * @return array $questionids Array containing question id as key and version as value. */ public static function load_versions(string $questionbankentryid): array { global $DB; $questionids = []; $sql = 'SELECT version, questionid FROM {question_versions} WHERE questionbankentryid = ? ORDER BY version'; $versions = $DB->get_records_sql($sql, [$questionbankentryid]); foreach ($versions as $key => $version) { $questionids[$version->questionid] = $key; } return $questionids; } /** * Return the question ID from the array of id => version that corresponds to the requested version. * * If the requested version is question_preview_options::ALWAYS_LATEST, this will return the latest version. * * @param array $versions * @param int $restartversion * @return ?int */ public static function get_restart_id(array $versions, int $restartversion): ?int { if ($restartversion === question_preview_options::ALWAYS_LATEST) { return array_key_last($versions); } else { return array_search($restartversion, $versions) ?: null; } } } PK\$cF F 0bank/previewquestion/classes/output/renderer.phpnu[. namespace qbank_previewquestion\output; use context; use qbank_previewquestion\helper; use qbank_previewquestion\question_preview_options; /** * Class renderer for rendering preview url * * @package qbank_previewquestion * @copyright 2009 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render an icon, optionally with the word 'Preview' beside it, to preview a given question. * * @param int $questionid the id of the question to be previewed. * @param context $context the context in which the preview is happening. * Must be a course or category context. * @param bool $showlabel if true, show the word 'Preview' after the icon. * If false, just show the icon. */ public function question_preview_link($questionid, context $context, $showlabel) { if ($showlabel) { $alt = ''; $label = get_string('preview'); $attributes = []; } else { $alt = get_string('preview'); $label = ''; $attributes = ['title' => $alt]; } $image = $this->pix_icon('t/preview', $alt, '', ['class' => 'iconsmall']); $link = helper::question_preview_url($questionid, null, null, null, null, $context, null, question_preview_options::ALWAYS_LATEST); $action = new \popup_action('click', $link, 'questionpreview', helper::question_preview_popup_params()); return $this->action_link($link, $image . $label, $action, $attributes); } /** * Render the preview page. * * @param array $previewdata */ public function render_preview_page($previewdata) { return $this->render_from_template('qbank_previewquestion/preview_question', $previewdata); } } PK\I9bank/previewquestion/classes/question_preview_options.phpnu[. namespace qbank_previewquestion; use question_display_options; /** * Displays question preview options as default and set the options. * * Setting default, getting and setting user preferences in question preview options. * * @package qbank_previewquestion * @copyright 2010 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_preview_options extends question_display_options { /** @var string the behaviour to use for this preview. */ public $behaviour; /** @var number the maximum mark to use for this preview. */ public $maxmark; /** @var int the variant of the question to preview. */ public $variant; /** @var string prefix to append to field names to get user_preference names. */ const OPTIONPREFIX = 'question_preview_options_'; /** @var int Special value for question version ID to indicate that we should always use the latest version */ const ALWAYS_LATEST = 0; /** * Constructor. * * @param \stdClass|\question_definition $question only the ->defaultmark field is used. */ public function __construct($question) { $this->behaviour = 'deferredfeedback'; $this->maxmark = $question->defaultmark; $this->variant = null; $this->correctness = self::VISIBLE; $this->marks = self::MARK_AND_MAX; $this->markdp = get_config('quiz', 'decimalpoints'); $this->feedback = self::VISIBLE; $this->numpartscorrect = $this->feedback; $this->generalfeedback = self::VISIBLE; $this->rightanswer = self::VISIBLE; $this->history = self::HIDDEN; $this->flags = self::HIDDEN; $this->manualcomment = self::HIDDEN; } /** * Names of the options we store in the user preferences table. * @return array */ protected function get_user_pref_fields(): array { return ['behaviour', 'correctness', 'marks', 'markdp', 'feedback', 'generalfeedback', 'rightanswer', 'history']; } /** * Names and param types of the options we read from the request. * @return array */ protected function get_field_types(): array { return [ 'behaviour' => PARAM_ALPHA, 'maxmark' => PARAM_LOCALISEDFLOAT, 'variant' => PARAM_INT, 'correctness' => PARAM_BOOL, 'marks' => PARAM_INT, 'markdp' => PARAM_INT, 'feedback' => PARAM_BOOL, 'generalfeedback' => PARAM_BOOL, 'rightanswer' => PARAM_BOOL, 'history' => PARAM_BOOL, ]; } /** * Load the value of the options from the user_preferences table. */ public function load_user_defaults(): void { $defaults = get_config('question_preview'); foreach ($this->get_user_pref_fields() as $field) { $this->$field = get_user_preferences( self::OPTIONPREFIX . $field, $defaults->$field); } $this->numpartscorrect = $this->feedback; } /** * Save a change to the user's preview options to the database. * @param object $newoptions */ public function save_user_preview_options($newoptions): void { foreach ($this->get_user_pref_fields() as $field) { if (isset($newoptions->$field)) { set_user_preference(self::OPTIONPREFIX . $field, $newoptions->$field); } } } /** * Set the value of any fields included in the request. */ public function set_from_request(): void { foreach ($this->get_field_types() as $field => $type) { $this->$field = optional_param($field, $this->$field, $type); } $this->numpartscorrect = $this->feedback; } /** * Parameters needed in the URL when continuing this preview. * * @return array URL fragment. */ public function get_url_params(): array { $params = []; foreach ($this->get_field_types() as $field => $notused) { if ($field === 'behaviour' || $field === 'maxmark' || is_null($this->$field)) { continue; } $params[$field] = $this->$field; } return $params; } } PK\}Ha a /bank/previewquestion/classes/preview_action.phpnu[. namespace qbank_previewquestion; use core_question\local\bank\question_action_base; /** * Question bank columns for the preview action icon. * * @package qbank_previewquestion * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class preview_action extends question_action_base { /** * @var string store this lang string for performance. */ protected $strpreview; public function init(): void { parent::init(); $this->strpreview = get_string('preview'); } public function get_menu_position(): int { return 100; } protected function get_url_icon_and_label(\stdClass $question): array { if (!\question_bank::is_qtype_installed($question->qtype)) { // It sometimes happens that people end up with junk questions // in their question bank of a type that is no longer installed. // We cannot do most actions on them, because that leads to errors. return [null, null, null]; } if (question_has_capability_on($question, 'use')) { $context = $this->qbank->get_most_specific_context(); // Default previews to always use the latest question version, unless we are previewing specific versions from the // question history. if ($this->qbank->is_listing_specific_versions()) { $requestedversion = $question->version; } else { $requestedversion = question_preview_options::ALWAYS_LATEST; } $url = helper::question_preview_url($question->id, null, null, null, null, $context, $this->qbank->returnurl, $requestedversion); return [$url, 't/preview', $this->strpreview]; } else { return [null, null, null]; } } } PK\CK/bank/previewquestion/classes/plugin_feature.phpnu[. namespace qbank_previewquestion; /** * Class columns is the entrypoint for the columns. * * @package qbank_previewquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base{ public function get_question_actions($qbank): array { return [ new preview_action($qbank) ]; } } PK\-8bank/previewquestion/templates/preview_question.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_previewquestion/preview_question The preview page for the question type preview. * actionurl - Url to post to * session - Moodle session * slot - The identifying number of the first question that was added to this usage * question - The html of the actual question from the engine * questionicon - The icon of the question type * questiontitle - The name of the question * versiontitle - The string for displaying the version * questionidumber - The idnumber of the question * restartdisabled - The attributes to enable or disable the button, same for finishdisabled and filldisabled * techinfo - Technical information like fraction, state, behaviour etc * exporttoxml - Link to export the question to xml * redirecturl - Url to the base view * comments - Comments api html * extrapreviewelements - Any plugin implementing the callback and sending extra html to view something in the preview page Example context (json): { "actionurl": "/", "session": "E2PwCfrnzz", "slot": "1", "question": "
    question html
    ", "questionicon": "", "questiontitle": "Question title", "versiontitle": { "versioninfo": "Version 3 (latest)" }, "questionidumber": "qidnumber1", "restartdisabled": "disabled='disabled'", "finishdisabled": "disabled='disabled'", "filldisabled": "disabled='disabled'", "techinfo": "
    Behaviour being used: Deferred feedback
    ", "redirecturl": "/", "exporttoxml": "Download this question in Moodle XML format", "comments": "html from comments api", "extrapreviewelements": "
    callback to get html from plugins need to show info in preview
    " } }}

    {{{questionicon}}}

    {{questiontitle}}

    {{#versiontitle}} {{>core_question/question_version_info}} {{/versiontitle}}

    {{#newerversion}}
    {{{newerversion}}}
    {{/newerversion}}
    ID number {{questionidumber}}

    {{{question}}}
    {{^redirect}} {{/redirect}} {{#redirect}} {{#str}} closepreview, question{{/str}} {{/redirect}}

    {{#comments}}
    {{{comments}}}
    {{{options}}} {{/comments}} {{^comments}} {{{options}}} {{/comments}} {{{techinfo}}} {{#extrapreviewelements}} {{{extrapreviewelements}}} {{/extrapreviewelements}} PK\ 5bank/previewquestion/templates/restartbutton.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_previewquestion/restartbutton A button to restart the preview. Example context (json): { } }} PK\^LY5Y5 bank/previewquestion/preview.phpnu[. /** * This page displays a preview of a question * * The preview uses the option settings from the activity within which the question * is previewed or the default settings if no activity is specified. The question session * information is stored in the session as an array of subsequent states rather * than in the database. * * @package qbank_previewquestion * @copyright Alex Smith {@link http://maths.york.ac.uk/serving_maths} * @author 2021 Safat Shahin and numerous contributors. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir . '/questionlib.php'); use \core\notification; use qbank_previewquestion\form\preview_options_form; use qbank_previewquestion\question_preview_options; use qbank_previewquestion\helper; use core_question\local\bank\version_options; /** * The maximum number of variants previewable. If there are more variants than this for a question * then we only allow the selection of the first x variants. * * @var integer */ define('QUESTION_PREVIEW_MAX_VARIANTS', 100); \core_question\local\bank\helper::require_plugin_enabled('qbank_previewquestion'); // Get and validate question id. $id = required_param('id', PARAM_INT); $returnurl = optional_param('returnurl', null, PARAM_LOCALURL); $restartversion = optional_param('restartversion', question_preview_options::ALWAYS_LATEST, PARAM_INT); $question = question_bank::load_question($id); if ($returnurl) { $returnurl = new moodle_url($returnurl); } // Were we given a particular context to run the question in? // This affects things like filter settings, or forced theme or language. if ($cmid = optional_param('cmid', 0, PARAM_INT)) { $cm = get_coursemodule_from_id(false, $cmid); require_login($cm->course, false, $cm); $context = context_module::instance($cmid); } else { require_login(); $category = $DB->get_record('question_categories', ['id' => $question->category], '*', MUST_EXIST); $context = context::instance_by_id($category->contextid); $PAGE->set_context($context); // Note that in the other cases, require_login will set the correct page context. } question_require_capability_on($question, 'use'); $PAGE->set_pagelayout('popup'); // Get and validate display options. $maxvariant = min($question->get_num_variants(), QUESTION_PREVIEW_MAX_VARIANTS); $options = new question_preview_options($question); $options->load_user_defaults(); $options->set_from_request(); $options->versioninfo = false; $PAGE->set_url(helper::question_preview_url($id, $options->behaviour, $options->maxmark, $options, $options->variant, $context, null, $restartversion)); // Get and validate existing preview, or start a new one. $previewid = optional_param('previewid', 0, PARAM_INT); if ($previewid) { try { $quba = question_engine::load_questions_usage_by_activity($previewid); } catch (Exception $e) { // This may not seem like the right error message to display, but // actually from the user point of view, it makes sense. throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', helper::question_preview_url($question->id, $options->behaviour, $options->maxmark, $options, $options->variant, $context, null, $restartversion), null, $e); } if ($quba->get_owning_context()->instanceid != $USER->id) { throw new moodle_exception('notyourpreview', 'question'); } $slot = $quba->get_first_question_number(); $usedquestion = $quba->get_question($slot, false); if ($usedquestion->id != $question->id) { throw new moodle_exception('questionidmismatch', 'question'); } $question = $usedquestion; $options->variant = $quba->get_variant($slot); } else { $quba = question_engine::make_questions_usage_by_activity( 'core_question_preview', context_user::instance($USER->id)); $quba->set_preferred_behaviour($options->behaviour); $slot = $quba->add_question($question, $options->maxmark); if ($options->variant) { $options->variant = min($maxvariant, max(1, $options->variant)); } else { $options->variant = rand(1, $maxvariant); } $quba->start_question($slot, $options->variant); $transaction = $DB->start_delegated_transaction(); question_engine::save_questions_usage_by_activity($quba); $transaction->allow_commit(); } $options->behaviour = $quba->get_preferred_behaviour(); $options->maxmark = $quba->get_question_max_mark($slot); // Load the question versions and convert to a simple array for select menu. $versions = version_options::get_version_menu_options($question->id); $versionids = helper::load_versions($question->questionbankentryid); // Create the settings form, and initialise the fields. $optionsform = new preview_options_form(helper::question_preview_form_url($question->id, $context, $previewid, $returnurl), [ 'quba' => $quba, 'maxvariant' => $maxvariant, 'versions' => $versions, 'restartversion' => $restartversion, ]); $optionsform->set_data($options); // Process change of settings, if that was requested. if ($newoptions = $optionsform->get_submitted_data()) { // Set user preferences. $options->save_user_preview_options($newoptions); if (!isset($newoptions->variant)) { $newoptions->variant = $options->variant; } $questionid = helper::get_restart_id($versionids, $restartversion); if (isset($newoptions->saverestart)) { helper::restart_preview($previewid, $questionid, $newoptions, $context, $returnurl, $newoptions->restartversion); } } // Prepare a URL that is used in various places. $actionurl = helper::question_preview_action_url($question->id, $quba->get_id(), $options, $context, $returnurl, $restartversion); // Process any actions from the buttons at the bottom of the form. if (data_submitted() && confirm_sesskey()) { try { if (optional_param('restart', false, PARAM_BOOL)) { $questionid = helper::get_restart_id($versionids, $restartversion); helper::restart_preview($previewid, $questionid, $options, $context, $returnurl, $restartversion); } else if (optional_param('fill', null, PARAM_BOOL)) { $correctresponse = $quba->get_correct_response($slot); if (!is_null($correctresponse)) { $quba->process_action($slot, $correctresponse); $transaction = $DB->start_delegated_transaction(); question_engine::save_questions_usage_by_activity($quba); $transaction->allow_commit(); } redirect($actionurl); } else if (optional_param('finish', null, PARAM_BOOL)) { $quba->process_all_actions(); $quba->finish_all_questions(); $transaction = $DB->start_delegated_transaction(); question_engine::save_questions_usage_by_activity($quba); $transaction->allow_commit(); redirect($actionurl); } else { $quba->process_all_actions(); $transaction = $DB->start_delegated_transaction(); question_engine::save_questions_usage_by_activity($quba); $transaction->allow_commit(); $mdlscrollto = optional_param('mdlscrollto', '', PARAM_RAW); if ($mdlscrollto !== '') { $actionurl->param('mdlscrollto', (int) $mdlscrollto); } redirect($actionurl); } } catch (question_out_of_sequence_exception $e) { throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', $actionurl); } catch (Exception $e) { // This sucks, if we display our own custom error message, there is no way // to display the original stack trace. $debuginfo = ''; if (!empty($e->debuginfo)) { $debuginfo = $e->debuginfo; } throw new moodle_exception('errorprocessingresponses', 'question', $actionurl, $e->getMessage(), $debuginfo); } } if ($question->length) { $displaynumber = '1'; } else { $displaynumber = 'i'; } $restartdisabled = []; $finishdisabled = []; $filldisabled = []; if ($quba->get_question_state($slot)->is_finished()) { $finishdisabled = ['disabled' => 'disabled']; $filldisabled = ['disabled' => 'disabled']; } // If question type cannot give us a correct response, disable this button. if (is_null($quba->get_correct_response($slot))) { $filldisabled = ['disabled' => 'disabled']; } if (!$previewid) { $restartdisabled = ['disabled' => 'disabled']; } // Prepare technical info to be output. $qa = $quba->get_question_attempt($slot); $technical = []; $technical[] = get_string('behaviourbeingused', 'question', question_engine::get_behaviour_name($qa->get_behaviour_name())); $technical[] = get_string('technicalinfominfraction', 'question', $qa->get_min_fraction()); $technical[] = get_string('technicalinfomaxfraction', 'question', $qa->get_max_fraction()); $technical[] = get_string('technicalinfovariant', 'question', $qa->get_variant()); $technical[] = get_string('technicalinfoquestionsummary', 'question', s($qa->get_question_summary())); $technical[] = get_string('technicalinforightsummary', 'question', s($qa->get_right_answer_summary())); $technical[] = get_string('technicalinforesponsesummary', 'question', s($qa->get_response_summary())); $technical[] = get_string('technicalinfostate', 'question', '' . $qa->get_state()); // Start output. $title = get_string('previewquestion', 'question', format_string($question->name)); $headtags = question_engine::initialise_js() . $quba->render_question_head_html($slot); $PAGE->set_title($title); $PAGE->set_heading($title); echo $OUTPUT->header(); $previewdata = []; $previewdata['questionicon'] = print_question_icon($question); $previewdata['questionidumber'] = $question->idnumber; $previewdata['questiontitle'] = $question->name; $versioninfo = new \core_question\output\question_version_info($question); $previewdata['versiontitle'] = $versioninfo->export_for_template($OUTPUT); if ($versioninfo->version !== $versioninfo->latestversion) { if ($restartversion == question_preview_options::ALWAYS_LATEST) { $newerversionparams = (object) [ 'currentversion' => $question->version, 'latestversion' => max($versionids), 'restartbutton' => $OUTPUT->render_from_template('qbank_previewquestion/restartbutton', []), ]; $newversionurl = clone $actionurl; $newversionurl->param('restart', 1); $previewdata['newerversionurl'] = $newversionurl; $previewdata['newerversion'] = get_string('newerversion', 'qbank_previewquestion', $newerversionparams); } } $previewdata['actionurl'] = $actionurl; $previewdata['session'] = sesskey(); $previewdata['slot'] = $slot; // Output of the question. $previewdata['question'] = $quba->render_question($slot, $options, $displaynumber); $previewdata['restartdisabled'] = html_writer::attributes($restartdisabled); $previewdata['finishdisabled'] = html_writer::attributes($finishdisabled); $previewdata['filldisabled'] = html_writer::attributes($filldisabled); // Output the technical info. $previewdata['techinfo'] = print_collapsible_region_start('', 'techinfo', get_string('technicalinfo', 'question'), 'core_question_preview_techinfo_collapsed', true, true, $OUTPUT->help_icon('technicalinfo', 'question')); foreach ($technical as $info) { $previewdata['techinfo'] .= html_writer::tag('p', $info, ['class' => 'notifytiny']); } $previewdata['techinfo'] .= print_collapsible_region_end(true); // Display the settings form. $previewdata['options'] = $optionsform->render(); list($comment, $extraelements) = helper::get_preview_extra_elements($question, $COURSE->id); if (!empty($comment)) { $previewdata['comments'] = $comment; } if (!empty($extraelements)) { $elements = []; foreach ($extraelements as $extraelement) { $element = new stdClass(); $element->extrapreviewelements = $extraelement; $elements[] = $element; } $previewdata['extrapreviewelements'] = $elements; } $previewdata['redirect'] = false; if (!is_null($returnurl)) { $previewdata['redirect'] = true; $previewdata['redirecturl'] = $returnurl; } $closeurl = new moodle_url('/question/edit.php', ['cmid' => $context->instanceid]); echo $PAGE->get_renderer('qbank_previewquestion')->render_preview_page($previewdata); // Log the preview of this question. $event = \core\event\question_viewed::create_from_question_instance($question, $context); $event->trigger(); $PAGE->requires->js_call_amd('qbank_previewquestion/preview', 'init', [$previewdata['redirect'], $closeurl->__toString()]); echo $OUTPUT->footer(); PK\!ii'bank/previewquestion/amd/src/preview.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Javascript for preview. * * @module qbank_previewquestion/preview * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {initForm as initQuestionEngineForm} from 'core_question/question_engine'; /** * Set up the actions. * * @method init * @param {bool} redirect Redirect. * @param {string} url url to redirect. */ export const init = (redirect, url) => { if (!redirect) { const closeButton = document.getElementById('close-previewquestion-page'); closeButton.addEventListener('click', (e) => { e.preventDefault(); if (window.opener === null) { location.href = url; } else { window.close(); } }); } // Set up the form to be displayed. initQuestionEngineForm('#responseform'); }; PK\U )-bank/previewquestion/amd/build/preview.min.jsnu[define("qbank_previewquestion/preview",["exports","core_question/question_engine"],(function(_exports,_question_engine){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;_exports.init=(redirect,url)=>{if(!redirect){document.getElementById("close-previewquestion-page").addEventListener("click",(e=>{e.preventDefault(),null===window.opener?location.href=url:window.close()}))}(0,_question_engine.initForm)("#responseform")}})); //# sourceMappingURL=preview.min.js.mapPK\4%1bank/previewquestion/amd/build/preview.min.js.mapnu[{"version":3,"file":"preview.min.js","sources":["../src/preview.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for preview.\n *\n * @module qbank_previewquestion/preview\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {initForm as initQuestionEngineForm} from 'core_question/question_engine';\n\n/**\n * Set up the actions.\n *\n * @method init\n * @param {bool} redirect Redirect.\n * @param {string} url url to redirect.\n */\nexport const init = (redirect, url) => {\n if (!redirect) {\n const closeButton = document.getElementById('close-previewquestion-page');\n closeButton.addEventListener('click', (e) => {\n e.preventDefault();\n if (window.opener === null) {\n location.href = url;\n } else {\n window.close();\n }\n });\n }\n // Set up the form to be displayed.\n initQuestionEngineForm('#responseform');\n};\n"],"names":["redirect","url","document","getElementById","addEventListener","e","preventDefault","window","opener","location","href","close"],"mappings":"mNAiCoB,CAACA,SAAUC,WACtBD,SAAU,CACSE,SAASC,eAAe,8BAChCC,iBAAiB,SAAUC,IACnCA,EAAEC,iBACoB,OAAlBC,OAAOC,OACPC,SAASC,KAAOT,IAEhBM,OAAOI,yCAKI"}PK\P + +8bank/previewquestion/tests/qbank_preview_helper_test.phpnu[. namespace qbank_previewquestion; use context_course; use moodle_url; use core\plugininfo\qbank; use question_bank; use question_engine; use stdClass; /** * Helper tests for question preview. * * @package qbank_previewquestion * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \qbank_previewquestion\helper */ final class qbank_preview_helper_test extends \advanced_testcase { /** * @var bool|\context|\context_course $context */ public $context; /** * @var object $questiondata; */ public $questiondata; /** * @var \question_usage_by_activity $quba */ public $quba; /** * @var question_preview_options $options */ public $options; /** * @var \moodle_url $returnurl */ public $returnurl; /** * Test set up. * * This is executed before running any test in this file. */ public function setUp(): void { global $USER; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course. $course = $generator->create_course(); $qbank = $generator->create_module('qbank', ['course' => $course->id]); $qbankcontext = \context_module::instance($qbank->cmid); $this->context = $qbankcontext; // Create a question in the default category. $contexts = new \core_question\local\bank\question_edit_contexts($qbankcontext); $cat = question_get_default_category($contexts->lowest()->id, true); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); $this->quba = question_engine::make_questions_usage_by_activity('core_question_preview', \context_user::instance($USER->id)); $this->options = new question_preview_options($this->questiondata); $this->options->load_user_defaults(); $this->options->set_from_request(); $this->returnurl = new moodle_url('/question/edit.php'); } /** * Test the preview action url from the helper class. * * @covers ::question_preview_action_url */ public function test_question_preview_action_url(): void { $actionurl = helper::question_preview_action_url($this->questiondata->id, $this->quba->get_id(), $this->options, $this->context, $this->returnurl, question_preview_options::ALWAYS_LATEST); $params = [ 'id' => $this->questiondata->id, 'previewid' => $this->quba->get_id(), 'returnurl' => $this->returnurl, 'cmid' => $this->context->instanceid, 'restartversion' => question_preview_options::ALWAYS_LATEST, ]; $params = array_merge($params, $this->options->get_url_params()); $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $actionurl); } /** * Test the preview action url from the helper class when no restartversion is passed. * * @covers ::question_preview_action_url */ public function test_question_preview_action_url_no_restartversion(): void { $actionurl = helper::question_preview_action_url($this->questiondata->id, $this->quba->get_id(), $this->options, $this->context, $this->returnurl); $params = [ 'id' => $this->questiondata->id, 'previewid' => $this->quba->get_id(), 'returnurl' => $this->returnurl, 'cmid' => $this->context->instanceid, ]; $params = array_merge($params, $this->options->get_url_params()); $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $actionurl); } /** * Test the preview form url from the helper class. * * @covers ::question_preview_form_url */ public function test_question_preview_form_url(): void { $formurl = helper::question_preview_form_url( $this->questiondata->id, $this->context, $this->quba->get_id(), $this->returnurl); $params = [ 'id' => $this->questiondata->id, 'previewid' => $this->quba->get_id(), 'returnurl' => $this->returnurl, 'cmid' => $this->context->instanceid, ]; $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $formurl); } /** * Test the preview url from the helper class. * * @covers ::question_preview_url */ public function test_question_preview_url(): void { $previewurl = helper::question_preview_url($this->questiondata->id, $this->options->behaviour, $this->options->maxmark, $this->options, $this->options->variant, $this->context, null, question_preview_options::ALWAYS_LATEST); $params = [ 'id' => $this->questiondata->id, 'behaviour' => $this->options->behaviour, 'maxmark' => $this->options->maxmark, 'cmid' => $this->context->instanceid, 'restartversion' => question_preview_options::ALWAYS_LATEST, ]; // Extra params for options. $params['correctness'] = $this->options->correctness; $params['marks'] = $this->options->marks; $params['markdp'] = $this->options->markdp; $params['feedback'] = (bool) $this->options->feedback; $params['generalfeedback'] = (bool) $this->options->generalfeedback; $params['rightanswer'] = (bool) $this->options->rightanswer; $params['history'] = (bool) $this->options->history; $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $previewurl); } /** * Test the preview url from the helper class. * * @covers ::question_preview_url */ public function test_question_preview_url_no_restartversion(): void { $previewurl = helper::question_preview_url($this->questiondata->id, $this->options->behaviour, $this->options->maxmark, $this->options, $this->options->variant, $this->context, null); $params = [ 'id' => $this->questiondata->id, 'behaviour' => $this->options->behaviour, 'maxmark' => $this->options->maxmark, 'cmid' => $this->context->instanceid, ]; // Extra params for options. $params['correctness'] = $this->options->correctness; $params['marks'] = $this->options->marks; $params['markdp'] = $this->options->markdp; $params['feedback'] = (bool) $this->options->feedback; $params['generalfeedback'] = (bool) $this->options->generalfeedback; $params['rightanswer'] = (bool) $this->options->rightanswer; $params['history'] = (bool) $this->options->history; $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $previewurl); } /** * Test the preview comment callback if available. * * @covers ::get_preview_extra_elements */ public function test_get_preview_extra_elements(): void { global $PAGE; $PAGE->set_url('/'); $question = \question_bank::load_question($this->questiondata->id); list($comment, $extraelements) = helper::get_preview_extra_elements($question, $this->context->instanceid); if (qbank::is_plugin_enabled('qbank_comment')) { $this->assertStringContainsString("comment-area", $comment); } else { $this->assertEquals('', $comment); } } /** * Test method load_versions(). * * @covers ::load_versions */ public function test_load_versions(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcat1 = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']); $questiongenerated = $generator->create_question('description', null, ['name' => 'q1', 'category' => $qcat1->id]); $qtypeobj = question_bank::get_qtype($questiongenerated->qtype); $question = question_bank::load_question($questiongenerated->id); $versionids = helper::load_versions($question->questionbankentryid); $this->assertEquals([ $question->id => 1, ], $versionids); $fromform = new stdClass(); $fromform->name = 'Name edited'; $fromform->category = $qcat1->id; $questiontwo = $qtypeobj->save_question($questiongenerated, $fromform); $versionids = helper::load_versions($question->questionbankentryid); $this->assertSame([ $question->id => 1, $questiontwo->id => 2, ], $versionids); } /** * Test method get_restart_id(). * * This should return the value of the specified version number, or the latest version if ALWAYS_LATEST is passed. * * @covers ::get_restart_id * @return void */ public function test_get_restart_id(): void { $versions = [ 100 => 1, 200 => 2, 300 => 3 ]; $this->assertEquals(100, helper::get_restart_id($versions, 1)); $this->assertEquals(200, helper::get_restart_id($versions, 2)); $this->assertEquals(300, helper::get_restart_id($versions, 3)); $this->assertEquals(300, helper::get_restart_id($versions, question_preview_options::ALWAYS_LATEST)); $this->assertNull(helper::get_restart_id($versions, 4)); $this->assertNull(helper::get_restart_id([], 1)); $this->assertNull(helper::get_restart_id([], question_preview_options::ALWAYS_LATEST)); } } PK\Қ3b9bank/previewquestion/tests/behat/preview_question.featurenu[@qbank @qbank_previewquestion Feature: A teacher can preview questions in the question bank In order to ensure the questions are properly created As a teacher I need to preview the questions Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "questions" exist: | questioncategory | qtype | name | | Default for Test quiz | numerical | Test question to be previewed | And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1" When I choose "Preview" action for "Test question to be previewed" in the question bank Scenario: Question preview shows the question and other information Then the state of "What is pi to two d.p.?" question is shown as "Not yet answered" And I should see "(latest)" And I should see "Marked out of 1.00" And I should see "Technical information" And I should see "Display options" And I should see "Preview options" And I should see "Comments" And I click on "Comments" "link" And I should see "Save comment" And I should see "ID number" And "Numerical" "icon" should exist And I should see "Version" And I click on "Preview options" "link" And I should see "These settings are for testing the question. The options you select only affect the preview." And I should see "Question version" Scenario: Preview lets the teacher see what happens when an answer is saved When I set the field "Answer:" to "1" And I press "Save" Then the state of "What is pi to two d.p.?" question is shown as "Answer saved" Scenario: Preview lets the teacher see what happens when an answer is submitted When I set the field "Answer:" to "3.14" And I press "Submit and finish" Then the state of "What is pi to two d.p.?" question is shown as "Correct" Scenario: Preview lets the teacher see what happens with different review options Given I set the field "Answer:" to "3.14" And I press "Submit and finish" And I press "Display options" When I set the field "Whether correct" to "Not shown" And I set the field "Decimal places in grades" to "5" And I press "Update display options" And I set the field "Answer:" to "3.14" And I press "Submit and finish" Then the state of "What is pi to two d.p.?" question is shown as "Complete" And I should see "1.00000" Scenario: Preview lets the teacher see what happens with different behaviours When I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I set the field "Marked out of" to "3" And I press "Save preview options and start again" And I set the field "Answer:" to "3.1" And I press "Check" Then the state of "What is pi to two d.p.?" question is shown as "Incorrect" And I should see "Mark 0.00 out of 3.00" And I should see "Not accurate enough." Scenario: Preview lets the teacher "Start again" while previewing Given I set the field "Answer:" to "1" And I press "Submit and finish" When I press "Start again" Then the state of "What is pi to two d.p.?" question is shown as "Not yet answered" Scenario: Preview lets the teacher "Fill in correct response" while previewing When I press "Fill in correct responses" Then the field "Answer:" matches value "3.14" Scenario: Preview a question with very small grade When I press "Preview options" And I set the field "Marked out of" to "0.00000123456789" And I press "Save preview options and start again" Then the field "Marked out of" matches value "0.00000123456789" Scenario: Question version is updated when edited and teacher can change question version Given I should see "Version 1" And I press "Close preview" And I choose "Edit question" action for "Test question to be previewed" in the question bank And I set the field "Question name" to "New version" And I set the field "Question text" to "New text version" And I click on "submitbutton" "button" And I choose "Preview" action for "New version" in the question bank When I expand all fieldsets And I should see "Version 2 (latest)" And I should see "New version" And I should see "New text version" And I should not see "Test question to be previewed" And I should not see "Version 1" And I should see "v1" in the "Question version" "select" And I should see "v2" in the "Question version" "select" And I set the field "Question version" to "v1" And I press "Save preview options and start again" Then I should see "Version 1" And I should not see "Version 2 (latest)" Scenario: The preview always uses the latest question version by default. Given the following "core_question > updated questions" exist: | questioncategory | question | questiontext | | Default for Test quiz | Test question to be previewed | Question version 2 | And I should see "Version 1 (latest)" And I expand all fieldsets And the field "Question version" matches value "Always latest" And I set the field "Answer:" to "3.14" And I press "Submit and finish" And I should see "Version 1" When I press "Start again" Then I should not see "Version 1" And I should see "Version 2 (latest)" Scenario: Detect a newer version during always latest preview and offer to switch to the latest Given I should not see "This preview is using version 1" And the following "core_question > updated questions" exist: | questioncategory | question | questiontext | | Default for Test quiz | Test question to be previewed | Question version 2 | And I should see "Version 1 (latest)" And I set the field "Answer:" to "3.14" When I press "Submit and finish" And I should see "This preview is using an older version of the question." And I press "Use latest version" Then I should not see "Version 1" And I should see "Version 2 (latest)" Scenario: Previewing from the question history will not always show the latest version Given I press "Close preview" And the following "core_question > updated questions" exist: | questioncategory | question | questiontext | | Default for Test quiz | Test question to be previewed | Question version 2 | And I choose "History" action for "Test question to be previewed" in the question bank And I choose "Preview" action for "Test question to be previewed" in the question bank And I should see "Version 1 (of 2)" And I expand all fieldsets And the field "Question version" matches value "1" And I set the field "Answer:" to "3.14" And I press "Submit and finish" And I should see "Version 1 (of 2)" And I should not see "The latest version is 2." And the following "core_question > updated questions" exist: | questioncategory | question | questiontext | | Default for Test quiz | Test question to be previewed | Question version 3 | When I press "Start again" Then I should see "Version 1 (of 3)" And I should not see "Version 3 (latest)" Scenario: Question preview can be closed And I press "Close preview" Then I should not see "(latest)" And I should see "Test quiz" PK\ C؂ @bank/previewquestion/tests/behat/preview_question_action.featurenu[@qbank @qbank_previewquestion Feature: Use the qbank plugin manager page for previewquestion In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | Scenario: Enable/disable previewquestion column from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Preview question" And I click on "Disable" "link" in the "Preview question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then the "Preview" item should not exist in the "Edit" action menu of the "First question" "table_row" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Preview question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And the "Preview" item should exist in the "Edit" action menu of the "First question" "table_row" Scenario: Enable/disable preview button from question edit form Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Preview question" And I click on "Disable" "link" in the "Preview question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I choose "Edit question" action for "First question" in the question bank Then I should not see "Preview" in the "region-main" "region" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Preview question" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I choose "Edit question" action for "First question" in the question bank And I should see "Preview" in the "region-main" "region" PK\8a a ,bank/statistics/lang/en/qbank_statistics.phpnu[. /** * Strings for component qbank_statistics, language 'en' * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'Question statistics'; $string['privacy:metadata'] = 'The Question statistics question bank plugin does not store any personal data.'; // Columns. $string['facility_index'] = 'Facility index'; $string['facility_index_help'] = 'The facility index gives the average mark (as a percentage) obtained on the question (all versions) in all quizzes where the question has been attempted. A higher value normally indicates an easier question.'; $string['discriminative_efficiency'] = 'Discriminative efficiency'; $string['discriminative_efficiency_help'] = 'Discriminative efficiency is a statistical estimate of how well the question assesses students, with a higher value being better. A particularly low value may indicate a problem with the question. A very difficult or easy question (with facility index close to 0% or 100%) can also lead to a low value.'; $string['discriminative_efficiency_link'] = 'mod/quiz/statistics'; $string['discrimination_index'] = 'Needs checking?'; $string['discrimination_index_help'] = 'A question is indicated as likely to need checking based on question statistics. For example, if students obtain a low score on the question but a high score on the whole quiz, or a high score on the question but a low score on the whole quiz, then there may be a problem with the question such as the wrong answer being set as correct. Statistics are not infallible though; this is just a hint that the question should be checked.'; // Text format. $string['verylikely'] = 'Very likely'; $string['likely'] = 'Likely'; $string['unlikely'] = 'Unlikely'; $string['na'] = 'N/A'; $string['emptyvalue'] = '-'; PK\g bbbank/statistics/version.phpnu[. /** * Question statistics * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_statistics'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\a =bank/statistics/classes/columns/discriminative_efficiency.phpnu[. namespace qbank_statistics\columns; use core_question\local\bank\column_base; /** * This column show the average discriminative efficiency for this question. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class discriminative_efficiency extends column_base { public function get_title(): string { return get_string('discriminative_efficiency', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('discriminative_efficiency', 'qbank_statistics'); } public function get_name(): string { return 'discriminative_efficiency'; } public function get_required_statistics_fields(): array { return ['discriminativeefficiency']; } protected function display_content($question, $rowclasses) { global $PAGE; $discriminativeefficiency = $this->qbank->get_aggregate_statistic($question->id, 'discriminativeefficiency'); echo $PAGE->get_renderer('qbank_statistics')->render_discriminative_efficiency($discriminativeefficiency); } public function display_preview(\stdClass $question, string $rowclasses): void { global $PAGE; $this->display_start($question, $rowclasses); echo $PAGE->get_renderer('qbank_statistics')->render_discriminative_efficiency(25); $this->display_end($question, $rowclasses);; } public function get_extra_classes(): array { return ['pe-3']; } } PK\Ai2q  8bank/statistics/classes/columns/discrimination_index.phpnu[. namespace qbank_statistics\columns; use core_question\local\bank\column_base; /** * This columns shows a message about whether this question is OK or needs revision. * * This is based on the average discrimination index. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class discrimination_index extends column_base { public function get_title(): string { return get_string('discrimination_index', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('discrimination_index', 'qbank_statistics'); } public function get_name(): string { return 'discrimination_index'; } public function get_required_statistics_fields(): array { return ['discriminationindex']; } protected function display_content($question, $rowclasses) { global $PAGE; $discriminationindex = $this->qbank->get_aggregate_statistic($question->id, 'discriminationindex'); echo $PAGE->get_renderer('qbank_statistics')->render_discrimination_index($discriminationindex); } public function display_preview(\stdClass $question, string $rowclasses): void { global $PAGE; $this->display_start($question, $rowclasses); echo $PAGE->get_renderer('qbank_statistics')->render_discrimination_index(50.00); $this->display_end($question, $rowclasses);; } public function get_extra_classes(): array { return ['pe-3']; } } PK\h"2bank/statistics/classes/columns/facility_index.phpnu[. namespace qbank_statistics\columns; use core_question\local\bank\column_base; use qbank_statistics\helper; /** * This column show the average facility index for this question. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class facility_index extends column_base { public function get_title(): string { return get_string('facility_index', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('facility_index', 'qbank_statistics'); } public function get_name(): string { return 'facility_index'; } public function get_required_statistics_fields(): array { return ['facility']; } protected function display_content($question, $rowclasses) { global $PAGE; $facility = $this->qbank->get_aggregate_statistic($question->id, 'facility'); echo $PAGE->get_renderer('qbank_statistics')->render_facility_index($facility); } public function display_preview(\stdClass $question, string $rowclasses): void { global $PAGE; $this->display_start($question, $rowclasses); echo $PAGE->get_renderer('qbank_statistics')->render_facility_index(0.75); $this->display_end($question, $rowclasses);; } public function get_extra_classes(): array { return ['pe-3']; } } PK\ʰ,bank/statistics/classes/privacy/provider.phpnu[. namespace qbank_statistics\privacy; /** * Privacy Subsystem implementation. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Explain why qbank_statistics store no data * * @return string reason why this plugin store no data */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\s'P"bank/statistics/classes/helper.phpnu[. namespace qbank_statistics; /** * Helper for statistics * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * @var float Threshold to determine 'Needs checking?' */ private const NEED_FOR_REVISION_LOWER_THRESHOLD = 30; /** * @var float Threshold to determine 'Needs checking?' */ private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50; /** * @deprecated since Moodle 4.3 please use the method from statistics_bulk_loader. */ #[\core\attribute\deprecated( 'statistics_bulk_loader or get_required_statistics_fields', since: '4.3', mdl: 'MDL-75576', final: true )] public static function calculate_average_question_facility(int $questionid): ?float { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 please use the method from statistics_bulk_loader. */ #[\core\attribute\deprecated( 'statistics_bulk_loader or get_required_statistics_fields', since: '4.3', mdl: 'MDL-75576', final: true )] public static function calculate_average_question_discriminative_efficiency(int $questionid): ?float { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 please use the method from statistics_bulk_loader. */ #[\core\attribute\deprecated( 'statistics_bulk_loader or get_required_statistics_fields', since: '4.3', mdl: 'MDL-75576', final: true )] public static function calculate_average_question_discrimination_index(int $questionid): ?float { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Format a number to a localised percentage with specified decimal points. * * @param float|null $number The number being formatted * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100 * @param int $decimals Sets the number of decimal points * @return string * @throws \coding_exception */ public static function format_percentage(?float $number, bool $fraction = true, int $decimals = 2): string { if (is_null($number)) { return get_string('na', 'qbank_statistics'); } $coefficient = $fraction ? 100 : 1; return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals)); } /** * Format discrimination index (Needs checking?). * * @param float|null $value stats value * @return array */ public static function format_discrimination_index(?float $value): array { if (is_null($value)) { $content = get_string('emptyvalue', 'qbank_statistics'); $classes = ''; } else if ($value < self::NEED_FOR_REVISION_LOWER_THRESHOLD) { $content = get_string('verylikely', 'qbank_statistics'); $classes = 'alert-danger'; } else if ($value < self::NEED_FOR_REVISION_UPPER_THRESHOLD) { $content = get_string('likely', 'qbank_statistics'); $classes = 'alert-warning'; } else { $content = get_string('unlikely', 'qbank_statistics'); $classes = 'alert-success'; } return [$content, $classes]; } } PK\~6I +bank/statistics/classes/output/renderer.phpnu[. namespace qbank_statistics\output; use qbank_statistics\helper; /** * Description * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render facility index column. * * @param float|null $facility facility index * @return string */ public function render_facility_index(?float $facility): string { $displaydata['facility_index'] = helper::format_percentage($facility); return $this->render_from_template('qbank_statistics/facility_index', $displaydata); } /** * Render discriminative_efficiency column. * * @param float|null $discriminativeefficiency discriminative efficiency * @return string */ public function render_discriminative_efficiency(?float $discriminativeefficiency): string { $displaydata['discriminative_efficiency'] = helper::format_percentage($discriminativeefficiency, false); return $this->render_from_template('qbank_statistics/discriminative_efficiency', $displaydata); } /** * Render discrimination index column. * * @param float|null $discriminationindex discrimination index * @return string */ public function render_discrimination_index(?float $discriminationindex): string { list($content, $classes) = helper::format_discrimination_index($discriminationindex); $displaydata['discrimination_index'] = $content; $displaydata['classes'] = $classes; return $this->render_from_template('qbank_statistics/discrimination_index', $displaydata); } } PK\0*mm*bank/statistics/classes/plugin_feature.phpnu[. namespace qbank_statistics; use qbank_statistics\columns\facility_index; use qbank_statistics\columns\discrimination_index; use qbank_statistics\columns\discriminative_efficiency; /** * Class plugin_features is the entrypoint for the columns. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { /** * This method will return the array of objects to be rendered as a prt of question bank columns/actions. * * @param view $qbank * @return array */ public function get_question_columns($qbank): array { return [ new discrimination_index($qbank), new facility_index($qbank), new discriminative_efficiency($qbank) ]; } } PK\9-<bank/statistics/templates/discriminative_efficiency.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_statistics/discriminative_efficiency Example context (json): { "statistics": [ { "discriminative_efficiency": "html content" } ] } }}
    {{{discriminative_efficiency}}}
    PK\P1bank/statistics/templates/facility_index.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_statistics/facility_index Example context (json): { "statistics": [ { "facility_index": "html content" } ] } }}
    {{{facility_index}}}
    PK\/9 m7bank/statistics/templates/discrimination_index.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qbank_statistics/discrimination_index Example context (json): { "statistics": [ { "discriminative_efficiency": "html content" } ] } }}
    {{{discrimination_index}}}
    PK\9sS$S$5bank/statistics/tests/behat/statistics_values.featurenu[@qbank @qbank_statistics Feature: Show statistics in question bank Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "users" exist: | username | firstname | lastname | email | | student1 | user1 | Student1 | student1@example.com | | student2 | user2 | Student2 | student2@example.com | | student3 | user3 | Student3 | student3@example.com | | student4 | user4 | Student4 | student4@example.com | | student5 | user5 | student5 | student5@example.com | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | student2 | C1 | student | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Qbank 1 | truefalse | TF1 | First question | | Default for Qbank 1 | truefalse | TF2 | Second question | | Default for Qbank 1 | truefalse | TF3 | Third question | | Default for Qbank 1 | truefalse | TF4 | Fourth question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 1.0 | | TF2 | 1 | 1.0 | | TF3 | 1 | 1.0 | | TF4 | 1 | 1.0 | And quiz "Quiz 2" contains the following questions: | question | page | maxmark | | TF2 | 1 | 1.0 | | TF3 | 1 | 1.0 | And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | False | | 3 | False | | 4 | False | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | True | | 3 | True | | 4 | True | And user "student3" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | | 3 | False | | 4 | True | And user "student4" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | True | | 3 | True | | 4 | False | Scenario: View facility index in question bank Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And I run pending statistics recalculation tasks When I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" Then I should see "50.00%" in the "TF1" "table_row" And I should see "75.00%" in the "TF2" "table_row" And I should see "75.00%" in the "TF3" "table_row" And I should see "50.00%" in the "TF4" "table_row" Scenario: View discriminative efficiency in question bank Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | False | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And I run pending statistics recalculation tasks When I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" Then I should see "50.00%" in the "TF1" "table_row" And I should see "75.00%" in the "TF2" "table_row" And I should see "75.00%" in the "TF3" "table_row" And I should see "50.00%" in the "TF4" "table_row" Scenario: View discrimination index in question bank, good questions Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | False | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And I run pending statistics recalculation tasks When I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" Then I should see "Likely" in the "TF1" "table_row" And I should see "Unlikely" in the "TF2" "table_row" And I should see "Unlikely" in the "TF3" "table_row" And I should see "Likely" in the "TF4" "table_row" And I should see "Likely" in the ".alert-warning" "css_element" And I should see "Unlikely" in the ".alert-success" "css_element" Scenario: View discrimination index in question bank, bad questions Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | True | And user "student3" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | False | And I run pending statistics recalculation tasks When I am on the "Qbank 1" "core_question > question bank" page logged in as "admin" Then I should see "Likely" in the "TF1" "table_row" And I should see "Very likely" in the "TF2" "table_row" And I should see "Very likely" in the "TF3" "table_row" And I should see "Likely" in the "TF4" "table_row" And I should see "Very likely" in the ".alert-danger" "css_element" And I should see "Likely" in the ".alert-warning" "css_element" Scenario: View discrimination index in question bank for bad multichoice questions Given the following "courses" exist: | fullname | shortname | category | | Course 2 | C2 | 0 | And the following "course enrolments" exist: | user | course | role | | student1 | C2 | student | | student2 | C2 | student | | student3 | C2 | student | | student4 | C2 | student | | student5 | C2 | student | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 3 | C2 | quiz3 | And the following "questions" exist: | questioncategory | qtype | template | name | | Default for Quiz 3 | multichoice | one_of_four | MCA | | Default for Quiz 3 | multichoice | one_of_four | MCB | | Default for Quiz 3 | multichoice | one_of_four | MCC | | Default for Quiz 3 | multichoice | one_of_four | MCD | | Default for Quiz 3 | multichoice | one_of_four | MCE | And quiz "Quiz 3" contains the following questions: | question | page | maxmark | | MCA | 1 | 1.0 | | MCB | 1 | 1.0 | | MCC | 1 | 1.0 | | MCD | 1 | 1.0 | | MCE | 1 | 1.0 | # student1 answers all correctly And user "student1" has attempted "Quiz 3" with responses: | slot | response | | 1 | One | | 2 | One | | 3 | One | | 4 | One | | 5 | One | # student2 answers A and B correctly, C, D and E incorrectly And user "student2" has attempted "Quiz 3" with responses: | slot | response | | 1 | One | | 2 | One | | 3 | Two | | 4 | Two | | 5 | Two | # student3 answers A, D and E correctly, B and C incorrectly And user "student3" has attempted "Quiz 3" with responses: | slot | response | | 1 | One | | 2 | Two | | 3 | Two | | 4 | One | | 5 | One | # student4 answers A and D correctly, B, C and E incorrectly And user "student4" has attempted "Quiz 3" with responses: | slot | response | | 1 | One | | 2 | Two | | 3 | Two | | 4 | One | | 5 | Two | # student5 answers E correctly, B, C, D and A incorrectly And user "student5" has attempted "Quiz 3" with responses: | slot | response | | 1 | Two | | 2 | Two | | 3 | Two | | 4 | Two | | 5 | One | And I run pending statistics recalculation tasks # Confirm the "Needs checking?" column matches the expected values based on students' answers When I am on the "Quiz 3" "mod_quiz > question bank" page logged in as "admin" Then "Likely" "text" should exist in the "MCA" "table_row" And "Very likely" "text" should exist in the "MCB" "table_row" And "Unlikely" "text" should exist in the "MCC" "table_row" And "Likely" "text" should exist in the "MCD" "table_row" And "Very likely" "text" should exist in the "MCE" "table_row" PK\̙odd6bank/statistics/tests/behat/behat_qbank_statistics.phpnu[. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Behat steps for qbank_statistics * * @package qbank_statistics * @copyright 2023 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 behat_qbank_statistics extends behat_base { /** * Run pending recalculation tasks. * * This runs the recalcuation ad-hoc tasks. We need a special step for this * as the run time for these tasks is set to an hour in the future, so * "I run all ad-hoc tasks" will not trigger them. * * @Given /^I run pending statistics recalculation tasks$/ */ function i_run_pending_statistics_recalculation_tasks() { \quiz_statistics\tests\statistics_helper::run_pending_recalculation_tasks(); } }PK\F5bank/statistics/tests/behat/statistics_action.featurenu[@qbank @qbank_statistics Feature: Use the qbank plugin manager page for statistics In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz | truefalse | First question | Answer the first question | Scenario: Enable/disable statistics columns from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question statistics" And I click on "Disable" "link" in the "Question statistics" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "Facility index" And I should not see "Discriminative efficiency" And I should not see "Needs checking?" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question statistics" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should see "Facility index" And I should see "Discriminative efficiency" And I should see "Needs checking?" PK\U--2format/multianswer/lang/en/qformat_multianswer.phpnu[. /** * Strings for component 'qformat_multianswer', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_multianswer * @copyright 2010 Helen Foster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Embedded answers (Cloze)'; $string['pluginname_help'] = 'Embedded answers (Cloze) format enables the import of a passage of text with questions such as multiple-choice and short answer embedded within it.'; $string['pluginname_link'] = 'question/type/multianswer'; $string['privacy:metadata'] = 'The Embedded answers question plugin does not store any personal data.'; PK\4FFformat/multianswer/version.phpnu[. /** * Version information for the calculated question type. * * @package qformat_multianswer * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_multianswer'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\  /format/multianswer/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_multianswer. * * @package qformat_multianswer * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_multianswer\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_multianswer implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\d>ee;format/multianswer/tests/fixtures/multianswer-all-types.xmlnu[ All CLOZE types in one question MULTICHOICE

    Whenever we talk about Asterix we have to imagine the times {1:MULTICHOICE:200 B.C.#too early~=50 B.C.#Correct~50 A.C.#100 years too late~200 A.C.#too late}

    Asterix is best know to be a brave warrior. With his best friend {1:MULTICHOICE_H:Idefix#That's the dog of Obelix~=Obelix#His mate~Troubadix#He is the bard and sings horribly} he experiences many adventures.

    According to the story Asterix has never been traveled to {1:MULTICHOICE_V:America#Asterix and the great crossing~=Australia#That was too far away at that time~India#Asterix and the Magic Carpet}

    Many quotes in the comics are written in {1:MULTICHOICE_S:Greek#While the olympic games were known at the time and Asterix actually participated at the games, the greek language was not widely used.~Hebrew#The heroes haven't been to the middle east.~=Latin#Yes, many quotes are in latin.} language.

    Obelix owns a company. In some of the episodes he first delivers {1:MULTICHOICE_HS:Fish#No, that's the quarrel between to other citizens of the village.~=a Menhir#Yes, he fabricates these types of obelisks.~a wild boar#No, he actually would eat, rather than delivering them.} before he can head off with Asterix to new adventures.

    In the name of Obelix, Methusalix and Miraculix hides a roman number. Which one is it? {1:MULTICHOICE_VS:=59#Yes~51#No, that's LI~109#No, that's CIX~509#No, that's DIX}


    MULTIRESPONSE

    To the Simpsons family belong the following persons: {1:MULTIRESPONSE:=Marge#Correct, she is the mother~Fred#No, he's not the husband of Marge~=Lisa#She's the daughter of Marge~Anna#Nope, she is from Frozen}

    Marge is known for her {1:MULTIRESPONSE_H:%100%blue hair#yes, that's true~%0%being very greedy#Not that we know of~%50%being a soccer mom#Not in all aspects}

    Whenever the children {1:MULTIRESPONSE_S:=Bart#first born son~=Lisa#first born daughter~Marge#Nope, that is the mother~=Maggie#Last born child~Abraham#He is the grandfather} need the help of her parents, they try to be there for them.

    The Simpsons series is known to "forsee" the future. These events {1:MULTIRESPONSE_HS:%100%Trump becomes president#in "Bart to the future"~%100%20th Century Fox as a division of The Walt Disney Company.#in "When You Dish Upon a Star"~O.J Simpson was convicted as a murderer#no, that is made up~=The Titanic submarine implosion#In one episode in 2006} were a topic in an episode many years before they actually happened.


    NUMERICAL and SHORTANSWER

    Let's start easy. Water freezes at 0°C and {1:NUMERICAL:=32:0#Must be exact} Farenheit. But, do you know that water is boiling at {1:NUMERICAL:=212:0#Exact~%50%212:10#Close, you get at least half the points.} Fahrenkeit?

    Let's be fair: Anders Celsius, after whom a temperature scale was named was born in which year? {3:NUMERICAL:%100%1701:0#Exact match~%66%1701:20#Within the tolerance of 20 years.~%33%1701:50#Within the tolerance of 20 years.} This answer is worth 3 points when you know the exact year, 2 points when you are off by 20 years, and still 1 point when you are off by 50 years.

    Fahrnheit lived some years before Celcius. You may have noticed already that the spelling of the scientist was different every time he was mentioned. Please spell his name correctly: {1:SHORTANSWER:=Fahrenheit#That's correct.}

    Water consists of Hydrogen and Oxygen. Please write down the correct formula: {1:SHORTANSWER_C:=H2O#That is correct.~=H2O#That's correct (the 2 is usually subscripted but not possible during input).~h2O#Both Oxygen and Hydrogen are used with upper case letters.~H2o#Both Oxygen and Hydrogen are used with upper case letters.~h2o#Both Oxygen and Hydrogen are used with upper case letters.}

    The word used for a famous beverage made from fermented grain mash was derived from "uisge beatha" or "uisce beatha" which means "water of life". Under which name is the beverage worldwide known for? {1:SHORTANSWER:=Whisky#english~=Whiskey#mostly in Scottland and the US.~%50%Wiskey#That's not spelled correctly.~%25%Wisky#That's hardly recognizable.}

    ]]>
    To get to know more about Asterix and his friends, read the more than 40 comic books.

    ]]>
    0.3333333 0
    PK\77:format/multianswer/tests/fixtures/broken_multianswer_1.txtnu[Please select the fruits {1:MULTICHOICE:=Apple#Correct}PK\|ev;format/multianswer/tests/fixtures/questions.multianswer.txtnu[Match the following cities with the correct state: * San Francisco: {1:MULTICHOICE:=California#OK~Arizona#Wrong} * Tucson: {1:MULTICHOICE:California#Wrong~%100%Arizona#OK} * Los Angeles: {1:MULTICHOICE:=California#OK~Arizona#Wrong} * Phoenix: {1:MULTICHOICE:%0%California#Wrong~=Arizona#OK} The capital of France is {1:SHORTANSWER:%100%Paris#Congratulations! ~%50%Marseille#No, that is the second largest city in France (after Paris).~*#Wrong answer. The capital of France is Paris, of course.}. PK\11:format/multianswer/tests/fixtures/broken_multianswer_3.txtnu[What grade would you give it? {3:NUMERICAL:=zero}PK\'9:format/multianswer/tests/fixtures/broken_multianswer_4.txtnu[Please select the fruits.PK\nII:format/multianswer/tests/fixtures/broken_multianswer_2.txtnu[Please select the fruits {1:MULTICHOICE:Pear#Incorrect~%50%Apple#Correct}PK\&.gg3format/multianswer/tests/multianswerformat_test.phpnu[. namespace qformat_multianswer; use qformat_multianswer; use question_check_specified_fields_expectation; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/multianswer/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the Embedded answer (Cloze) question importer. * * @package qformat_multianswer * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class multianswerformat_test extends \question_testcase { public function test_import(): void { $lines = file(self::get_fixture_path(__NAMESPACE__, 'questions.multianswer.txt')); $importer = new qformat_multianswer(); $qs = $importer->readquestions($lines); $expectedq = (object) array( 'name' => 'Match the following cities with the correct state: * San Francisco: {#1} * ...', 'questiontext' => 'Match the following cities with the correct state: * San Francisco: {#1} * Tucson: {#2} * Los Angeles: {#3} * Phoenix: {#4} The capital of France is {#5}. ', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'multianswer', 'defaultmark' => 5, 'penalty' => 0.3333333, 'length' => 1, ); $this->assertEquals(1, count($qs)); $this->assert(new question_check_specified_fields_expectation($expectedq), $qs[0]); $this->assertEquals('multichoice', $qs[0]->options->questions[1]->qtype); $this->assertEquals('multichoice', $qs[0]->options->questions[2]->qtype); $this->assertEquals('multichoice', $qs[0]->options->questions[3]->qtype); $this->assertEquals('multichoice', $qs[0]->options->questions[4]->qtype); $this->assertEquals('shortanswer', $qs[0]->options->questions[5]->qtype); } public function test_read_brokencloze_1(): void { $lines = file(self::get_fixture_path(__NAMESPACE__, 'broken_multianswer_1.txt')); $importer = new qformat_multianswer(); // The importer echoes some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question', $output); $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output); $this->assertStringContainsString('This type of question requires at least 2 choices', $output); // No question have been imported. $this->assertCount(0, $questions); } public function test_read_brokencloze_2(): void { $lines = file(self::get_fixture_path(__NAMESPACE__, 'broken_multianswer_2.txt')); $importer = new qformat_multianswer(); // The importer echoes some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question', $output); $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output); $this->assertStringContainsString('One of the answers should have a score of 100% so it is possible to get full marks for this question.', $output); // No question have been imported. $this->assertCount(0, $questions); } public function test_read_brokencloze_3(): void { $lines = file(self::get_fixture_path(__NAMESPACE__, 'broken_multianswer_3.txt')); $importer = new qformat_multianswer(); // The importer echoes some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question', $output); $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output); $this->assertStringContainsString('The answer must be a number, for example -1.234 or 3e8, or \'*\'.', $output); // No question have been imported. $this->assertCount(0, $questions); } public function test_read_brokencloze_4(): void { $lines = file(self::get_fixture_path(__NAMESPACE__, 'broken_multianswer_4.txt')); $importer = new qformat_multianswer(); // The importer echoes some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question', $output); $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output); $this->assertStringContainsString('The question text must include at least one embedded answer.', $output); // No question have been imported. $this->assertCount(0, $questions); } } PK\k format/multianswer/format.phpnu[. /** * Embedded answer (Cloze) question importer. * * @package qformat_multianswer * @copyright 2003 Henrik Kaipe * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Importer that imports a text file containing a single Multianswer question * from a text file. * * @copyright 2003 Henrik Kaipe * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_multianswer extends qformat_default { public function provide_import() { return true; } /** * Validate the given file. * * For more expensive or detailed integrity checks. * * @param stored_file $file the file to check * @return string the error message that occurred while validating the given file */ public function validate_file(stored_file $file): string { return $this->validate_is_utf8_file($file); } public function readquestions($lines) { question_bank::get_qtype('multianswer'); // Ensure the multianswer code is loaded. // For this class the method has been simplified as // there can never be more than one question for a // multianswer import. $questions = array(); $questiontext = array(); $questiontext['text'] = implode('', $lines); $questiontext['format'] = FORMAT_MOODLE; $questiontext['itemid'] = ''; $question = qtype_multianswer_extract_question($questiontext); $errors = qtype_multianswer_validate_question($question); if ($errors) { $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors))); return array(); } $question->questiontext = $question->questiontext['text']; $question->questiontextformat = 0; $question->qtype = 'multianswer'; $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_MOODLE; $question->length = 1; $question->penalty = 0.3333333; $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $question->version = 1; $question->versionid = 0; $question->questionbankentryid = 0; if (!empty($question)) { $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question')); $questions[] = $question; } return $questions; } } PK\[l>$format/gift/lang/en/qformat_gift.phpnu[. /** * Strings for component 'qformat_gift', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_gift * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['braceerror'] = 'Could not find {...} around answers'; $string['giftleftbraceerror'] = 'Could not find a {'; $string['giftmatchingformat'] = 'Matching question answers are improperly formatted'; $string['giftnonumericalanswers'] = 'No answers found for numerical question'; $string['giftnovalidquestion'] = 'No valid question found'; $string['giftqtypenotset'] = 'Question type is not set'; $string['giftrightbraceerror'] = 'Could not find a }'; $string['importminerror'] = 'There is an error in the question. There are not enough answers for this question type'; $string['nohandler'] = 'No handler for question type {$a}'; $string['pluginname'] = 'GIFT format'; $string['pluginname_help'] = 'GIFT format enables multiple choice, true/false, short answer, matching, missing word, numerical and essay questions to be imported or exported via text file.'; $string['pluginname_link'] = 'qformat/gift'; $string['privacy:metadata'] = 'The GIFT question format plugin does not store any personal data.'; PK\E88format/gift/version.phpnu[. /** * Version information for the calculated question type. * * @package qformat_gift * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_gift'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\^b<(format/gift/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_gift. * * @package qformat_gift * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_gift\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_gift implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\N''format/gift/examples.txtnu[// EXAMPLE QUESTIONS for the GIFT import filter // by Paul Tsuchido Shew, January 2004. //-----------------------------------------// // Examples from the class description. //-----------------------------------------// Who's buried in Grant's tomb?{~Grant ~Jefferson =no one} Grant is {~buried =entombed ~living} in Grant's tomb. Grant is buried in Grant's tomb.{FALSE} Who's buried in Grant's tomb?{=no one =nobody} When was Ulysses S. Grant born?{#1822:1} //-----------------------------------------// // Examples from the documentation. //-----------------------------------------// // ===Multiple Choice=== Who's buried in Grant's tomb?{~Grant ~Jefferson =no one} Grant is {~buried =entombed ~living} in Grant's tomb. The American holiday of Thanksgiving is celebrated on the { ~second ~third =fourth } Thursday of November. Japanese characters originally came from what country? { ~India =China ~Korea ~Egypt} // ===Short Answer=== Who's buried in Grant's tomb?{=no one =nobody} Two plus two equals {=four =4}. // ===True-False=== Grant is buried in Grant's tomb.{F} The sun rises in the east.{T} // ===Matching=== Matching Question. { =subquestion1 -> subanswer1 =subquestion2 -> subanswer2 =subquestion3 -> subanswer3 } Match the following countries with their corresponding capitals. { =Canada -> Ottawa =Italy -> Rome =Japan -> Tokyo =India -> New Delhi } // ===Numerical=== When was Ulysses S. Grant born? {#1822} What is the value of pi (to 3 decimal places)? {#3.1415:0.0005}. What is the value of pi (to 3 decimal places)? {#3.141..3.142}. When was Ulysses S. Grant born? {# =1822:0 =%50%1822:2} // OPTIONS // ===Line Comments=== // Subheading: Numerical questions below What's 2 plus 2? {#4} // ===Question Name=== ::Kanji Origins::Japanese characters originally came from what country? {=China} ::Thanksgiving Date::The American holiday of Thanksgiving is celebrated on the {~second ~third =fourth} Thursday of November. // ===Feedback=== What's the answer to this multiple-choice question?{ ~wrong answer#feedback comment on the wrong answer ~another wrong answer#feedback comment on this wrong answer =right answer#Very good!} Who's buried in Grant's tomb?{ =no one#excellent answer! =nobody#excellent answer!} // ===Specify text format=== [markdown]Who's buried in **Grant's tomb**?{ =no one#excellent answer! =nobody#excellent answer!} // ===Percentage Answer Weights=== Grant is buried in Grant's tomb.{FALSE#No one is buried in Grant's tomb.} Difficult question.{~wrong answer ~%50%half credit answer =full credit answer} ::Jesus' hometown::Jesus Christ was from { ~Jerusalem#This was an important city, but the wrong answer. ~%25%Bethlehem#He was born here, but not raised here. ~%50%Galilee#You need to be more specific. =Nazareth#Yes! That's right!}. ::Jesus' hometown:: Jesus Christ was from { =Nazareth#Yes! That's right! =%75%Nazereth#Right, but misspelled. =%25%Bethlehem#He was born here, but not raised here.} // ===Multiple Answers=== What two people are entombed in Grant's tomb? { ~No one ~%50%Grant ~%50%Grant's wife ~Grant's father } What two people are entombed in Grant's tomb? { ~%-50%No one ~%50%Grant ~%50%Grant's wife ~%-50%Grant's father } // ===Special Characters=== Which answer equals 5? { ~ \= 2 + 2 = \= 2 + 3 ~ \= 2 + 4 } ::GIFT Control Characters:: Which of the following is NOT a control character for the GIFT import format? { ~ \~ # \~ is a control character. ~ \= # \= is a control character. ~ \# # \# is a control character. ~ \{ # \{ is a control character. ~ \} # \} is a control character. = \\ # Correct! \\ (backslash) is not a control character. BUT, it is used to escape the control characters. So, to specify a literal backslash, you must escape it with a backslash (as shown in this example). } //-----------------------------------------// // Examples from gift/format.php. //-----------------------------------------// Who's buried in Grant's tomb?{~Grant ~Jefferson =no one} Grant is {~buried =entombed ~living} in Grant's tomb. Grant is buried in Grant's tomb.{FALSE} Who's buried in Grant's tomb?{=no one =nobody} When was Ulysses S. Grant born?{#1822:5} Match the following countries with their corresponding capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo} //-----------------------------------------// // More complicated examples. //-----------------------------------------// ::Grant's Tomb::Grant is { ~buried#No one is buried there. =entombed#Right answer! ~living#We hope not! } in Grant's tomb. Difficult multiple choice question.{ ~wrong answer #comment on wrong answer ~%50%half credit answer #comment on answer =full credit answer #well done!} ::Jesus' hometown (Short answer ex.):: Jesus Christ was from { =Nazareth#Yes! That's right! =%75%Nazereth#Right, but misspelled. =%25%Bethlehem#He was born here, but not raised here. }. //this comment will be ignored by the filter ::Numerical example:: When was Ulysses S. Grant born? {# =1822:0 #Correct! 100% credit =%50%1822:2 #He was born in 1822. You get 50% credit for being close. } PK\a%format/gift/tests/giftformat_test.phpnu[. namespace qformat_gift; use phpunit_util; use qformat_gift; use question_bank; use question_check_specified_fields_expectation; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/gift/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the GIFT import/export format. * * @package qformat_gift * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class giftformat_test extends \question_testcase { public function assert_same_gift($expectedtext, $text) { $this->assertEquals( phpunit_util::normalise_line_endings($expectedtext), phpunit_util::normalise_line_endings($text) ); } public function test_import_essay(): void { $gift = ' // essay ::Q8:: How are you? {}'; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Q8', 'questiontext' => 'How are you?', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'essay', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array()), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_essay(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Q8', 'questiontext' => 'How are you?', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'qtype' => 'essay', 'options' => (object) array( 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => '', 'graderinfoformat' => FORMAT_HTML, ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Q8 ::Q8::How are you?{} "; $this->assert_same_gift($expectedgift, $gift); } public function test_import_match(): void { $gift = ' // question: 2 name: Moodle activities ::Moodle activities::[html]Match the activity to the description.{ =[html]An activity supporting asynchronous discussions. -> Forum =[moodle]A teacher asks a question and specifies a choice of multiple responses. -> Choice =[plain]A bank of record entries which participants can add to. -> Database =[markdown]A collection of web pages that anyone can add to or edit. -> Wiki = -> Chat }'; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Moodle activities', 'questiontext' => 'Match the activity to the description.', 'questiontextformat' => FORMAT_HTML, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_HTML, 'qtype' => 'match', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'shuffleanswers' => '1', 'correctfeedback' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ), 'partiallycorrectfeedback' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ), 'incorrectfeedback' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ), 'subquestions' => array( 0 => array( 'text' => 'An activity supporting asynchronous discussions.', 'format' => FORMAT_HTML, 'files' => array(), ), 1 => array( 'text' => 'A teacher asks a question and specifies a choice of multiple responses.', 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => 'A bank of record entries which participants can add to.', 'format' => FORMAT_PLAIN, 'files' => array(), ), 3 => array( 'text' => 'A collection of web pages that anyone can add to or edit.', 'format' => FORMAT_MARKDOWN, 'files' => array(), ), 4 => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ), ), 'subanswers' => array( 0 => 'Forum', 1 => 'Choice', 2 => 'Database', 3 => 'Wiki', 4 => 'Chat', ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->subquestions, $q->subquestions); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_match(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Moodle activities', 'questiontext' => 'Match the activity to the description.', 'questiontextformat' => FORMAT_HTML, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_HTML, 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'qtype' => 'match', 'options' => (object) array( 'id' => 123, 'question' => 666, 'shuffleanswers' => 1, 'subquestions' => array( 42 => (object) array( 'id' => 1234, 'code' => 12341234, 'question' => 666, 'questiontext' => '
    An activity supporting asynchronous discussions.
    ', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'Forum', ), 43 => (object) array( 'id' => 1234, 'code' => 12341234, 'question' => 666, 'questiontext' => 'A teacher asks a question and specifies a choice of multiple responses.', 'questiontextformat' => FORMAT_MOODLE, 'answertext' => 'Choice', ), 44 => (object) array( 'id' => 1234, 'code' => 12341234, 'question' => 666, 'questiontext' => 'A bank of record entries which participants can add to.', 'questiontextformat' => FORMAT_PLAIN, 'answertext' => 'Database', ), 45 => (object) array( 'id' => 1234, 'code' => 12341234, 'question' => 666, 'questiontext' => 'A collection of web pages that anyone can add to or edit.', 'questiontextformat' => FORMAT_MARKDOWN, 'answertext' => 'Wiki', ), 46 => (object) array( 'id' => 1234, 'code' => 12341234, 'question' => 666, 'questiontext' => '', 'questiontextformat' => FORMAT_MARKDOWN, 'answertext' => 'Chat', ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Moodle activities ::Moodle activities::[html]Match the activity to the description.{ \t=
    An activity supporting asynchronous discussions.
    -> Forum \t=[moodle]A teacher asks a question and specifies a choice of multiple responses. -> Choice \t=[plain]A bank of record entries which participants can add to. -> Database \t=[markdown]A collection of web pages that anyone can add to or edit. -> Wiki \t= -> Chat } "; $this->assert_same_gift($expectedgift, $gift); } /** * Test import of multichoice question in GIFT format * * @dataProvider numberingstyle_provider * * @param string $numberingstyle multichoice numbering style to set for qtype_multichoice * */ public function test_import_multichoice($numberingstyle): void { $this->resetAfterTest(true); set_config('answernumbering', $numberingstyle, 'qtype_multichoice'); $gift = " // multiple choice with specified feedback for right and wrong answers ::Q2:: What's between orange and green in the spectrum? { =yellow # right; good! ~red # [html]wrong, it's yellow ~[plain]blue # wrong, it's yellow }"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Q2', 'questiontext' => "What's between orange and green in the spectrum?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'multichoice', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'single' => 1, 'shuffleanswers' => '1', 'answernumbering' => $numberingstyle, 'correctfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'partiallycorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'incorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'answer' => array( 0 => array( 'text' => 'yellow', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => 'red', 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => 'blue', 'format' => FORMAT_PLAIN, 'files' => array(), ), ), 'fraction' => array(1, 0, 0), 'feedback' => array( 0 => array( 'text' => 'right; good!', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "wrong, it's yellow", 'format' => FORMAT_HTML, 'files' => array(), ), 2 => array( 'text' => "wrong, it's yellow", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } /** * Return a list of numbering styles (see question/type/multichoice/questiontype.php * for valid choices) * * @return array Array of 1-element arrays of qtype_multichoice numbering styles */ public static function numberingstyle_provider(): array { return [ ['abc'], ['ABCD'], ['123'], ['iii'], ['IIII'], ['none'] ]; } public function test_import_multichoice_multi(): void { $gift = " // multiple choice, multiple response with specified feedback for right and wrong answers ::colours:: What's between orange and green in the spectrum? { ~%50%yellow # right; good! ~%-100%red # [html]wrong ~%50%off-beige # right; good! ~%-100%[plain]blue # wrong }"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'colours', 'questiontext' => "What's between orange and green in the spectrum?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'multichoice', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'single' => 0, 'shuffleanswers' => '1', 'answernumbering' => 'abc', 'correctfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'partiallycorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'incorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'answer' => array( 0 => array( 'text' => 'yellow', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => 'red', 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => 'off-beige', 'format' => FORMAT_MOODLE, 'files' => array(), ), 3 => array( 'text' => 'blue', 'format' => FORMAT_PLAIN, 'files' => array(), ), ), 'fraction' => array(0.5, -1, 0.5, -1), 'feedback' => array( 0 => array( 'text' => 'right; good!', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "wrong", 'format' => FORMAT_HTML, 'files' => array(), ), 2 => array( 'text' => "right; good!", 'format' => FORMAT_MOODLE, 'files' => array(), ), 3 => array( 'text' => "wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_multichoice_multi_tricky(): void { $gift = " // multiple choice, multiple response with specified feedback for right and wrong answers ::colours:: What's between orange and green in the spectrum? { ~%100%yellow # right; good! ~%-50%red # wrong ~%-50%blue # wrong }"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'colours', 'questiontext' => "What's between orange and green in the spectrum?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'multichoice', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'single' => 0, 'shuffleanswers' => '1', 'answernumbering' => 'abc', 'correctfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'partiallycorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'incorrectfeedback' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'answer' => array( 0 => array( 'text' => 'yellow', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => 'red', 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => 'blue', 'format' => FORMAT_MOODLE, 'files' => array(), ), ), 'fraction' => array(1, -0.5, -0.5), 'feedback' => array( 0 => array( 'text' => 'right; good!', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => "wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_multichoice(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Q8', 'questiontext' => "What's between orange and green in the spectrum?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'qtype' => 'multichoice', 'options' => (object) array( 'single' => 1, 'shuffleanswers' => '1', 'answernumbering' => 'abc', 'correctfeedback' => '', 'correctfeedbackformat' => FORMAT_MOODLE, 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => FORMAT_MOODLE, 'incorrectfeedback' => '', 'incorrectfeedbackformat' => FORMAT_MOODLE, 'answers' => array( 123 => (object) array( 'id' => 123, 'answer' => 'yellow', 'answerformat' => FORMAT_MOODLE, 'fraction' => 1, 'feedback' => 'right; good!', 'feedbackformat' => FORMAT_MOODLE, ), 124 => (object) array( 'id' => 124, 'answer' => 'red', 'answerformat' => FORMAT_MOODLE, 'fraction' => 0, 'feedback' => "wrong, it's yellow", 'feedbackformat' => FORMAT_HTML, ), 125 => (object) array( 'id' => 125, 'answer' => 'blue', 'answerformat' => FORMAT_PLAIN, 'fraction' => 0, 'feedback' => "wrong, it's yellow", 'feedbackformat' => FORMAT_MOODLE, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Q8 ::Q8::What's between orange and green in the spectrum?{ \t=yellow#right; good! \t~red#[html]wrong, it's yellow \t~[plain]blue#wrong, it's yellow } "; $this->assert_same_gift($expectedgift, $gift); } public function test_export_multichoice_multi_tricky(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Q8', 'questiontext' => "What's between orange and green in the spectrum?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'qtype' => 'multichoice', 'options' => (object) array( 'single' => 0, 'shuffleanswers' => '1', 'answernumbering' => 'abc', 'correctfeedback' => '', 'correctfeedbackformat' => FORMAT_MOODLE, 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => FORMAT_MOODLE, 'incorrectfeedback' => '', 'incorrectfeedbackformat' => FORMAT_MOODLE, 'answers' => array( 123 => (object) array( 'id' => 123, 'answer' => 'yellow', 'answerformat' => FORMAT_MOODLE, 'fraction' => 1, 'feedback' => 'right; good!', 'feedbackformat' => FORMAT_MOODLE, ), 124 => (object) array( 'id' => 124, 'answer' => 'red', 'answerformat' => FORMAT_MOODLE, 'fraction' => -0.5, 'feedback' => "wrong, it's yellow", 'feedbackformat' => FORMAT_MOODLE, ), 125 => (object) array( 'id' => 125, 'answer' => 'blue', 'answerformat' => FORMAT_MOODLE, 'fraction' => -0.5, 'feedback' => "wrong, it's yellow", 'feedbackformat' => FORMAT_MOODLE, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Q8 ::Q8::What's between orange and green in the spectrum?{ \t~%100%yellow#right; good! \t~%-50%red#wrong, it's yellow \t~%-50%blue#wrong, it's yellow } "; $this->assert_same_gift($expectedgift, $gift); } public function test_import_numerical(): void { $gift = " // math range question ::Q5:: What is a number from 1 to 5? {#3:2~#Completely wrong}"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Q5', 'questiontext' => "What is a number from 1 to 5?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'numerical', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'answer' => array( '3', '*', ), 'fraction' => array(1, 0), 'feedback' => array( 0 => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "Completely wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), 'tolerance' => array(2, ''), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->fraction, $q->fraction); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_numerical(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Q5', 'questiontext' => "What is a number from 1 to 5?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'qtype' => 'numerical', 'options' => (object) array( 'id' => 123, 'question' => 666, 'unitsleft' => 0, 'showunits' => 2, 'unitgradingtype' => 0, 'unitpenalty' => 0, 'answers' => array( 1 => (object) array( 'id' => 123, 'answer' => '3', 'answerformat' => 0, 'fraction' => 1, 'tolerance' => 2, 'feedback' => '', 'feedbackformat' => FORMAT_MOODLE, ), 2 => (object) array( 'id' => 124, 'answer' => '*', 'answerformat' => 0, 'fraction' => 0, 'tolerance' => 0, 'feedback' => "Completely wrong", 'feedbackformat' => FORMAT_MOODLE, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Q5 ::Q5::What is a number from 1 to 5?{# \t=%100%3:2# \t~#Completely wrong } "; $this->assert_same_gift($expectedgift, $gift); } public function test_import_shortanswer(): void { $gift = " // question: 666 name: Shortanswer ::Shortanswer::Which is the best animal?{ =Frog#Good! =%50%Cat#What is it with Moodlers and cats? =%0%*#Completely wrong }"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Shortanswer', 'questiontext' => "Which is the best animal?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'shortanswer', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'answer' => array( 'Frog', 'Cat', '*', ), 'fraction' => array(1, 0.5, 0), 'feedback' => array( 0 => array( 'text' => 'Good!', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "What is it with Moodlers and cats?", 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => "Completely wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->fraction, $q->fraction); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_shortanswer_with_general_feedback(): void { $gift = " // question: 666 name: Shortanswer ::Shortanswer::Which is the best animal?{ =Frog#Good! =%50%Cat#What is it with Moodlers and cats? =%0%*#Completely wrong ####[html]Here is some general feedback! }"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Shortanswer', 'questiontext' => "Which is the best animal?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => 'Here is some general feedback!', 'generalfeedbackformat' => FORMAT_HTML, 'qtype' => 'shortanswer', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'answer' => array( 'Frog', 'Cat', '*', ), 'fraction' => array(1, 0.5, 0), 'feedback' => array( 0 => array( 'text' => 'Good!', 'format' => FORMAT_MOODLE, 'files' => array(), ), 1 => array( 'text' => "What is it with Moodlers and cats?", 'format' => FORMAT_MOODLE, 'files' => array(), ), 2 => array( 'text' => "Completely wrong", 'format' => FORMAT_MOODLE, 'files' => array(), ), ), ); // Repeated test for better failure messages. $this->assertEquals($expectedq->answer, $q->answer); $this->assertEquals($expectedq->fraction, $q->fraction); $this->assertEquals($expectedq->feedback, $q->feedback); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_shortanswer(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Shortanswer', 'questiontext' => "Which is the best animal?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'qtype' => 'shortanswer', 'options' => (object) array( 'id' => 123, 'questionid' => 666, 'usecase' => 1, 'answers' => array( 1 => (object) array( 'id' => 1, 'answer' => 'Frog', 'answerformat' => 0, 'fraction' => 1, 'feedback' => 'Good!', 'feedbackformat' => FORMAT_MOODLE, ), 2 => (object) array( 'id' => 2, 'answer' => 'Cat', 'answerformat' => 0, 'fraction' => 0.5, 'feedback' => "What is it with Moodlers and cats?", 'feedbackformat' => FORMAT_MOODLE, ), 3 => (object) array( 'id' => 3, 'answer' => '*', 'answerformat' => 0, 'fraction' => 0, 'feedback' => "Completely wrong", 'feedbackformat' => FORMAT_MOODLE, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Shortanswer ::Shortanswer::Which is the best animal?{ \t=%100%Frog#Good! \t=%50%Cat#What is it with Moodlers and cats? \t=%0%*#Completely wrong } "; $this->assert_same_gift($expectedgift, $gift); } public function test_export_shortanswer_with_general_feedback(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Shortanswer', 'questiontext' => "Which is the best animal?", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => 'Here is some general feedback!', 'generalfeedbackformat' => FORMAT_HTML, 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'qtype' => 'shortanswer', 'options' => (object) array( 'id' => 123, 'questionid' => 666, 'usecase' => 1, 'answers' => array( 1 => (object) array( 'id' => 1, 'answer' => 'Frog', 'answerformat' => 0, 'fraction' => 1, 'feedback' => 'Good!', 'feedbackformat' => FORMAT_MOODLE, ), 2 => (object) array( 'id' => 2, 'answer' => 'Cat', 'answerformat' => 0, 'fraction' => 0.5, 'feedback' => "What is it with Moodlers and cats?", 'feedbackformat' => FORMAT_MOODLE, ), 3 => (object) array( 'id' => 3, 'answer' => '*', 'answerformat' => 0, 'fraction' => 0, 'feedback' => "Completely wrong", 'feedbackformat' => FORMAT_MOODLE, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Shortanswer ::Shortanswer::Which is the best animal?{ \t=%100%Frog#Good! \t=%50%Cat#What is it with Moodlers and cats? \t=%0%*#Completely wrong \t####[html]Here is some general feedback! } "; $this->assert_same_gift($expectedgift, $gift); } public function test_import_truefalse(): void { $gift = " // true/false ::Q1:: 42 is the Absolute Answer to everything.{ FALSE#42 is the Ultimate Answer.#You gave the right answer.}"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Q1', 'questiontext' => "42 is the Absolute Answer to everything.", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'truefalse', 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'correctanswer' => 0, 'feedbacktrue' => array( 'text' => '42 is the Ultimate Answer.', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'feedbackfalse' => array( 'text' => 'You gave the right answer.', 'format' => FORMAT_MOODLE, 'files' => array(), ), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_truefalse_true_answer1(): void { $gift = "// name 0-11 ::2-08 TSL::TSL is blablabla.{T}"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => '2-08 TSL', 'questiontext' => "TSL is blablabla.", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'truefalse', 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'correctanswer' => 1, 'feedbacktrue' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'feedbackfalse' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_truefalse_true_answer2(): void { $gift = "// name 0-11 ::2-08 TSL::TSL is blablabla.{TRUE}"; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => '2-08 TSL', 'questiontext' => "TSL is blablabla.", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'truefalse', 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'correctanswer' => 1, 'feedbacktrue' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), 'feedbackfalse' => array( 'text' => '', 'format' => FORMAT_MOODLE, 'files' => array(), ), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_truefalse(): void { $qdata = (object) array( 'id' => 666 , 'name' => 'Q1', 'questiontext' => "42 is the Absolute Answer to everything.", 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 1, 'length' => 1, 'qtype' => 'truefalse', 'options' => (object) array( 'id' => 123, 'question' => 666, 'trueanswer' => 1, 'falseanswer' => 2, 'answers' => array( 1 => (object) array( 'id' => 123, 'answer' => 'True', 'answerformat' => 0, 'fraction' => 1, 'feedback' => 'You gave the right answer.', 'feedbackformat' => FORMAT_MOODLE, ), 2 => (object) array( 'id' => 124, 'answer' => 'False', 'answerformat' => 0, 'fraction' => 0, 'feedback' => "42 is the Ultimate Answer.", 'feedbackformat' => FORMAT_HTML, ), ), ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: Q1 ::Q1::42 is the Absolute Answer to everything.{TRUE#[html]42 is the Ultimate Answer.#You gave the right answer.} "; $this->assert_same_gift($expectedgift, $gift); } public function test_export_backslash(): void { // There was a bug (MDL-34171) where \\ was getting exported as \\, not // \\\\, and on import, \\ in converted to \. // We need \\\\ in the test code, because of PHPs string escaping rules. $qdata = (object) array( 'id' => 666 , 'name' => 'backslash', 'questiontext' => 'A \\ B \\\\ C', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'qtype' => 'essay', 'options' => (object) array( 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => '', 'graderinfoformat' => FORMAT_HTML, ), ); $exporter = new qformat_gift(); $gift = $exporter->writequestion($qdata); $expectedgift = "// question: 666 name: backslash ::backslash::A \\\\ B \\\\\\\\ C{} "; $this->assert_same_gift($expectedgift, $gift); } public function test_import_backslash(): void { // There was a bug (MDL-34171) where \\ in the import was getting changed // to \. This test checks for that. // We need \\\\ in the test code, because of PHPs string escaping rules. $gift = ' // essay ::double backslash:: A \\\\ B \\\\\\\\ C{}'; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'double backslash', 'questiontext' => 'A \\ B \\\\ C', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'essay', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array()), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_pre_content(): void { $gift = ' ::Q001::[html]

    What would running the test method print?

        public void test() \{
            method1();
            method2();
            method3();
        \}
    
    {}'; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Q001', 'questiontext' => '

    What would running the test method print?

        public void test() {
            method1();
            method2();
            method3();
        }
    
    ', 'questiontextformat' => FORMAT_HTML, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_HTML, 'qtype' => 'essay', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array()), ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_question_with_tags(): void { $gift = ' // This question is to test importing tags: [tag:tag] [tag:other-tag]. // And an idnumber: [id:myid]. ::Question name:: How are you? {}'; $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $importer = new qformat_gift(); $q = $importer->readquestion($lines); $expectedq = (object) array( 'name' => 'Question name', 'questiontext' => 'How are you?', 'questiontextformat' => FORMAT_MOODLE, 'generalfeedback' => '', 'generalfeedbackformat' => FORMAT_MOODLE, 'qtype' => 'essay', 'defaultmark' => 1, 'penalty' => 0.3333333, 'length' => 1, 'responseformat' => 'editor', 'responsefieldlines' => 15, 'attachments' => 0, 'graderinfo' => array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array()), 'tags' => ['tag', 'other-tag'], 'idnumber' => 'myid', ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } /** * Data provider for test_extract_idnumber_and_tags_from_comment. * * @return array the test cases. */ public static function extract_idnumber_and_tags_from_comment_testcases(): array { return [ 'blank comment' => ['', [], ''], 'nothing in comment' => ['', [], '// A basic comment.'], 'idnumber only' => ['frog', [], '// A comment with [id:frog] <-- an idnumber.'], 'tags only' => ['', ['frog', 'toad'], '// Look tags: [tag:frog] [tag:toad].'], 'everything' => ['four', ['add', 'basic'], '// [tag:add] [tag:basic] [id:four]'], 'everything mixed up' => ['four', ['basic', 'add'], "// [tag: basic] Here is \n// a [id: four ] que[tag:add ]stion."], 'split over line' => ['', [], "// Ceci n\'est pas une [tag:\n\\ frog]."], 'escape ] idnumber' => ['i]d', [], '// [id:i\]d].'], 'escape ] tag' => ['', ['t]ag'], '// [tag:t\]ag].'], ]; } /** * Test extract_idnumber_and_tags_from_comment. * * @dataProvider extract_idnumber_and_tags_from_comment_testcases * @param string $expectedidnumber the expected idnumber. * @param array $expectedtags the expected tags. * @param string $comment the comment to parse. */ public function test_extract_idnumber_and_tags_from_comment( string $expectedidnumber, array $expectedtags, string $comment): void { $importer = new qformat_gift(); list($idnumber, $tags) = $importer->extract_idnumber_and_tags_from_comment($comment); $this->assertSame($expectedidnumber, $idnumber); $this->assertSame($expectedtags, $tags); } public function test_export_question_with_tags_and_idnumber(): void { $this->resetAfterTest(); // Create a question with tags. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(); $question = $generator->create_question('truefalse', null, ['category' => $category->id, 'idnumber' => 'myid']); \core_tag_tag::set_item_tags('core_question', 'question', $question->id, \context::instance_by_id($category->contextid), ['tag1', 'tag2'], 0); // Export it. $questiondata = question_bank::load_question_data($question->id); $exporter = new qformat_gift(); $exporter->course = get_course(SITEID); $gift = $exporter->writequestion($questiondata); // Verify. $expectedgift = "// question: {$question->id} name: True/false question // [id:myid] [tag:tag1] [tag:tag2] ::True/false question::[html]The answer is true.{TRUE#This is the wrong answer.#This is the right answer.####You should have selected true.} "; $this->assert_same_gift($expectedgift, $gift); } } PK\ ڝCformat/gift/tests/fixtures/questions_encoding_windows-1252.gift.txtnu[// question: 0 name: Switch category to $course$/top/Default for LTTEST $CATEGORY: $course$/top/Default for LTTEST // question: 19756780 name: asdf ::asdf::[html]

    asdf

    {} // question: 19756810 name: test daniel ::test daniel::[html]

    asdfasdf

    {} // question: 19756750 name: asdf ::asdf::[html]

    asdf

    { =

    adf

    -> asdf =

    asdf

    -> asdf =

    asdf

    -> asdf } PK\Mqjj9format/gift/tests/fixtures/questions_in_category.gift.txtnu[// question: 0 name: switch category to $course$/Default for New Features $CATEGORY: $course$/Default for New Features // question: 44 name: Moodle activities ::Moodle activities::[html]Match the activity to the description.{ =An activity supporting asynchronous discussions. -> Forum =A teacher asks a question and specifies a choice of multiple responses. -> Choice =A bank of record entries which participants can add to. -> Database =A collection of web pages that anyone can add to or edit. -> Wiki = -> Chat } // question: 43 name: Greeting ::Greeting::[html]Listen to this greeting\:

    What language is being spoken?{ ~English#Sorry, listen again. =French#Yes, well done! ~German#Sorry, listen again. ~Spanish#Sorry, listen again. } // question: 46 name: Moodle user ::Moodle user::[html]Anyone who uses Moodle is a ...{ =%100%Moodler# } // question: 45 name: Moodle acronym ::Moodle acronym::[html]Moodle is an acronym for Modular Object-Oriented Dynamic Learning Environment.{TRUE} PK\,**-format/gift/tests/fixtures/questions.gift.txtnu[// essay ::Q8:: How are you? {} // question: 2 name: Moodle activities ::Moodle activities::[html]Match the activity to the description.{ =[html]An activity supporting asynchronous discussions. -> Forum =[moodle]A teacher asks a question and specifies a choice of multiple responses. -> Choice =[plain]A bank of record entries which participants can add to. -> Database =[markdown]A collection of web pages that anyone can add to or edit. -> Wiki = -> Chat } // multiple choice with specified feedback for right and wrong answers ::Q2:: What's between orange and green in the spectrum? { =yellow # right; good! ~red # [html]wrong, it's yellow ~[plain]blue # wrong, it's yellow } // multiple choice, multiple response with specified feedback for right and wrong answers ::colours:: What's between orange and green in the spectrum? { ~%50%yellow # right; good! ~%-100%red # [html]wrong ~%50%off-beige # right; good! ~%-100%[plain]blue # wrong } // math range question ::Q5:: What is a number from 1 to 5? {#3:2~#Completely wrong}"; // question: 666 name: Shortanswer ::Shortanswer::Which is the best animal?{ =Frog#Good! =%50%Cat#What is it with Moodlers and cats? =%0%*#Completely wrong } // true/false, with general feedback ::Q1:: 42 is the Absolute Answer to everything.{ FALSE#42 is the Ultimate Answer.#You gave the right answer. ####This is, of course, a Hitchiker's Guide to the Galaxy reference.}"; // name 0-11 ::2-08 TSL::TSL is blablabla.{T} // name 0-11 ::2-08 TSL::TSL is blablabla.{TRUE} PK\:`G G -format/gift/tests/behat/import_export.featurenu[@qformat @qformat_gift Feature: Test importing questions from GIFT format. In order to reuse questions As an teacher I need to be able to import them in GIFT format. Background: Given the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And the following "users" exist: | username | firstname | | teacher | Teacher | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" @javascript @_file_upload Scenario: import some GIFT questions When I set the field "id_format_gift" to "1" And I upload "question/format/gift/tests/fixtures/questions.gift.txt" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 9 questions from file" And I should see "What's between orange and green in the spectrum?" When I press "Continue" Then I should see "colours" # Now export again. And I am on the "Qbank 1" "core_question > question export" page And I set the field "id_format_gift" to "1" And I press "Export questions to file" And following "click here" should download a file that: | Has mimetype | text/plain | | Contains text | What's between orange and green in the spectrum? | @javascript @_file_upload Scenario: import a GIFT file which specifies the category When I set the field "id_format_gift" to "1" And I upload "question/format/gift/tests/fixtures/questions_in_category.gift.txt" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 4 questions from file" And I should see "Match the activity to the description." When I press "Continue" Then I should see "Moodle activities" @javascript @_file_upload Scenario: import some GIFT questions with unsupported encoding When I set the field "id_format_gift" to "1" And I upload "question/format/gift/tests/fixtures/questions_encoding_windows-1252.gift.txt" file to "Import" filemanager And I press "id_submitbutton" Then I should see "The file you selected does not use UTF-8 character encoding. GIFT format files must use UTF-8." PK\%,CCformat/gift/format.phpnu[. /** * GIFT format question importer/exporter. * * @package qformat_gift * @copyright 2003 Paul Tsuchido Shew * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * The GIFT import filter was designed as an easy to use method * for teachers writing questions as a text file. It supports most * question types and the missing word format. * * Multiple Choice / Missing Word * Who's buried in Grant's tomb?{~Grant ~Jefferson =no one} * Grant is {~buried =entombed ~living} in Grant's tomb. * True-False: * Grant is buried in Grant's tomb.{FALSE} * Short-Answer. * Who's buried in Grant's tomb?{=no one =nobody} * Numerical * When was Ulysses S. Grant born?{#1822:5} * Matching * Match the following countries with their corresponding * capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo} * * Comment lines start with a double backslash (//). * Optional question names are enclosed in double colon(::). * Answer feedback is indicated with hash mark (#). * Percentage answer weights immediately follow the tilde (for * multiple choice) or equal sign (for short answer and numerical), * and are enclosed in percent signs (% %). See docs and examples.txt for more. * * This filter was written through the collaboration of numerous * members of the Moodle community. It was originally based on * the missingword format, which included code from Thomas Robb * and others. Paul Tsuchido Shew wrote this filter in December 2003. * * @copyright 2003 Paul Tsuchido Shew * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_gift extends qformat_default { public function provide_import() { return true; } public function provide_export() { return true; } public function export_file_extension() { return '.txt'; } /** * Validate the given file. * * For more expensive or detailed integrity checks. * * @param stored_file $file the file to check * @return string the error message that occurred while validating the given file */ public function validate_file(stored_file $file): string { return $this->validate_is_utf8_file($file); } protected function answerweightparser(&$answer) { $answer = substr($answer, 1); // Removes initial %. $endposition = strpos($answer, "%"); $answerweight = substr($answer, 0, $endposition); // Gets weight as integer. $answerweight = $answerweight/100; // Converts to percent. $answer = substr($answer, $endposition+1); // Removes comment from answer. return $answerweight; } protected function commentparser($answer, $defaultformat) { $bits = explode('#', $answer, 2); $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat); if (count($bits) > 1) { $feedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat); } else { $feedback = array('text' => '', 'format' => $defaultformat, 'files' => array()); } return array($ans, $feedback); } protected function split_truefalse_comment($answer, $defaultformat) { $bits = explode('#', $answer, 3); $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat); if (count($bits) > 1) { $wrongfeedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat); } else { $wrongfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array()); } if (count($bits) > 2) { $rightfeedback = $this->parse_text_with_format(trim($bits[2]), $defaultformat); } else { $rightfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array()); } return array($ans, $wrongfeedback, $rightfeedback); } protected function escapedchar_pre($string) { // Replaces escaped control characters with a placeholder BEFORE processing. $escapedcharacters = array("\\:", "\\#", "\\=", "\\{", "\\}", "\\~", "\\n" ); $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); $string = str_replace("\\\\", "&&092;", $string); $string = str_replace($escapedcharacters, $placeholders, $string); $string = str_replace("&&092;", "\\", $string); return $string; } protected function escapedchar_post($string) { // Replaces placeholders with corresponding character AFTER processing is done. $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); $characters = array(":", "#", "=", "{", "}", "~", "\n" ); $string = str_replace($placeholders, $characters, $string); return $string; } protected function check_answer_count($min, $answers, $text) { $countanswers = count($answers); if ($countanswers < $min) { $this->error(get_string('importminerror', 'qformat_gift'), $text); return false; } return true; } protected function parse_text_with_format($text, $defaultformat = FORMAT_MOODLE) { $result = array( 'text' => $text, 'format' => $defaultformat, 'files' => array(), ); if (strpos($text, '[') === 0) { $formatend = strpos($text, ']'); $result['format'] = $this->format_name_to_const(substr($text, 1, $formatend - 1)); if ($result['format'] == -1) { $result['format'] = $defaultformat; } else { $result['text'] = substr($text, $formatend + 1); } } $result['text'] = trim($this->escapedchar_post($result['text'])); return $result; } public function readquestion($lines) { // Given an array of lines known to define a question in this format, this function // converts it into a question object suitable for processing and insertion into Moodle. $question = $this->defaultquestion(); // Define replaced by simple assignment, stop redefine notices. $giftanswerweightregex = '/^%\-*([0-9]{1,2})\.?([0-9]*)%/'; // Separate comments and implode. $comments = ''; foreach ($lines as $key => $line) { $line = trim($line); if (substr($line, 0, 2) == '//') { $comments .= $line . "\n"; $lines[$key] = ' '; } } $text = trim(implode("\n", $lines)); if ($text == '') { return false; } // Substitute escaped control characters with placeholders. $text = $this->escapedchar_pre($text); // Look for category modifier. if (preg_match('~^\$CATEGORY:~', $text)) { $newcategory = trim(substr($text, 10)); // Build fake question to contain category. $question->qtype = 'category'; $question->category = $newcategory; return $question; } // Question name parser. if (substr($text, 0, 2) == '::') { $text = substr($text, 2); $namefinish = strpos($text, '::'); if ($namefinish === false) { $question->name = false; // Name will be assigned after processing question text below. } else { $questionname = substr($text, 0, $namefinish); $question->name = $this->clean_question_name($this->escapedchar_post($questionname)); $text = trim(substr($text, $namefinish+2)); // Remove name from text. } } else { $question->name = false; } // Find the answer section. $answerstart = strpos($text, '{'); $answerfinish = strpos($text, '}'); $description = false; if ($answerstart === false && $answerfinish === false) { // No answer means it's a description. $description = true; $answertext = ''; $answerlength = 0; } else if ($answerstart === false || $answerfinish === false) { $this->error(get_string('braceerror', 'qformat_gift'), $text); return false; } else { $answerlength = $answerfinish - $answerstart; $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1)); } // Format the question text, without answer, inserting "_____" as necessary. if ($description) { $questiontext = $text; } else if (substr($text, -1) == "}") { // No blank line if answers follow question, outside of closing punctuation. $questiontext = substr_replace($text, "", $answerstart, $answerlength + 1); } else { // Inserts blank line for missing word format. $questiontext = substr_replace($text, "_____", $answerstart, $answerlength + 1); } // Look to see if there is any general feedback. $gfseparator = strrpos($answertext, '####'); if ($gfseparator === false) { $generalfeedback = ''; } else { $generalfeedback = substr($answertext, $gfseparator + 4); $answertext = trim(substr($answertext, 0, $gfseparator)); } // Get questiontext format from questiontext. $text = $this->parse_text_with_format($questiontext); $question->questiontextformat = $text['format']; $question->questiontext = $text['text']; // Get generalfeedback format from questiontext. $text = $this->parse_text_with_format($generalfeedback, $question->questiontextformat); $question->generalfeedback = $text['text']; $question->generalfeedbackformat = $text['format']; // Set question name if not already set. if ($question->name === false) { $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question')); } // Determine question type. $question->qtype = null; // Extract any idnumber and tags from the comments. list($question->idnumber, $question->tags) = $this->extract_idnumber_and_tags_from_comment($comments); // Give plugins first try. // Plugins must promise not to intercept standard qtypes // MDL-12346, this could be called from lesson mod which has its own base class =(. if (method_exists($this, 'try_importing_using_qtypes') && ($tryquestion = $this->try_importing_using_qtypes($lines, $question, $answertext))) { return $tryquestion; } if ($description) { $question->qtype = 'description'; } else if ($answertext == '') { $question->qtype = 'essay'; } else if ($answertext[0] == '#') { $question->qtype = 'numerical'; } else if (strpos($answertext, '~') !== false) { // Only Multiplechoice questions contain tilde ~. $question->qtype = 'multichoice'; } else if (strpos($answertext, '=') !== false && strpos($answertext, '->') !== false) { // Only Matching contains both = and ->. $question->qtype = 'match'; } else { // Either truefalse or shortanswer. // Truefalse question check. $truefalsecheck = $answertext; if (strpos($answertext, '#') > 0) { // Strip comments to check for TrueFalse question. $truefalsecheck = trim(substr($answertext, 0, strpos($answertext, "#"))); } $validtfanswers = array('T', 'TRUE', 'F', 'FALSE'); if (in_array($truefalsecheck, $validtfanswers)) { $question->qtype = 'truefalse'; } else { // Must be shortanswer. $question->qtype = 'shortanswer'; } } if (!isset($question->qtype)) { $giftqtypenotset = get_string('giftqtypenotset', 'qformat_gift'); $this->error($giftqtypenotset, $text); return false; } switch ($question->qtype) { case 'description': $question->defaultmark = 0; $question->length = 0; return $question; case 'essay': $question->responseformat = 'editor'; $question->responserequired = 1; $question->responsefieldlines = 15; $question->attachments = 0; $question->attachmentsrequired = 0; $question->graderinfo = array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array()); $question->responsetemplate = array( 'text' => '', 'format' => FORMAT_HTML); return $question; case 'multichoice': // "Temporary" solution to enable choice of answernumbering on GIFT import // by respecting default set for multichoice questions (MDL-59447) $question->answernumbering = get_config('qtype_multichoice', 'answernumbering'); if (strpos($answertext, "=") === false) { $question->single = 0; // Multiple answers are enabled if no single answer is 100% correct. } else { $question->single = 1; // Only one answer allowed (the default). } $question = $this->add_blank_combined_feedback($question); $answertext = str_replace("=", "~=", $answertext); $answers = explode("~", $answertext); if (isset($answers[0])) { $answers[0] = trim($answers[0]); } if (empty($answers[0])) { array_shift($answers); } $countanswers = count($answers); if (!$this->check_answer_count(2, $answers, $text)) { return false; } foreach ($answers as $key => $answer) { $answer = trim($answer); // Determine answer weight. if ($answer[0] == '=') { $answerweight = 1; $answer = substr($answer, 1); } else if (preg_match($giftanswerweightregex, $answer)) { // Check for properly formatted answer weight. $answerweight = $this->answerweightparser($answer); } else { // Default, i.e., wrong anwer. $answerweight = 0; } list($question->answer[$key], $question->feedback[$key]) = $this->commentparser($answer, $question->questiontextformat); $question->fraction[$key] = $answerweight; } // End foreach answer. return $question; case 'match': $question = $this->add_blank_combined_feedback($question); $answers = explode('=', $answertext); if (isset($answers[0])) { $answers[0] = trim($answers[0]); } if (empty($answers[0])) { array_shift($answers); } if (!$this->check_answer_count(2, $answers, $text)) { return false; } foreach ($answers as $key => $answer) { $answer = trim($answer); if (strpos($answer, "->") === false) { $this->error(get_string('giftmatchingformat', 'qformat_gift'), $answer); return false; } $marker = strpos($answer, '->'); $question->subquestions[$key] = $this->parse_text_with_format( substr($answer, 0, $marker), $question->questiontextformat); $question->subanswers[$key] = trim($this->escapedchar_post( substr($answer, $marker + 2))); } return $question; case 'truefalse': list($answer, $wrongfeedback, $rightfeedback) = $this->split_truefalse_comment($answertext, $question->questiontextformat); if ($answer['text'] == "T" || $answer['text'] == "TRUE") { $question->correctanswer = 1; $question->feedbacktrue = $rightfeedback; $question->feedbackfalse = $wrongfeedback; } else { $question->correctanswer = 0; $question->feedbacktrue = $wrongfeedback; $question->feedbackfalse = $rightfeedback; } $question->penalty = 1; return $question; case 'shortanswer': // Shortanswer question. $answers = explode("=", $answertext); if (isset($answers[0])) { $answers[0] = trim($answers[0]); } if (empty($answers[0])) { array_shift($answers); } if (!$this->check_answer_count(1, $answers, $text)) { return false; } foreach ($answers as $key => $answer) { $answer = trim($answer); // Answer weight. if (preg_match($giftanswerweightregex, $answer)) { // Check for properly formatted answer weight. $answerweight = $this->answerweightparser($answer); } else { // Default, i.e., full-credit anwer. $answerweight = 1; } list($answer, $question->feedback[$key]) = $this->commentparser( $answer, $question->questiontextformat); $question->answer[$key] = $answer['text']; $question->fraction[$key] = $answerweight; } return $question; case 'numerical': // Note similarities to ShortAnswer. $answertext = substr($answertext, 1); // Remove leading "#". // If there is feedback for a wrong answer, store it for now. if (($pos = strpos($answertext, '~')) !== false) { $wrongfeedback = substr($answertext, $pos); $answertext = substr($answertext, 0, $pos); } else { $wrongfeedback = ''; } $answers = explode("=", $answertext); if (isset($answers[0])) { $answers[0] = trim($answers[0]); } if (empty($answers[0])) { array_shift($answers); } if (count($answers) == 0) { // Invalid question. $giftnonumericalanswers = get_string('giftnonumericalanswers', 'qformat_gift'); $this->error($giftnonumericalanswers, $text); return false; } foreach ($answers as $key => $answer) { $answer = trim($answer); // Answer weight. if (preg_match($giftanswerweightregex, $answer)) { // Check for properly formatted answer weight. $answerweight = $this->answerweightparser($answer); } else { // Default, i.e., full-credit anwer. $answerweight = 1; } list($answer, $question->feedback[$key]) = $this->commentparser( $answer, $question->questiontextformat); $question->fraction[$key] = $answerweight; $answer = $answer['text']; // Calculate Answer and Min/Max values. if (strpos($answer, "..") > 0) { // Optional [min]..[max] format. $marker = strpos($answer, ".."); $max = trim(substr($answer, $marker + 2)); $min = trim(substr($answer, 0, $marker)); $ans = ($max + $min)/2; $tol = $max - $ans; } else if (strpos($answer, ':') > 0) { // Standard [answer]:[errormargin] format. $marker = strpos($answer, ':'); $tol = trim(substr($answer, $marker+1)); $ans = trim(substr($answer, 0, $marker)); } else { // Only one valid answer (zero errormargin). $tol = 0; $ans = trim($answer); } if (!(is_numeric($ans) || $ans = '*') || !is_numeric($tol)) { $errornotnumbers = get_string('errornotnumbers'); $this->error($errornotnumbers, $text); return false; } // Store results. $question->answer[$key] = $ans; $question->tolerance[$key] = $tol; } if ($wrongfeedback) { $key += 1; $question->fraction[$key] = 0; list($notused, $question->feedback[$key]) = $this->commentparser( $wrongfeedback, $question->questiontextformat); $question->answer[$key] = '*'; $question->tolerance[$key] = ''; } return $question; default: $this->error(get_string('giftnovalidquestion', 'qformat_gift'), $text); return false; } } protected function repchar($text, $notused = 0) { // Escapes 'reserved' characters # = ~ {) : // Removes new lines. $reserved = array( '\\', '#', '=', '~', '{', '}', ':', "\n", "\r"); $escaped = array('\\\\', '\#', '\=', '\~', '\{', '\}', '\:', '\n', ''); $newtext = str_replace($reserved, $escaped, $text); return $newtext; } /** * @param int $format one of the FORMAT_ constants. * @return string the corresponding name. */ protected function format_const_to_name($format) { if ($format == FORMAT_MOODLE) { return 'moodle'; } else if ($format == FORMAT_HTML) { return 'html'; } else if ($format == FORMAT_PLAIN) { return 'plain'; } else if ($format == FORMAT_MARKDOWN) { return 'markdown'; } else { return 'moodle'; } } /** * @param int $format one of the FORMAT_ constants. * @return string the corresponding name. */ protected function format_name_to_const($format) { if ($format == 'moodle') { return FORMAT_MOODLE; } else if ($format == 'html') { return FORMAT_HTML; } else if ($format == 'plain') { return FORMAT_PLAIN; } else if ($format == 'markdown') { return FORMAT_MARKDOWN; } else { return -1; } } /** * Extract any tags or idnumber declared in the question comment. * * @param string $comment E.g. "// Line 1.\n//Line 2.\n". * @return array with two elements. string $idnumber (or '') and string[] of tags. */ public function extract_idnumber_and_tags_from_comment(string $comment): array { // Find the idnumber, if any. There should not be more than one, but if so, we just find the first. $idnumber = ''; if (preg_match('~ # Start of id token. \[id: # Any number of (non-control) characters, with any ] escaped. # This is the bit we want so capture it. ( (?:\\\\]|[^][:cntrl:]])+ ) # End of id token. ] ~x', $comment, $match)) { $idnumber = str_replace('\]', ']', trim($match[1])); } // Find any tags. $tags = []; if (preg_match_all('~ # Start of tag token. \[tag: # Any number of allowed characters (see PARAM_TAG), with any ] escaped. # This is the bit we want so capture it. ( (?:\\\\]|[^]<>`[:cntrl:]]|)+ ) # End of tag token. ] ~x', $comment, $matches)) { foreach ($matches[1] as $rawtag) { $tags[] = str_replace('\]', ']', trim($rawtag)); } } return [$idnumber, $tags]; } public function write_name($name) { return '::' . $this->repchar($name) . '::'; } public function write_questiontext($text, $format, $defaultformat = FORMAT_MOODLE) { $output = ''; if ($text != '' && $format != $defaultformat) { $output .= '[' . $this->format_const_to_name($format) . ']'; } $output .= $this->repchar($text, $format); return $output; } /** * Outputs the general feedback for the question, if any. This needs to be the * last thing before the }. * @param object $question the question data. * @param string $indent to put before the general feedback. Defaults to a tab. * If this is not blank, a newline is added after the line. */ public function write_general_feedback($question, $indent = "\t") { $generalfeedback = $this->write_questiontext($question->generalfeedback, $question->generalfeedbackformat, $question->questiontextformat); if ($generalfeedback) { $generalfeedback = '####' . $generalfeedback; if ($indent) { $generalfeedback = $indent . $generalfeedback . "\n"; } } return $generalfeedback; } public function writequestion($question) { // Start with a comment. $expout = "// question: {$question->id} name: {$question->name}\n"; $expout .= $this->write_idnumber_and_tags($question); // Output depends on question type. switch($question->qtype) { case 'category': // Not a real question, used to insert category switch. $expout .= "\$CATEGORY: $question->category\n"; break; case 'description': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); break; case 'essay': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= "{"; $expout .= $this->write_general_feedback($question, ''); $expout .= "}\n"; break; case 'truefalse': $trueanswer = $question->options->answers[$question->options->trueanswer]; $falseanswer = $question->options->answers[$question->options->falseanswer]; if ($trueanswer->fraction == 1) { $answertext = 'TRUE'; $rightfeedback = $this->write_questiontext($trueanswer->feedback, $trueanswer->feedbackformat, $question->questiontextformat); $wrongfeedback = $this->write_questiontext($falseanswer->feedback, $falseanswer->feedbackformat, $question->questiontextformat); } else { $answertext = 'FALSE'; $rightfeedback = $this->write_questiontext($falseanswer->feedback, $falseanswer->feedbackformat, $question->questiontextformat); $wrongfeedback = $this->write_questiontext($trueanswer->feedback, $trueanswer->feedbackformat, $question->questiontextformat); } $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= '{' . $this->repchar($answertext); if ($wrongfeedback) { $expout .= '#' . $wrongfeedback; } else if ($rightfeedback) { $expout .= '#'; } if ($rightfeedback) { $expout .= '#' . $rightfeedback; } $expout .= $this->write_general_feedback($question, ''); $expout .= "}\n"; break; case 'multichoice': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= "{\n"; foreach ($question->options->answers as $answer) { if ($answer->fraction == 1 && $question->options->single) { $answertext = '='; } else if ($answer->fraction == 0) { $answertext = '~'; } else { $weight = $answer->fraction * 100; $answertext = '~%' . $weight . '%'; } $expout .= "\t" . $answertext . $this->write_questiontext($answer->answer, $answer->answerformat, $question->questiontextformat); if ($answer->feedback != '') { $expout .= '#' . $this->write_questiontext($answer->feedback, $answer->feedbackformat, $question->questiontextformat); } $expout .= "\n"; } $expout .= $this->write_general_feedback($question); $expout .= "}\n"; break; case 'shortanswer': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= "{\n"; foreach ($question->options->answers as $answer) { $weight = 100 * $answer->fraction; $expout .= "\t=%" . $weight . '%' . $this->repchar($answer->answer) . '#' . $this->write_questiontext($answer->feedback, $answer->feedbackformat, $question->questiontextformat) . "\n"; } $expout .= $this->write_general_feedback($question); $expout .= "}\n"; break; case 'numerical': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= "{#\n"; foreach ($question->options->answers as $answer) { if ($answer->answer != '' && $answer->answer != '*') { $weight = 100 * $answer->fraction; $expout .= "\t=%" . $weight . '%' . $answer->answer . ':' . (float)$answer->tolerance . '#' . $this->write_questiontext($answer->feedback, $answer->feedbackformat, $question->questiontextformat) . "\n"; } else { $expout .= "\t~#" . $this->write_questiontext($answer->feedback, $answer->feedbackformat, $question->questiontextformat) . "\n"; } } $expout .= $this->write_general_feedback($question); $expout .= "}\n"; break; case 'match': $expout .= $this->write_name($question->name); $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat); $expout .= "{\n"; foreach ($question->options->subquestions as $subquestion) { $expout .= "\t=" . $this->write_questiontext($subquestion->questiontext, $subquestion->questiontextformat, $question->questiontextformat) . ' -> ' . $this->repchar($subquestion->answertext) . "\n"; } $expout .= $this->write_general_feedback($question); $expout .= "}\n"; break; default: // Check for plugins. if ($out = $this->try_exporting_using_qtypes($question->qtype, $question)) { $expout .= $out; } } // Add empty line to delimit questions. $expout .= "\n"; return $expout; } /** * Prepare any question idnumber or tags for export. * * @param stdClass $questiondata the question data we are exporting. * @return string a string that can be written as a line in the GIFT file, * e.g. "// [id:myid] [tag:some-tag]\n". Will be '' if none. */ public function write_idnumber_and_tags(stdClass $questiondata): string { if ($questiondata->qtype == 'category') { return ''; } $bits = []; if (isset($questiondata->idnumber) && $questiondata->idnumber !== '') { $bits[] = '[id:' . str_replace(']', '\]', $questiondata->idnumber) . ']'; } // Write the question tags. if (core_tag_tag::is_enabled('core_question', 'question')) { $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $questiondata->id); if (!empty($tagobjects)) { $context = context::instance_by_id($questiondata->contextid); $sortedtagobjects = question_sort_tags($tagobjects, $context); // Currently we ignore course tags. This should probably be fixed in future. if (!empty($sortedtagobjects->tags)) { foreach ($sortedtagobjects->tags as $tag) { $bits[] = '[tag:' . str_replace(']', '\]', $tag) . ']'; } } } } if (!$bits) { return ''; } return '// ' . implode(' ', $bits) . "\n"; } } PK\ª2format/missingword/lang/en/qformat_missingword.phpnu[. /** * Strings for component 'qformat_missingword', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_missingword * @copyright 2010 Helen Foster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Missing word format'; $string['pluginname_help'] = 'Missing word format enables questions to be imported via text file.'; $string['pluginname_link'] = 'qformat/missingword'; $string['beginanswernotfound'] = 'Could not find a required "{" character in imported file content.'; $string['endanswernotfound'] = 'Could not find a required "}" character in imported file content.'; $string['noanswerfound'] = 'No answers found in question'; $string['privacy:metadata'] = 'The Missing word question format plugin does not store any personal data.'; PK\iFFformat/missingword/version.phpnu[. /** * Version information for the calculated question type. * * @package qformat_missingword * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_missingword'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\}Kf/format/missingword/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_missingword. * * @package qformat_missingword * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_missingword\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_missingword implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\ i;format/missingword/tests/fixtures/question.missingword3.txtnu[This is {=the best answer#comment on the best answer ~75%a good answer#comment on the good answer ~a wrong one#comment on the bad answer} PK\ qq;format/missingword/tests/fixtures/question.missingword2.txtnu[You can use the missing word format to import questions into both Moodle's Question bank and {=Lesson} activity. PK\|.;format/missingword/tests/fixtures/question.missingword1.txtnu[As soon as we begin to explore our body parts as infants we become students of {=anatomy and physiology ~reflexology ~science ~experiment}, and in a sense we remain students for life. PK\ format/missingword/format.phpnu[. /** * Missing word question importer. * * @package qformat_missingword * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Missing word question importer. * * This Moodle class provides all functions necessary to import and export * one-correct-answer multiple choice questions in this format: * * As soon as we begin to explore our body parts as infants * we become students of {=anatomy and physiology ~reflexology * ~science ~experiment}, and in a sense we remain students for life. * * Each answer is separated with a tilde ~, and the correct answer is * prefixed with an equals sign = * * Percentage weights can be included by following the tilde with the * desired percent. Comments can be included for each choice by following * the comment with a hash mark ("#") and the comment. Example: * * This is {=the best answer#comment on the best answer ~75%a good * answer#comment on the good answer ~a wrong one#comment on the bad answer} * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_missingword extends qformat_default { public function provide_import() { return true; } /** * Validate the given file. * * For more expensive or detailed integrity checks. * * @param stored_file $file the file to check * @return string the error message that occurred while validating the given file */ public function validate_file(stored_file $file): string { return $this->validate_is_utf8_file($file); } public function readquestion($lines) { // Given an array of lines known to define a question in // this format, this function converts it into a question // object suitable for processing and insertion into Moodle. $question = $this->defaultquestion(); $comment = null; // Added by T Robb. $text = implode(" ", $lines); // Find answer section. $answerstart = strpos($text, "{"); if ($answerstart === false) { $this->error(get_string('beginanswernotfound', 'qformat_missingword'), $text); return false; } $answerfinish = strpos($text, "}"); if ($answerfinish === false) { $this->error(get_string('endanswernotfound', 'qformat_missingword'), $text); return false; } $answerlength = $answerfinish - $answerstart; $answertext = substr($text, $answerstart + 1, $answerlength - 1); // Save the new question text. $question->questiontext = substr_replace($text, "_____", $answerstart, $answerlength+1); $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question')); // Parse the answers. $answertext = str_replace("=", "~=", $answertext); $answers = explode("~", $answertext); if (isset($answers[0])) { $answers[0] = trim($answers[0]); } if (empty($answers[0])) { array_shift($answers); } $countanswers = count($answers); switch ($countanswers) { case 0: // Invalid question. $this->error(get_string('noanswerfound', 'qformat_missingword'), $answertext); return false; case 1: $question->qtype = 'shortanswer'; $answer = trim($answers[0]); if ($answer[0] == "=") { $answer = substr($answer, 1); } $question->answer[] = $answer; $question->fraction[] = 1; $question->feedback[] = array('text' => '', 'format' => FORMAT_HTML); return $question; default: $question->qtype = 'multichoice'; $question = $this->add_blank_combined_feedback($question); $question->single = 1; // Only one answer allowed. foreach ($answers as $key => $answer) { $answer = trim($answer); // Tom's addition starts here. $answeight = 0; if (strspn($answer, "1234567890%") > 0) { // Make sure that the percent sign is the last in the span. if (strpos($answer, "%") == strspn($answer, "1234567890%") - 1) { $answeight0 = substr($answer, 0, strspn($answer, "1234567890%")); $answeight = round(($answeight0/100), 2); $answer = substr($answer, (strspn($answer, "1234567890%"))); } } if ($answer[0] == "=") { $answeight = 1; } // Remove the protective underscore for leading numbers in answers. if ($answer[0] == "_") { $answer = substr($answer, 1); } $answer = trim($answer); if (strpos($answer, "#") > 0) { $hashpos = strpos($answer, "#"); $comment = substr(($answer), $hashpos+1); $answer = substr($answer, 0, $hashpos); } else { $comment = " "; } // End of Tom's addition. if ($answer[0] == "=") { $question->fraction[$key] = $answeight; $answer = substr($answer, 1); } else { $question->fraction[$key] = $answeight; } $question->answer[$key] = array('text' => $answer, 'format' => FORMAT_HTML); $question->feedback[$key] = array('text' => $comment, 'format' => FORMAT_HTML); } return $question; } } } PK\t"format/xml/lang/en/qformat_xml.phpnu[. /** * Strings for component 'qformat_xml', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_xml * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['invalidxml'] = 'Invalid XML file - string expected (use CDATA?)'; $string['pluginname'] = 'Moodle XML format'; $string['pluginname_help'] = 'This is a Moodle-specific format for importing and exporting questions.'; $string['pluginname_link'] = 'qformat/xml'; $string['privacy:metadata'] = 'The XML question format plugin does not store any personal data.'; $string['truefalseimporterror'] = 'Warning: The true/false question \'{$a->questiontext}\' could not be imported properly. It was not clear whether the correct answer is true or false. The question has been imported assuming that the answer is \'{$a->answer}\'. If this is not correct, you will need to edit the question.'; $string['unsupportedexport'] = 'Question type {$a} is not supported by XML export'; $string['xmlimportnoname'] = 'Missing question name in XML file'; $string['xmlimportnoquestion'] = 'Missing question text in XML file'; $string['xmltypeunsupported'] = 'Question type {$a} is not supported by XML import'; PK\YL66format/xml/version.phpnu[. /** * Version information for the calculated question type. * * @package qformat_xml * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_xml'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\V7'format/xml/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_xml. * * @package qformat_xml * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_xml\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_xml implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\TVll2format/xml/tests/fixtures/error_invalid_grades.xmlnu[ invalid grades : x > 1 & x < 2]]> ]]> 1.0000000 0.3333333 0 false true abc 0 Your answer is correct. Your answer is partially correct. Your answer is incorrect. 1

    ]]>
    2

    ]]>
    3

    ]]>
    4

    ]]>
    5

    ]]>
    PK\gS͢|i|i)format/xml/tests/fixtures/multichoice.xmlnu[ $module$/Default for testhead/Multichoice Greeting Listen to this greeting:

    What language is being spoken?

    ]]>
    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Fl7pz/jb6rABwGCAGDAGhSDiEYjCYFBTQjFYJHgumNR////////////+v69////////////7sToIDJhMBjQYMHDsFBgwCBDdpEfY20TiErmN0iYRIBteqiV1NLe81CJTXUoNyiIw8VAeLDFhQMML41sHTEYbBrIMVlcw8LjTAuaCxIzGXVfEoFMhG54CAIqFNyV/Xd56yYPTCzG6qYU61nheF5IKUtZ02WWOUqtKpuii81BRfNnAXD7ZoKY7khaoKOBNTRmrv05UBikgYAyIeNbxfsGhNUZAOB4XP4XDSeoX0MDAorApAOiIjOiWkfdiEMhcKNKEYuMJACDkt4qCQOBgwCAMZeCACCCOtosA4LgQlAAkDa6E0RCcvABA//LCwD145VuTxnQBnOAA8LFlX6lU8yoAghQIIF76FthgPtIBwAM3j9nrOc24l1nCFQE/YsAe/70ts5RICy7o6Dy4QiDIICRgYNhUXJfCME3/+Z3MgwAMVCoFBAQS4MTCMVFAYLyYYmFwDz//8Eqh0DLtlsoQ5AENltwENQMAiIA2f////////////JgBz////////////4dTEFNRTMuOTWqqqgAAHHsAAYVAUYBiiAh5BIHiRECotGiaPGD4vG5w/mDgamtpLmAwNmW/8GUxUGu6Dm2AuGVmHhAGGFaID4dmEYqmH4dnMYKBB1mAZgmvIQmFIyGWJxioCgAETJwvAUCq0jGkdS5AhAIHDSDAUMXgfRVCA1CojGKIKjgDFrhACQKIpTERhHOCEF0uSwAyARziqBcgWkUBk9xADLEyEBV9LRVqHgLL7GNwCr3HgJlooCpeEwDEgwaAWyztsinClBgaHimRZgBA2wwEBQNBgDgCMNgbkhfxsBbBp7WjBYGhAD6xnmBQBGAoE5MiMGgVbGFwNhquhiWpAAYqAigHmWQ9GP/ywsBMeP9qc8pgcZ3oAAgHBQANCAGCsACyw4DKlg8CQWDUKBGYShMYLE+YKBChjGB0DgICBEJJgWBTzRkIA0CAuBRKMUQCMNwssy53kIgEB6OMzFMv//8LARFy7wJBIgA4wjAYHCwWhMHAjKAixwwsdjhKBCA0FAyWAJKoFBcEDAsLDCMCh4LwwGtz0v68MDI7OwkkzVDqPAKNAIVAMLvMedTn////////////3+f////////////OcgAJmQABIvDDVFwE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T3y3///MCg7MVhnFiUMOhpMSAXMDAjMHg5yjL//ywsCcOINahC4EAZ3oABSqerSr////zAgDDC8RjBIQzFoWzGAiQCBhhSIRiiOxiAQte5yVQTMw7Eq3/////5jsGYyAxgABBjyXhmCQxhmERh0QJkMKZiUOJKTxluBRk2jxpKZFNTTU+wF0YQ1qozpGVpMC////////+YJjuAhCMTBlMKAyMIAdAIJBUGzCsPBEJpiwA5igRJi8DKPRgEAZgGDgcFUMujIHafaRv7KYq0p9pFqt3H//////////1Amxoqx1iLGoquZlbDnxdxyXqfNrUTkDSYTax5//rXcd81/7/9f///////////////1r8pyps0xBTUUzLjk1VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/8sLAaDysAAADSAGAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVTEFNRTMuOTVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//LCwByZ/wAAA0gBgAAAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk1VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==
    1.0000000 0.1000000 0 true true abc British Flag English

    ]]>
    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 Sorry, listen again.

    ]]>
    French flag French

    ]]>
    /9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAFAAeAwERAAIRAQMRAf/EALYAAAMBAQAAAAAAAAAAAAAAAAgJCgcLAQADAQEAAAAAAAAAAAAAAAAHCAkFChAAAAEHCQEIEwEAAAAAAAAAAgEREgMTBAUAFBUGFgcXCAkYMUFhMkIjdSchUYEisjOktDZmN0dnxzhIiLgKOREAAAEIAwUQEAcAAAAAAAAAAQARAhIDEwQFFAYHQaEzFRYhMVFxscHRIkKCI3M0hDUXgZFyorLCQ4OTo7O0RYU3CbXFJjZHCBj/2gAMAwEAAhEDEQA/ABDzlatOaG7e+OM1XgMTdiOEPeX53ViG9vQBjCoeWYCiCAKJCkCSVB6p2YVcmEpQiWyIrpAFwNAl3m9aZlDxYskB2oCJWtaIl9Vb8w+mBljvir4tAvrXXDGilVoBjWBFZ/MJexVdxMNZ35UYbBFJOzvkkolsMohZFaNMZVBAaGZUdXfQrFMb6QkYamxjWPq3DRbfCpvD9hqmiF4CFzXNzN3i5ctl2wDwrUWxxspZNasVpWewjmBmZCnRpxdu9uQTnsU0hnTvdLXldkq1fbbsdqpazlnlOiKWL8UOjAA4fGa+fxKBTaPWo1mPer0YbGx1lWhUw2DrmMJA9LyOKxY8O706DXricYa0gV5SkLuEMThlioRjdJmnECO2RADBpiAFQ6sFg1nUqrTIbNoOCZhATSKiEm7YUQFqkDGCiG6CAXAQBNkiIhdHNEcwABJ+oX9RFaekIp56WViKi9BM+5DULkCn/SCWmJdED+cv/GjJz+Qn7UX4SRG3v6szbmvucOR6s/8A2jCed9s0IL/6S/sx/Ir5FSXesvkd/wCKVzftFfyD8i/OSlJL6ZE6JJ4YpYbLkbXSDwgKjlcvrjVDjY38MjSW5mgs5jxeZaS3dLWqik58cY84FxJ5zrE/E5KJjSpel1mUdjQ1aG7BR2ZU2ofRLjODJh4m+O+WFZbPPsFUzpW7cOwXcRgHijhN1n2UmzBh7Zbw6dQP6yzw/CeSm2k41y1jcdnxnwK58/AMle8VIuVZomI2FB5Lt1fSJnvnLA9WDa06hNoHE73pWSnM3+HNPIeRH7khdNqNwdINujXj6xUV/o/1tfqfqtX+HUpXn7k/rSTu6T6dRZpb6l6Odpu0ZtGU+d02CPNpM0jpck5t6WajQXSRjKZh+2GuTeTj/R2WkppzzKR42oxzn5K2eGuYJ4e6X//Z Yes, well done!

    ]]>
    German flag German

    ]]>
    /9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAFAAeAwERAAIRAQMRAf/EAKYAAQACAwAAAAAAAAAAAAAAAAcACQEICgEAAQMFAQAAAAAAAAAAAAAAAAUGCQECAwQHCBAAAAMFBQMICwAAAAAAAAAAAQIFEQMEBggAMRIUByETFWEyldUWF1cYQVFxgZGhsSIjNSgRAAACBQYGDQ0AAAAAAAAAAAEEABECAwUhMRITBgdhk9MVFghBkaEiMtIjoxTUVVYXUYHBUmKiwnM1lSYnGP/aAAwDAQACEQMRAD8ApA1tqzq/RdQlxOSpRn4YGHinjtxuZTmM7vAUwlBhiQLLg9dk9qOQJkVNnSgDhfOw+JHm7uwvOfs1jizkfbdjMLMPNiA+cHIghOFZlahQYWVNRwALgCUpoAPgEFawY3Z0RWJwkI/OdcZMnhXer3atD9uOZFMDWZWoN8p6jD7ZSmgfrBWAjdnQmOksc64yVC629YJrNWi+3HMihAtVN1dKmqMnTVGImoIK0tPFR4kp3ZqYimO8UENUSYl4LgYPePTFgo94xgbL7ZAjMDFgWwNlKsJxrXatukpNdq7m8tl+yVagEdA01wWBIm6YyLFTNUsZAEZAmTqM1SpOqUjpyVomD0smN9DvIh4Lt4SPQylMAmFjAMslH5Wixjms9q/k4i8LmrSkWHzIygLsysNpwKT+2Rviu7LwNy6fRlyy8BkFhy3EQ78oVTvhPMvSKD13ZI/qjV370EMUZ6ujm8aLtu23HPZNJ5QqnfCeZekUHrux/VGrv3oIYoz1dDxou27bcc9k01zWqXah3Wu8mpDzTSYCqcTxTLwox6KLx5u0NVenwmBXF2GF27MO0wXWepXWRuLe2SMxR3aIkMPd0KTdWYUC3jABJULlEQCQNlOUxC9mwLd5BA6zF3IlmKxbXKqBbp4HqLnEAS+zXvzHdvY3gOS4EweG5HHzMX5M3j+7OYuf6GMZsZaHKC6C0X2ltZpHWjXUpl7FD2FcHZnXKtPBhnO62c3K6FR3qvTh8qCf9R8tlv8AUaav5Jh3Un9R8tj9RofkmHdQXU++PvXlvON7bNj+EXN/WR+Z92S3tn0U0H0ONVH0DeVmMYo+9RRKeZ2zk7pr6ZLR2hXuLT//2Q== Sorry, listen again.

    ]]>
    Denmark flag Danish

    ]]>
    /9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAFAAeAwERAAIRAQMRAf/EALoAAAMBAQAAAAAAAAAAAAAAAAYJCgQHAQACAgMBAAAAAAAAAAAAAAAHCQYIAwQFChAAAAAKBwIIDwEAAAAAAAAAARECEgMTBBQVFwAFBhYIGAkxByFBM2QnR2cZYSJC0iNDhJSkVShoqOgKGhEAAAMEBQQICxkAAAAAAAAAARECEgMTFAAEBRUGMRYHFyFBIqJjCBgJUWFxcoJDg2RFZSaBkaGx0TJCUmKSsjNTcyREpOQl5aYnZygZ/9oADAMBAAIRAxEAPwAlxSak2IOwu9CtLP1O3oAMbEna0KMJTQ0ElFEok5hBwCSTQBAClHsTaRLeqNpKq7lQMJEQyjT1R6DuJpolxRghxa1oulTDxCBHcpHKkxo6HX9xWbzMMeUyXTSjZ77z3jCxKlRH3ak1DxKwBONj6fbsHQwadcUWlhu6rvEo0y12EuXwhot/moNBGCtNmf2eCBXdlyQSABKYveJl+YRSc3vWMTHzFn96afNpX7WhiP2weeNHB8hLQt8iv3qfVoD1tqfYnWy3FlK2vIWgQVQzVwVDELU0EsrclamJMxgSNgRHFgZyGg4gkQgFgGEfALO6x3iCsu3tpLeiCqsgGUgIkIrWlAirqAoSDo05VocU3Q9YddqGCavZ7tbu26ysHr9SQF47RV6u+rKUugyA08cpbUZinYAgM6L99n9Gcnbb1hY3JzeJwTNCGJZhIQtUJVZznI+tDD9oloRUNls8YG6K6qp3REZEQOaZydKWV6dFdaNOZ71iYZdYi1iScVKRh3DFJoD9ffTsy60KMH1RNUTu2ZG9Bs55zzM6zJdXbl1L7s+t3GIxfvmru6+tW+jnukvSXq7kvoU5ORu3QmIULgnjTUT3JM7Z7FSuI/xH+WXnP5T5t5t3d4OvCYvCf7/qUGDJcLEi+wY3amf9RP2N/kx+v1BZylfEv2z7rS+/+IH8n/pz8+pyi1H9Kcc3n7srXZLnW5RdpGuH5jF8Sitm63qBW9yJQuaiLrRqkpys0QDjid5xxiY9Tf1u5yggkSmzNpaU5ZbYJo8g5CpFbW5mW68R2XYGshu8nj1ETN9mHCq75+bN9i21CYJpJNNGJEM2WLycM+Lev0UFH6ydXh3Vu7wFW7eSpNFs49vDQS1rNCdfXzMTkQTaLe7RdSjEMP8AKLzXszVrc2bkmhmC0Zl20903121k2KOC16c830p5hnPrzuhC1PY9H15/sRovDQ66cM3fwu/2/rMNnuDR72in+au1zeXeqOX8CzcU/G0Bku7H5lJ4+lLnXwtAJ+3XC+hRuf8AczvDfUwFzGiSBc/PbqmdDXMzlES5YPxuSGIXHTpVbMOQrMKJLspb6JREEXZFSE25ys87rFnZO9oz+XI2WpSsRGtv4psi2yp//9k= Sorry, listen again.

    ]]>
    PK\gWW)format/xml/tests/fixtures/multianswer.xmlnu[ $module$/Default for Test images in backup cloze with images Match the following cities with the correct state,

    California California flag or Arizona Arizona flag
    * San Francisco: {1:MULTICHOICE:=California#OK~Arizona#Wrong}
    * Tucson: {1:MULTICHOICE:California#Wrong~%100%Arizona#OK}
    * Los Angeles: {1:MULTICHOICE:=California#OK~Arizona#Wrong}
    * Phoenix: {1:MULTICHOICE:%0%California#Wrong~=Arizona#OK}
    The capital of France is {1:SHORTANSWER:%100%Paris#Congratulations!
    ~%50%Marseille#No, that is the second largest city in France (after
    Paris).~*#Wrong answer. The capital of France is Paris, of course.}.

    ]]>
    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
    This is a Moodle question Moodle logo

    ]]>
    iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAHzSURBVDhPxZFbSJNhGIDXERsVGbKawWJaoyAYGeqNYgdl6zenrhhUlB0I6iJEbUl0WF1UUK2DYhDkGghu7Z+HraisPLAsu+0ygi66kCioqwg2t6f///YhzAVBXfTc/HzP9/J8L/wG/pG8QDw+TE+Xn/O+dva07MW5vZayys3yNp/ZwPPRBPuOHqGisordDc00Ki42bqhAjQ3Jid8jAvoL9vIt4qs463G73VRtrcFiseDvuSYG+TwB7y/zfdzHpzeDWachAjeuXyWkPqZ21zZ2Nrioa/JQarOjlK8jes7Eh/s18GwHRMxkgka+3lvEi1ubcgM63Zea6W5v5ESLB4fDwRVvNam+eRBey3TAxqObNl76rXwLFvAlsIyhC2W5gZ+JJmYixQS9q8RZZzpghwEr/R2LpYGPIYWZqJnYSWNugCkPPDDx1JddTyeZqCetasMdS6WB9OQB7Xet5kmbITeQntoP/YWEfSZxFoy5SA2YCXeukAJ+vD5MMl7Cw9YF2UAkGhIXmYSbZLgI9WKxOOukx52kVAvq6eXS6HOHyAyuRz0lN5hlwg19hcTOWqXQ0FxaXZPnGC4V7s+BsToRUM8YpdA2eHswG/DO2eDV7WpGWg30dpZIA5Nddkba5nP3+Epp4F2vwqh3CXeOLZyzwV/wvwPwC58bSNEdAkhaAAAAAElFTkSuQmCC
    0.3333333 0
    PK\0O'ff5format/xml/tests/fixtures/html_chars_in_idnumbers.xmlnu[ $module$/Alpha This is Alpha category for test The inequalities < & > Alpha Question Testing Alpha Question

    ]]>
    1.0000000 1.0000000 0 T & F true false
    PK\Im447format/xml/tests/fixtures/category_with_description.xmlnu[ $module$/Alpha This is Alpha category for test alpha-idnumber Alpha Question Testing Alpha Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\4~Z^EE/format/xml/tests/fixtures/nested_categories.xmlnu[ $module$/Delta This is Delta category for test $module$/Delta/Epsilon This is Epsilon category for test $module$/Delta/Epsilon/Zeta This is Zeta category for test Zeta Question Testing Zeta Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\߆ >format/xml/tests/fixtures/nested_categories_with_questions.xmlnu[ $module$/Iota This is Iota category for test Iota Question Testing Iota Question

    ]]>
    1.0000000 1.0000000 0 true false
    $module$/Iota/Kappa This is Kappa category for test Kappa Essay Question Testing Kappa Essay Question 1.0000000 0.0000000 0 editor 1 10 0 0 0 Kappa Question Testing Kappa Question

    ]]>
    1.0000000 1.0000000 0 true false
    $module$/Iota/Kappa/Lambda This is Lambda category for test Lambda Question Testing Lambda Question

    ]]>
    1.0000000 1.0000000 0 true false
    $module$/Iota/Mu This is Mu category for test Mu Question Testing Mu Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\4format/xml/tests/fixtures/broken_cloze_questions.xmlnu[ Multianswer question without subquestions Please select the fruits. 0.3333333 0 Multichoice subquestion without choices Please select the fruits {1:MULTICHOICE} 0.3333333 0 Multichoice subquestion with only one choice Please select the fruits {1:MULTICHOICE:=Apple#Correct} 0.3333333 0 Multichoice subquestion with no completely correct answer Please select the fruits {1:MULTICHOICE:Pear#Incorrect~%50%Apple#Correct} 0.3333333 0 Numerical subquestion with buggy answer What grade would you give it? {3:NUMERICAL:=zero} 0.3333333 0 PK\GD'format/xml/tests/fixtures/truefalse.xmlnu[ $module$/Default for testhead/TrueFalse Moodle acronym (False) Moodle Moodle logo is an acronym for Modular Object-Oriented Dynamic Learning Education.

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBggGBQkIBwgKCQkKDRYODQwMDRoTFBAWHxwhICocHh4jJzIqIyUvLR4eNzssLy41ODg0JSo4QTAqPDwsNDUBCQoKDQsNGQ4OGTUkHiQ1NTU1NTU1NTU1NTUuNTU1LTU1NTU1NTQsMjU1NTU1NSk0KSw0MjQ2Miw1NDQyNDQ0Nf/AABEIADAAMAMBIgACEQEDEQH/xAAbAAEAAgIDAAAAAAAAAAAAAAAHAAYDBQEECP/EADUQAAEDAwEEBgcJAAAAAAAAAAECAwQABREGEiExURNBYXORsQcUFRZDYqEXIyQ2QnGywfD/xAAYAQEAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACIRAAEEAgIBBQAAAAAAAAAAAAMAAQIEETESIeEFUYGhsf/aAAwDAQACEQMRAD8AcalSsT8lmMkKfdbaSeBWoJH1oiy1K0z+sbDGTly7Q+WEuhR8BWW16lt16dUm3vF9KeLiUKCM8tojGaItpUqVKIqRq7Xr9sBj2i3yZEg/FUyoIT2jPH/caHbtepdzubqr3ch06fhLXgp7N/DwFOMwx/f9MZxoEuQUuBRJI3OKGMcOsdtHOsNMRGLxNu7TilqcfUVNuIBAwcYBGKpKcYcc3xlWjFMueDaVEj3Bj2mwOi9ZZ6QBaUfqGeA516GsKbTAtTD7LiSgpGyAN6ezZHDFG1j9GsyVLXd0pSwlP3jTSgOXnXfuM+VDiNR4qg2uSpKARu3msNu7MBIwjHPL9WqtViaDzeWkie9ltSrZcW432qbOK2caUzLZDsdxLrauCknIo6XpCdbrYJ65q3hu20L5E43eNcaZubtq1emEFH1eUkko6goddViuHhYYB2bvWF3OsKQXKF9e6sdzVI+0SGCn8N6go7Xz9Kn+qquskj2fK71f8qTHojTzyHlIBcQMJV1gccfQUa6ptl7lPSYzVv2mluKUle2N4Jzzp6oEheHBs4dcUiRg8uT46V8s35fa7s+VHN8QBNtffN+YpItTTjdjbQUkL6PGDzo6m2q9yrnEDtvKG47ySVBQO4H96i+EhDilBum39KapIxHNnfaQruM6acHyJ8xVAaSB6QIGOSqQbsy+7px1uO30j2wnZRnGSCKoVstt3e1dFly4JZbbyCQoGoshJK8ObN02O/l0CSLVpRd+38L/2Q==
    InformationsFor further information, see the documentation about Moodle.

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBggGBQkIBwgKCQkKGRYaDQwMDRodFBAhKhwlIB8lHh4jIjIoIyQvJSQeKy8sLzMtODgsISo9QTs2QTIuMywBCQoKDQsNGQ4OGTUkHiQ1NTU1NTU1NTU1NTU1NTU1Ly82NTU1NDUtLTQ0NTU1MCwsLCw0KTU0LzU2NjMpKTQ2Kf/AABEIADEAIgMBIgACEQEDEQH/xAAaAAEAAwEBAQAAAAAAAAAAAAAABQYHBAID/8QALhAAAQMCBAMHBAMAAAAAAAAAAgABAwQFBhEhMUFRgRITIjJxsfAHYWKRFBVC/8QAGQEAAgMBAAAAAAAAAAAAAAAAAAEDBAUC/8QAIREAAgEDBAMBAAAAAAAAAAAAAAECAxExBBITISIjQQX/2gAMAwEAAhEDEQA/ANxXk5AiHtSGINzJ8lz3GvitlCdTOTCAcVl16xZU3SoMhMoof8iz65fdWaGmlXfWCOpUUMmrDVwGbCM8RE+zMbZuvqsdvzR2WSme3Xf+W8o5n3ZeX11dsn5PyVowJjUrof8AX1xN3w+Qufzbq3TuppHCHJF3RzGqnLa8l6REVMmKV9UpzhsMLA7sJF4v2yySe5OD9mPV+LvwW84osg3+xzUj6G7eF23+cfVmWAXW01Vnrnp62JwJn0LLwn6fNFt/mzi4OH0p6iLvuJ3HZi01EUEQQCTFm0YMLPtyUdg+eePFNI8RF2vFx/F392ZSGN7va7sVva0yHI0Ik0jlG46u7ZZZ77Kx/TPBdQFc1zr4ii7HkAm1bjryd9NOWee7KS/Fo/Zl3FbdV8TV0RFgF0LiuFmoboDjWU4SZ75t78H6rtRCdgISiwdaKCbvYKUBPg4gIu3UWZ1MhGEQMEYsAtswtoy9Im5OXbYrWCIiQwiIgAiIgAiIgD//2Q==
    1.0000000 1.0000000 0 true Thumb down Your answer is incorrect.

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAdHx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5Ojf/2wBDAQoKCg0MDRoPDxo3JR8lNzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzf/wAARCAAYABgDASIAAhEBAxEB/8QAGgABAAIDAQAAAAAAAAAAAAAAAAQFAQMGB//EACcQAAIBAwMDBAMBAAAAAAAAAAECAwAEBQYRIRITMTJBUXEiUmKh/8QAFwEAAwEAAAAAAAAAAAAAAAAAAgQFA//EACIRAAIBAwQCAwAAAAAAAAAAAAECAwAEERIhMVEFQROh8f/aAAwDAQACEQMRAD8A9AubiTK5m5t5JpEihZlVUiaTbpO3pX5PvWy4wLR2Ml1Fc9QRS/TJAYzsPPnn2+KqsdcompsksjxoO7MAZLpoBv3P2Uf5XTX17BFpm7l7iSKI2jJhnM4DNwPyPJ9QPPipiKkgZn537q9N8kLIkfBx17+6qrd7vC5i2tZpFKzMoKoSVIY7b/e9KrchmoMtqXGS2qyKglhTZwAd+5v7fdKYtGB1BTsDtSfkY2XQzjDEb1jVGlb45Oe6sYmmincuQnJUnzx91GgsdQQ4efFrj5jBO4diYh1Ajbwer+RSlC1jGWLAkZ6/KJPLzhFQgHGOR1x7qTpfSt8MnBdX0TQxQOHAfgsR44+6UpW8ECwrpWlLq7kun1yV/9k=
    false Thumb up Your answer is correct.

    ]]>
    /9j/4AAQSkZJRgABAQEAYABgAAD/4RDARXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAExAAIAAAAuAAAIVgEyAAIAAAAUAAAIhIdpAAQAAAABAAAImOocAAcAAAgMAAAASgAAAAAc6gAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE1pY3Jvc29mdCBXaW5kb3dzIFBob3RvIFZpZXdlciA2LjEuNzYwMC4xNjM4NQAyMDEzOjAxOjEwIDE4OjI1OjUzAAAB6hwABwAACAwAAAiqAAAAABzqAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/4TG4aHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49J++7vycgaWQ9J1c1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCc/Pg0KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyI+PHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj48cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0idXVpZDpmYWY1YmRkNS1iYTNkLTExZGEtYWQzMS1kMzNkNzUxODJmMWIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyI+PHhtcDpDcmVhdG9yVG9vbD5NaWNyb3NvZnQgV2luZG93cyBQaG90byBWaWV3ZXIgNi4xLjc2MDAuMTYzODU8L3htcDpDcmVhdG9yVG9vbD48L3JkZjpEZXNjcmlwdGlvbj48L3JkZjpSREY+PC94OnhtcG1ldGE+DQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSd3Jz8+/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwEDBAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgAGAAYAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A99+OX7RviD/hMdS0fQrt9O0/TpzAzwLh5WUfP83+9WHqPjz4o6Z4D0zxZJ4n1FtPv5ngiCXKeYrLu+8vlf8ATJ61fB3wssfiL8W/FVjfyTRQ/a76dXhKht32nb/ED617p4Y8IadbfA3RbOSCKaFbZLtFvtPfUQkkh3N+6T5m/wBYy/L90e1fndHDYzHValWVVqOtrO2x+w4jG5RleGw9CGHjOS5XO63TXQ8T+CP7RviE+MtN0XxBdvqWn6lOsKNcDc8bsPl+f/eoqv4x0CGy+Lfhia2SCKNbuycrbaVLp6l/tP8Azykb9aK7MFjMRhoSo1ZtuL3/AKZ4ue4HA4ypSxNCkoKcU2vmzvdY0zV/hR4/1TUrOCNo7ySSRJpi5jZZG3bf97dV3Svi5LZ+GbXRbvShPFBGsHm2t+9qwVRhMbfmH3f71FFefi8TVwdepSouy1PXwOEoZlg6dfExvKy8tvQztH8PXHxB8d6TfQ2NxaWVpIksks13LcFfLfcB5sn95v4P9miiivpsqw8MRQdSpq2/L/I+SzitOjinRp6RirL+mf/Z
    Moodle acronym (True) Moodle Moodle logo is an acronym for Modular Object-Oriented Dynamic Learning Environment.

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBggGBQkIBwgKCQkKDRYODQwMDRoTFBAWHxwhICocHh4jJzIqIyUvLR4eNzssLy41ODg0JSo4QTAqPDwsNDUBCQoKDQsNGQ4OGTUkHiQ1NTU1NTU1NTU1NTUuNTU1LTU1NTU1NTQsMjU1NTU1NSk0KSw0MjQ2Miw1NDQyNDQ0Nf/AABEIADAAMAMBIgACEQEDEQH/xAAbAAEAAgIDAAAAAAAAAAAAAAAHAAYDBQEECP/EADUQAAEDAwEEBgcJAAAAAAAAAAECAwQABREGEiExURNBYXORsQcUFRZDYqEXIyQ2QnGywfD/xAAYAQEAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACIRAAEEAgIBBQAAAAAAAAAAAAMAAQIEETESIeEFUYGhsf/aAAwDAQACEQMRAD8AcalSsT8lmMkKfdbaSeBWoJH1oiy1K0z+sbDGTly7Q+WEuhR8BWW16lt16dUm3vF9KeLiUKCM8tojGaItpUqVKIqRq7Xr9sBj2i3yZEg/FUyoIT2jPH/caHbtepdzubqr3ch06fhLXgp7N/DwFOMwx/f9MZxoEuQUuBRJI3OKGMcOsdtHOsNMRGLxNu7TilqcfUVNuIBAwcYBGKpKcYcc3xlWjFMueDaVEj3Bj2mwOi9ZZ6QBaUfqGeA516GsKbTAtTD7LiSgpGyAN6ezZHDFG1j9GsyVLXd0pSwlP3jTSgOXnXfuM+VDiNR4qg2uSpKARu3msNu7MBIwjHPL9WqtViaDzeWkie9ltSrZcW432qbOK2caUzLZDsdxLrauCknIo6XpCdbrYJ65q3hu20L5E43eNcaZubtq1emEFH1eUkko6goddViuHhYYB2bvWF3OsKQXKF9e6sdzVI+0SGCn8N6go7Xz9Kn+qquskj2fK71f8qTHojTzyHlIBcQMJV1gccfQUa6ptl7lPSYzVv2mluKUle2N4Jzzp6oEheHBs4dcUiRg8uT46V8s35fa7s+VHN8QBNtffN+YpItTTjdjbQUkL6PGDzo6m2q9yrnEDtvKG47ySVBQO4H96i+EhDilBum39KapIxHNnfaQruM6acHyJ8xVAaSB6QIGOSqQbsy+7px1uO30j2wnZRnGSCKoVstt3e1dFly4JZbbyCQoGoshJK8ObN02O/l0CSLVpRd+38L/2Q==
    InformationsFor further information, see the documentation about Moodle

    .

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBggGBQkIBwgKCQkKGRYaDQwMDRodFBAhKhwlIB8lHh4jIjIoIyQvJSQeKy8sLzMtODgsISo9QTs2QTIuMywBCQoKDQsNGQ4OGTUkHiQ1NTU1NTU1NTU1NTU1NTU1Ly82NTU1NDUtLTQ0NTU1MCwsLCw0KTU0LzU2NjMpKTQ2Kf/AABEIADEAIgMBIgACEQEDEQH/xAAaAAEAAwEBAQAAAAAAAAAAAAAABQYHBAID/8QALhAAAQMCBAMHBAMAAAAAAAAAAgABAwQFBhEhMUFRgRITIjJxsfAHYWKRFBVC/8QAGQEAAgMBAAAAAAAAAAAAAAAAAAEDBAUC/8QAIREAAgEDBAMBAAAAAAAAAAAAAAECAxExBBITISIjQQX/2gAMAwEAAhEDEQA/ANxXk5AiHtSGINzJ8lz3GvitlCdTOTCAcVl16xZU3SoMhMoof8iz65fdWaGmlXfWCOpUUMmrDVwGbCM8RE+zMbZuvqsdvzR2WSme3Xf+W8o5n3ZeX11dsn5PyVowJjUrof8AX1xN3w+Qufzbq3TuppHCHJF3RzGqnLa8l6REVMmKV9UpzhsMLA7sJF4v2yySe5OD9mPV+LvwW84osg3+xzUj6G7eF23+cfVmWAXW01Vnrnp62JwJn0LLwn6fNFt/mzi4OH0p6iLvuJ3HZi01EUEQQCTFm0YMLPtyUdg+eePFNI8RF2vFx/F392ZSGN7va7sVva0yHI0Ik0jlG46u7ZZZ77Kx/TPBdQFc1zr4ii7HkAm1bjryd9NOWee7KS/Fo/Zl3FbdV8TV0RFgF0LiuFmoboDjWU4SZ75t78H6rtRCdgISiwdaKCbvYKUBPg4gIu3UWZ1MhGEQMEYsAtswtoy9Im5OXbYrWCIiQwiIgAiIgAiIgD//2Q==
    1.0000000 1.0000000 0 true thumb up Your answer is correct.

    ]]>
    /9j/4AAQSkZJRgABAQEAYABgAAD/4RDARXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAExAAIAAAAuAAAIVgEyAAIAAAAUAAAIhIdpAAQAAAABAAAImOocAAcAAAgMAAAASgAAAAAc6gAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE1pY3Jvc29mdCBXaW5kb3dzIFBob3RvIFZpZXdlciA2LjEuNzYwMC4xNjM4NQAyMDEzOjAxOjEwIDE4OjI1OjUzAAAB6hwABwAACAwAAAiqAAAAABzqAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/4TG4aHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49J++7vycgaWQ9J1c1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCc/Pg0KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyI+PHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj48cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0idXVpZDpmYWY1YmRkNS1iYTNkLTExZGEtYWQzMS1kMzNkNzUxODJmMWIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyI+PHhtcDpDcmVhdG9yVG9vbD5NaWNyb3NvZnQgV2luZG93cyBQaG90byBWaWV3ZXIgNi4xLjc2MDAuMTYzODU8L3htcDpDcmVhdG9yVG9vbD48L3JkZjpEZXNjcmlwdGlvbj48L3JkZjpSREY+PC94OnhtcG1ldGE+DQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSd3Jz8+/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwEDBAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgAGAAYAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A99+OX7RviD/hMdS0fQrt9O0/TpzAzwLh5WUfP83+9WHqPjz4o6Z4D0zxZJ4n1FtPv5ngiCXKeYrLu+8vlf8ATJ61fB3wssfiL8W/FVjfyTRQ/a76dXhKht32nb/ED617p4Y8IadbfA3RbOSCKaFbZLtFvtPfUQkkh3N+6T5m/wBYy/L90e1fndHDYzHValWVVqOtrO2x+w4jG5RleGw9CGHjOS5XO63TXQ8T+CP7RviE+MtN0XxBdvqWn6lOsKNcDc8bsPl+f/eoqv4x0CGy+Lfhia2SCKNbuycrbaVLp6l/tP8Azykb9aK7MFjMRhoSo1ZtuL3/AKZ4ue4HA4ypSxNCkoKcU2vmzvdY0zV/hR4/1TUrOCNo7ySSRJpi5jZZG3bf97dV3Svi5LZ+GbXRbvShPFBGsHm2t+9qwVRhMbfmH3f71FFefi8TVwdepSouy1PXwOEoZlg6dfExvKy8tvQztH8PXHxB8d6TfQ2NxaWVpIksks13LcFfLfcB5sn95v4P9miiivpsqw8MRQdSpq2/L/I+SzitOjinRp6RirL+mf/Z
    false thumb down Your answer is incorrect.

    ]]>
    /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAdHx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5Ojf/2wBDAQoKCg0MDRoPDxo3JR8lNzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzf/wAARCAAYABgDASIAAhEBAxEB/8QAGgABAAIDAQAAAAAAAAAAAAAAAAQFAQMGB//EACcQAAIBAwMDBAMBAAAAAAAAAAECAwAEBQYRIRITMTJBUXEiUmKh/8QAFwEAAwEAAAAAAAAAAAAAAAAAAgQFA//EACIRAAIBAwQCAwAAAAAAAAAAAAECAwAEERIhMVEFQROh8f/aAAwDAQACEQMRAD8A9AubiTK5m5t5JpEihZlVUiaTbpO3pX5PvWy4wLR2Ml1Fc9QRS/TJAYzsPPnn2+KqsdcompsksjxoO7MAZLpoBv3P2Uf5XTX17BFpm7l7iSKI2jJhnM4DNwPyPJ9QPPipiKkgZn537q9N8kLIkfBx17+6qrd7vC5i2tZpFKzMoKoSVIY7b/e9KrchmoMtqXGS2qyKglhTZwAd+5v7fdKYtGB1BTsDtSfkY2XQzjDEb1jVGlb45Oe6sYmmincuQnJUnzx91GgsdQQ4efFrj5jBO4diYh1Ajbwer+RSlC1jGWLAkZ6/KJPLzhFQgHGOR1x7qTpfSt8MnBdX0TQxQOHAfgsR44+6UpW8ECwrpWlLq7kun1yV/9k=
    PK\ 221format/xml/tests/fixtures/deprecated_category.xmlnu[ $course$/Alpha This is Alpha category for test alpha-idnumber Alpha Question Testing Alpha Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\]KK-format/xml/tests/fixtures/export_category.xmlnu[ $module$/Alpha This is Alpha category for test alpha-idnumber Alpha Question Testing Alpha Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\gy1  -format/xml/tests/fixtures/old_format_file.xmlnu[ $module$/Pi Pi Question Testing Pi Question

    ]]>
    1.0000000 1.0000000 0 true false
    $module$/Pi/Rho Rho Question Testing Rho Question

    ]]>
    1.0000000 1.0000000 0 true false
    PK\WWAformat/xml/tests/fixtures/sample_questions_with_old_image_tag.xmlnu[ $module$/Default for Test images in backup cloze question with image Match the following cities with the correct state:
    * San Francisco: {1:MULTICHOICE:=California#OK~Arizona#Wrong}
    * Tucson: {1:MULTICHOICE:California#Wrong~%100%Arizona#OK}
    * Los Angeles: {1:MULTICHOICE:=California#OK~Arizona#Wrong}
    * Phoenix: {1:MULTICHOICE:%0%California#Wrong~=Arizona#OK}
    The capital of France is {1:SHORTANSWER:%100%Paris#Congratulations!
    ~%50%Marseille#No, that is the second largest city in France (after
    Paris).~*#Wrong answer. The capital of France is Paris, of course.}.
    ]]>
    moodle.png iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAHzSURBVDhPxZFbSJNhGIDXERsVGbKawWJaoyAYGeqNYgdl6zenrhhUlB0I6iJEbUl0WF1UUK2DYhDkGghu7Z+HraisPLAsu+0ygi66kCioqwg2t6f///YhzAVBXfTc/HzP9/J8L/wG/pG8QDw+TE+Xn/O+dva07MW5vZayys3yNp/ZwPPRBPuOHqGisordDc00Ki42bqhAjQ3Jid8jAvoL9vIt4qs463G73VRtrcFiseDvuSYG+TwB7y/zfdzHpzeDWachAjeuXyWkPqZ21zZ2Nrioa/JQarOjlK8jes7Eh/s18GwHRMxkgka+3lvEi1ubcgM63Zea6W5v5ESLB4fDwRVvNam+eRBey3TAxqObNl76rXwLFvAlsIyhC2W5gZ+JJmYixQS9q8RZZzpghwEr/R2LpYGPIYWZqJnYSWNugCkPPDDx1JddTyeZqCetasMdS6WB9OQB7Xet5kmbITeQntoP/YWEfSZxFoy5SA2YCXeukAJ+vD5MMl7Cw9YF2UAkGhIXmYSbZLgI9WKxOOukx52kVAvq6eXS6HOHyAyuRz0lN5hlwg19hcTOWqXQ0FxaXZPnGC4V7s+BsToRUM8YpdA2eHswG/DO2eDV7WpGWg30dpZIA5Nddkba5nP3+Epp4F2vwqh3CXeOLZyzwV/wvwPwC58bSNEdAkhaAAAAAElFTkSuQmCC 0
    mcq with image moodle.png iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAHzSURBVDhPxZFbSJNhGIDXERsVGbKawWJaoyAYGeqNYgdl6zenrhhUlB0I6iJEbUl0WF1UUK2DYhDkGghu7Z+HraisPLAsu+0ygi66kCioqwg2t6f///YhzAVBXfTc/HzP9/J8L/wG/pG8QDw+TE+Xn/O+dva07MW5vZayys3yNp/ZwPPRBPuOHqGisordDc00Ki42bqhAjQ3Jid8jAvoL9vIt4qs463G73VRtrcFiseDvuSYG+TwB7y/zfdzHpzeDWachAjeuXyWkPqZ21zZ2Nrioa/JQarOjlK8jes7Eh/s18GwHRMxkgka+3lvEi1ubcgM63Zea6W5v5ESLB4fDwRVvNam+eRBey3TAxqObNl76rXwLFvAlsIyhC2W5gZ+JJmYixQS9q8RZZzpghwEr/R2LpYGPIYWZqJnYSWNugCkPPDDx1JddTyeZqCetasMdS6WB9OQB7Xet5kmbITeQntoP/YWEfSZxFoy5SA2YCXeukAJ+vD5MMl7Cw9YF2UAkGhIXmYSbZLgI9WKxOOukx52kVAvq6eXS6HOHyAyuRz0lN5hlwg19hcTOWqXQ0FxaXZPnGC4V7s+BsToRUM8YpdA2eHswG/DO2eDV7WpGWg30dpZIA5Nddkba5nP3+Epp4F2vwqh3CXeOLZyzwV/wvwPwC58bSNEdAkhaAAAAAElFTkSuQmCC 1 0.1 0 1 true true abc wrong answer another wrong answer right answer
    PK\*"6format/xml/tests/fixtures/categories_reverse_order.xmlnu[ $module$/Sigma/Tau This is Tau category for test Tau Question Testing Tau Question 1.0000000 0.0000000 0 editor 1 15 0 0 $module$/Sigma This is Sigma category for test Sigma Question Testing Sigma Question 1.0000000 0.3333333 0 0 yes no may be PK\4Vaa3format/xml/tests/qformat_xml_import_export_test.phpnu[. /** * Unit tests for export/import description (info) for question category in the Moodle XML format. * * @package qformat_xml * @copyright 2014 Nikita Nikitsky, Volgograd State Technical University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format/xml/format.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/editlib.php'); /** * Unit tests for the XML question format import and export. * * @copyright 2014 Nikita Nikitsky, Volgograd State Technical University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qformat_xml */ final class qformat_xml_import_export_test extends advanced_testcase { /** @var stdClass mod_qbank instance */ private stdClass $qbank; /** * Create object qformat_xml for test. * @param string $filename with name for testing file. * @return qformat_xml XML question format object. */ public function create_qformat($filename) { $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qformat = new qformat_xml(); $qformat->setContexts([context_module::instance($qbank->cmid)]); $qformat->setCourse($course); $qformat->setFilename(__DIR__ . '/fixtures/' . $filename); $qformat->setRealfilename($filename); $qformat->setMatchgrades('error'); $qformat->setCatfromfile(1); $qformat->setContextfromfile(1); $qformat->setStoponerror(1); $qformat->setCattofile(1); $qformat->setContexttofile(1); $qformat->set_display_progress(false); $this->qbank = $qbank; return $qformat; } /** * Check xml for compliance. * @param string $expectedxml with correct string. * @param string $xml you want to check. */ public function assert_same_xml($expectedxml, $xml) { $this->assertEquals($this->normalise_xml($expectedxml), $this->normalise_xml($xml)); } /** * Clean up some XML to remove irrelevant differences, before it is compared. * @param string $xml some XML. * @return string cleaned-up XML. */ protected function normalise_xml($xml) { // Normalise line endings. $xml = phpunit_util::normalise_line_endings($xml); $xml = preg_replace("~\n$~", "", $xml); // Strip final newline in file. // Replace all numbers in question id comments with 0. $xml = preg_replace('~(?<=)~', '0', $xml); // Deal with how different databases output numbers. Only match when only thing in a tag. $xml = preg_replace("~>.0000000<~", '>0<', $xml); // Needed by MS SQL Server database. $xml = preg_replace("~(\.(:?[0-9]*[1-9])?)0*<~", '$1<', $xml); // Other cases of trailing 0s $xml = preg_replace("~([0-9]).<~", '$1<', $xml); // Stray . in 1. after last step. return $xml; } /** * Check imported category. * @param string $name imported category name. * @param string $info imported category info field (description of category). * @param int $infoformat imported category info field format. */ public function assert_category_imported($name, $info, $infoformat, $idnumber = null) { global $DB; $category = $DB->get_record('question_categories', ['name' => $name], '*', MUST_EXIST); $this->assertEquals($info, $category->info); $this->assertEquals($infoformat, $category->infoformat); $this->assertSame($idnumber, $category->idnumber); } /** * Check a question category has a given parent. * @param string $catname Name of the question category * @param string $parentname Name of the parent category * @throws dml_exception */ public function assert_category_has_parent($catname, $parentname) { global $DB; $sql = 'SELECT qc1.* FROM {question_categories} qc1 JOIN {question_categories} qc2 ON qc1.parent = qc2.id WHERE qc1.name = ? AND qc2.name = ?'; $categories = $DB->get_records_sql($sql, [$catname, $parentname]); $this->assertTrue(count($categories) == 1); } /** * Check a question exists in a category. * @param string $qname The name of the question * @param string $catname The name of the category * @throws dml_exception */ public function assert_question_in_category($qname, $catname) { global $DB; $sql = "SELECT q.*, qbe.questioncategoryid AS category FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.name = :name"; $question = $DB->get_record_sql($sql, ['name' => $qname], MUST_EXIST); $category = $DB->get_record('question_categories', ['name' => $catname], '*', MUST_EXIST); $this->assertEquals($category->id, $question->category); } /** * Simple check for importing a category with a description. */ public function test_import_category(): void { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $this->setAdminUser(); $qformat = $this->create_qformat('category_with_description.xml'); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Alpha', 'This is Alpha category for test', FORMAT_MOODLE, 'alpha-idnumber'); $this->assert_category_has_parent('Alpha', 'top'); } /** * Check importing categories that were in a now deprecated context. * * @return void * @covers \qformat_default::importprocess() */ public function test_deprecated_category_import(): void { $this->resetAfterTest(); self::setAdminUser(); $qformat = $this->create_qformat('deprecated_category.xml'); $cat = question_get_default_category($qformat->contexts[0]->id, true); $qformat->setCategory($cat); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Alpha', 'This is Alpha category for test', FORMAT_MOODLE, 'alpha-idnumber'); $this->assert_category_has_parent('Alpha', 'top'); } /** * Check importing nested categories. */ public function test_import_nested_categories(): void { $this->resetAfterTest(); $this->setAdminUser(); $qformat = $this->create_qformat('nested_categories.xml'); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Delta', 'This is Delta category for test', FORMAT_PLAIN); $this->assert_category_imported('Epsilon', 'This is Epsilon category for test', FORMAT_MARKDOWN); $this->assert_category_imported('Zeta', 'This is Zeta category for test', FORMAT_MOODLE); $this->assert_category_has_parent('Delta', 'top'); $this->assert_category_has_parent('Epsilon', 'Delta'); $this->assert_category_has_parent('Zeta', 'Epsilon'); } /** * Check importing nested categories contain the right questions. */ public function test_import_nested_categories_with_questions(): void { $this->resetAfterTest(); $this->setAdminUser(); $qformat = $this->create_qformat('nested_categories_with_questions.xml'); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Iota', 'This is Iota category for test', FORMAT_PLAIN); $this->assert_category_imported('Kappa', 'This is Kappa category for test', FORMAT_MARKDOWN); $this->assert_category_imported('Lambda', 'This is Lambda category for test', FORMAT_MOODLE); $this->assert_category_imported('Mu', 'This is Mu category for test', FORMAT_MOODLE); $this->assert_question_in_category('Iota Question', 'Iota'); $this->assert_question_in_category('Kappa Question', 'Kappa'); $this->assert_question_in_category('Lambda Question', 'Lambda'); $this->assert_question_in_category('Mu Question', 'Mu'); $this->assert_category_has_parent('Iota', 'top'); $this->assert_category_has_parent('Kappa', 'Iota'); $this->assert_category_has_parent('Lambda', 'Kappa'); $this->assert_category_has_parent('Mu', 'Iota'); } /** * Check import of an old file (without format), for backward compatability. */ public function test_import_old_format(): void { $this->resetAfterTest(); $this->setAdminUser(); $qformat = $this->create_qformat('old_format_file.xml'); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Pi', '', FORMAT_MOODLE); $this->assert_category_imported('Rho', '', FORMAT_MOODLE); $this->assert_question_in_category('Pi Question', 'Pi'); $this->assert_question_in_category('Rho Question', 'Rho'); $this->assert_category_has_parent('Pi', 'top'); $this->assert_category_has_parent('Rho', 'Pi'); } /** * Check the import of an xml file where the child category exists before the parent category. */ public function test_import_categories_in_reverse_order(): void { $this->resetAfterTest(); $this->setAdminUser(); $qformat = $this->create_qformat('categories_reverse_order.xml'); $imported = $qformat->importprocess(); $this->assertTrue($imported); $this->assert_category_imported('Sigma', 'This is Sigma category for test', FORMAT_HTML); $this->assert_category_imported('Tau', 'This is Tau category for test', FORMAT_HTML); $this->assert_question_in_category('Sigma Question', 'Sigma'); $this->assert_question_in_category('Tau Question', 'Tau'); $this->assert_category_has_parent('Sigma', 'top'); $this->assert_category_has_parent('Tau', 'Sigma'); } /** * Check exception when importing questions with invalid grades. * * @covers \qformat_default::importprocess */ public function test_import_invalid_grades(): void { global $OUTPUT; $this->resetAfterTest(true); $this->setAdminUser(); $qformat = $this->create_qformat('error_invalid_grades.xml'); ob_start(); $imported = $qformat->importprocess(); $output = ob_get_clean(); $a = ['grades' => '0.33', 'question' => 'Question with invalid grades : x > 1 & x < 2']; $expectedoutput = $OUTPUT->notification(get_string('invalidgradequestion', 'question', $a)); $expectedoutput .= $OUTPUT->notification(get_string('importparseerror', 'question')); $this->assertFalse($imported); $this->assertEquals($expectedoutput, $output); } /** * Simple check for exporting a category. */ public function test_export_category(): void { global $SITE; $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->resetAfterTest(); $this->setAdminUser(); // Note while this loads $qformat with all the 'right' data from the xml file, // the call to setCategory, followed by exportprocess will actually only export data // from the database (created by the generator). $qformat = $this->create_qformat('export_category.xml'); $category = $generator->create_question_category([ 'name' => 'Alpha', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Alpha category for test', 'infoformat' => '0', 'idnumber' => 'alpha-idnumber', 'stamp' => make_unique_id_code(), 'parent' => '0', 'sortorder' => '999']); $question = $generator->create_question('truefalse', null, [ 'category' => $category->id, 'name' => 'Alpha Question', 'questiontext' => ['format' => '1', 'text' => '

    Testing Alpha Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $qformat->setCategory($category); $expectedxml = file_get_contents(self::get_fixture_path('qformat_xml', 'export_category.xml')); $this->assert_same_xml($expectedxml, $qformat->exportprocess()); } /** * Check exporting nested categories. */ public function test_export_nested_categories(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qformat = $this->create_qformat('nested_categories.xml'); $categorydelta = $generator->create_question_category([ 'name' => 'Delta', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Delta category for test', 'infoformat' => '2', 'stamp' => make_unique_id_code(), 'parent' => '0', 'sortorder' => '999']); $categoryepsilon = $generator->create_question_category([ 'name' => 'Epsilon', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Epsilon category for test', 'infoformat' => '4', 'stamp' => make_unique_id_code(), 'parent' => $categorydelta->id, 'sortorder' => '999']); $categoryzeta = $generator->create_question_category([ 'name' => 'Zeta', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Zeta category for test', 'infoformat' => '0', 'stamp' => make_unique_id_code(), 'parent' => $categoryepsilon->id, 'sortorder' => '999']); $question = $generator->create_question('truefalse', null, [ 'category' => $categoryzeta->id, 'name' => 'Zeta Question', 'questiontext' => [ 'format' => '1', 'text' => '

    Testing Zeta Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $qformat->setCategory($categorydelta); $qformat->setCategory($categoryepsilon); $qformat->setCategory($categoryzeta); $expectedxml = file_get_contents(__DIR__ . '/fixtures/nested_categories.xml'); $this->assert_same_xml($expectedxml, $qformat->exportprocess()); } /** * Check exporting nested categories contain the right questions. */ public function test_export_nested_categories_with_questions(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qformat = $this->create_qformat('nested_categories_with_questions.xml'); $categoryiota = $generator->create_question_category([ 'name' => 'Iota', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Iota category for test', 'infoformat' => '2', 'stamp' => make_unique_id_code(), 'parent' => '0', 'sortorder' => '999']); $iotaquestion = $generator->create_question('truefalse', null, [ 'category' => $categoryiota->id, 'name' => 'Iota Question', 'questiontext' => [ 'format' => '1', 'text' => '

    Testing Iota Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $categorykappa = $generator->create_question_category([ 'name' => 'Kappa', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Kappa category for test', 'infoformat' => '4', 'stamp' => make_unique_id_code(), 'parent' => $categoryiota->id, 'sortorder' => '999']); $kappaquestion = $generator->create_question('essay', null, [ 'category' => $categorykappa->id, 'name' => 'Kappa Essay Question', 'questiontext' => [ 'format' => '0', 'text' => 'Testing Kappa Essay Question', ], 'generalfeedback' => '', 'responseformat' => 'editor', 'responserequired' => 1, 'responsefieldlines' => 10, 'attachments' => 0, 'attachmentsrequired' => 0, 'graderinfo' => ['format' => '1', 'text' => ''], 'responsetemplate' => ['format' => '1', 'text' => ''], 'idnumber' => '']); $kappaquestion1 = $generator->create_question('truefalse', null, [ 'category' => $categorykappa->id, 'name' => 'Kappa Question', 'questiontext' => [ 'format' => '1', 'text' => '

    Testing Kappa Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1', 'idnumber' => '']); $categorylambda = $generator->create_question_category([ 'name' => 'Lambda', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Lambda category for test', 'infoformat' => '0', 'stamp' => make_unique_id_code(), 'parent' => $categorykappa->id, 'sortorder' => '999']); $lambdaquestion = $generator->create_question('truefalse', null, [ 'category' => $categorylambda->id, 'name' => 'Lambda Question', 'questiontext' => [ 'format' => '1', 'text' => '

    Testing Lambda Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $categorymu = $generator->create_question_category([ 'name' => 'Mu', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Mu category for test', 'infoformat' => '0', 'stamp' => make_unique_id_code(), 'parent' => $categoryiota->id, 'sortorder' => '999']); $muquestion = $generator->create_question('truefalse', null, [ 'category' => $categorymu->id, 'name' => 'Mu Question', 'questiontext' => [ 'format' => '1', 'text' => '

    Testing Mu Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $qformat->setCategory($categoryiota); $expectedxml = file_get_contents(self::get_fixture_path('qformat_xml', 'nested_categories_with_questions.xml')); $this->assert_same_xml($expectedxml, $qformat->exportprocess()); } /** * Simple check for exporting a category. */ public function test_export_category_with_special_chars(): void { global $SITE; $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->resetAfterTest(); $this->setAdminUser(); // Note while this loads $qformat with all the 'right' data from the xml file, // the call to setCategory, followed by exportprocess will actually only export data // from the database (created by the generator). $qformat = $this->create_qformat('export_category.xml'); $category = $generator->create_question_category([ 'name' => 'Alpha', 'contextid' => context_module::instance($this->qbank->cmid)->id, 'info' => 'This is Alpha category for test', 'infoformat' => '0', 'idnumber' => 'The inequalities < & >', 'stamp' => make_unique_id_code(), 'parent' => '0', 'sortorder' => '999']); $generator->create_question('truefalse', null, [ 'category' => $category->id, 'name' => 'Alpha Question', 'questiontext' => ['format' => '1', 'text' => '

    Testing Alpha Question

    '], 'generalfeedback' => ['format' => '1', 'text' => ''], 'idnumber' => 'T & F', 'correctanswer' => '1', 'feedbacktrue' => ['format' => '1', 'text' => ''], 'feedbackfalse' => ['format' => '1', 'text' => ''], 'penalty' => '1']); $qformat->setCategory($category); $expectedxml = file_get_contents(self::get_fixture_path('qformat_xml', 'html_chars_in_idnumbers.xml')); $this->assert_same_xml($expectedxml, $qformat->exportprocess()); } /** * Test that bad multianswer questions are not imported. */ public function test_import_broken_multianswer_questions(): void { $lines = file(self::get_fixture_path('qformat_xml', 'broken_cloze_questions.xml')); $importer = $qformat = new qformat_xml(); // The importer echoes some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question', $output); $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output); $this->assertStringContainsString('This type of question requires at least 2 choices', $output); $this->assertStringContainsString('The answer must be a number, for example -1.234 or 3e8, or \'*\'.', $output); $this->assertStringContainsString('One of the answers should have a score of 100% so it is possible to get full marks for this question.', $output); $this->assertStringContainsString('The question text must include at least one embedded answer.', $output); // No question have been imported. $this->assertCount(0, $questions); } } PK\9#format/xml/tests/xmlformat_test.phpnu[. /** * Unit tests for the Moodle XML format. * * @package qformat_xml * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_xml; use qformat_xml; use qtype_numerical_answer; use question_answer; use question_bank; use question_check_specified_fields_expectation; use question_hint; use question_hint_with_parts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format/xml/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the matching question definition class. * * @package qformat_xml * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class xmlformat_test extends \question_testcase { public function make_test_question() { global $USER; $q = new \stdClass(); $q->id = 0; $q->contextid = 0; $q->idnumber = null; $q->category = 0; $q->parent = 0; $q->questiontextformat = FORMAT_HTML; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; $q->modifiedby = $USER->id; return $q; } /** * The data the XML import format sends to save_question is not exactly * the same as the data returned from the editing form, so this method * makes necessary changes to the return value of * \test_question_maker::get_question_form_data so that the tests can work. * @param object $expectedq as returned by get_question_form_data. * @return object one more likely to match the return value of import_...(). */ public function remove_irrelevant_form_data_fields($expectedq) { return $this->itemid_to_files($expectedq); } /** * Becuase XML import uses a files array instead of an itemid integer to * handle saving files with a question, we need to covert the output of * \test_question_maker::get_question_form_data to match. This method recursively * replaces all array elements with key itemid with an array entry with * key files and value an empty array. * * @param mixed $var any data structure. * @return mixed an equivalent structure with the relacements made. */ protected function itemid_to_files($var) { if (is_object($var)) { $newvar = new \stdClass(); foreach (get_object_vars($var) as $field => $value) { $newvar->$field = $this->itemid_to_files($value); } } else if (is_array($var)) { $newvar = array(); foreach ($var as $index => $value) { if ($index === 'itemid') { $newvar['files'] = array(); } else { $newvar[$index] = $this->itemid_to_files($value); } } } else { $newvar = $var; } return $newvar; } public function test_xml_escape_simple_input_not_escaped(): void { $exporter = new qformat_xml(); $string = 'Nothing funny here. Even if we go to a café or to 日本.'; $this->assertEquals($string, $exporter->xml_escape($string)); } public function test_xml_escape_html_wrapped_in_cdata(): void { $exporter = new qformat_xml(); $string = '

    Nothing funny here. Even if we go to a café or to 日本.

    '; $this->assertEquals('', $exporter->xml_escape($string)); } public function test_xml_escape_script_tag_handled_ok(): void { $exporter = new qformat_xml(); $input = ''; $expected = ']]>'; $this->assertEquals($expected, $exporter->xml_escape($input)); // Check that parsing the expected result does give the input again. $parsed = simplexml_load_string('
    ' . $expected . '
    '); $this->assertEquals($input, $parsed->xpath('//div')[0]); } public function test_xml_escape_code_that_looks_like_cdata_end_ok(): void { $exporter = new qformat_xml(); $input = "if (x[[0]]>a) print('hah');"; $expected = "a) print('hah');]]>"; $this->assertEquals($expected, $exporter->xml_escape($input)); // Check that parsing the expected result does give the input again. $parsed = simplexml_load_string('
    ' . $expected . '
    '); $this->assertEquals($input, $parsed->xpath('//div')[0]); } public function test_write_hint_basic(): void { $q = $this->make_test_question(); $q->contextid = \context_system::instance()->id; $q->name = 'Short answer question'; $q->questiontext = 'Name an amphibian: __________'; $q->generalfeedback = 'Generalfeedback: frog or toad would have been OK.'; if (!isset($q->options)) { $q->options = new \stdClass(); } $q->options->usecase = false; $q->options->answers = array( 13 => new question_answer(13, 'frog', 1.0, 'Frog is a very good answer.', FORMAT_HTML), 14 => new question_answer(14, 'toad', 0.8, 'Toad is an OK good answer.', FORMAT_HTML), 15 => new question_answer(15, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), ); $q->qtype = 'shortanswer'; $q->hints = array( new question_hint(0, 'This is the first hint.', FORMAT_MOODLE), ); $exporter = new qformat_xml(); $xml = $exporter->writequestion($q); $this->assertMatchesRegularExpression('|\s*\s*' . 'This is the first hint\.\s*\s*|', $xml); $this->assertDoesNotMatchRegularExpression('||', $xml); $this->assertDoesNotMatchRegularExpression('||', $xml); $this->assertDoesNotMatchRegularExpression('||', $xml); } public function test_write_hint_with_parts(): void { $q = $this->make_test_question(); $q->contextid = \context_system::instance()->id; $q->name = 'Matching question'; $q->questiontext = 'Classify the animals.'; $q->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.'; $q->qtype = 'match'; if (!isset($q->options)) { $q->options = new \stdClass(); } $q->options->shuffleanswers = 1; $q->options->correctfeedback = ''; $q->options->correctfeedbackformat = FORMAT_HTML; $q->options->partiallycorrectfeedback = ''; $q->options->partiallycorrectfeedbackformat = FORMAT_HTML; $q->options->incorrectfeedback = ''; $q->options->incorrectfeedbackformat = FORMAT_HTML; $q->options->subquestions = array(); $q->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, false, true), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, false), ); $exporter = new qformat_xml(); $xml = $exporter->writequestion($q); $this->assertMatchesRegularExpression( '|\s*\s*This is the first hint\.\s*|', $xml); $this->assertMatchesRegularExpression( '|\s*\s*This is the second hint\.\s*|', $xml); list($ignored, $hint1, $hint2) = explode('assertDoesNotMatchRegularExpression('||', $hint1); $this->assertMatchesRegularExpression('||', $hint1); $this->assertMatchesRegularExpression('||', $hint2); $this->assertDoesNotMatchRegularExpression('||', $hint2); $this->assertDoesNotMatchRegularExpression('||', $xml); } public function test_import_hints_no_parts(): void { $xml = << This is the first hint This is the second hint
    END; $questionxml = xmlize($xml); $qo = new \stdClass(); $importer = new qformat_xml(); $importer->import_hints($qo, $questionxml['question'], false, false, 'html'); $this->assertEquals(array( array('text' => 'This is the first hint', 'format' => FORMAT_HTML), array('text' => 'This is the second hint', 'format' => FORMAT_HTML), ), $qo->hint); $this->assertFalse(isset($qo->hintclearwrong)); $this->assertFalse(isset($qo->hintshownumcorrect)); } public function test_import_hints_with_parts(): void { $xml = << This is the first hint This is the second hint
    END; $questionxml = xmlize($xml); $qo = new \stdClass(); $importer = new qformat_xml(); $importer->import_hints($qo, $questionxml['question'], true, true, 'html'); $this->assertEquals(array( array('text' => 'This is the first hint', 'format' => FORMAT_HTML), array('text' => 'This is the second hint', 'format' => FORMAT_HTML), ), $qo->hint); $this->assertEquals(array(1, 0), $qo->hintclearwrong); $this->assertEquals(array(0, 1), $qo->hintshownumcorrect); } public function test_import_no_hints_no_error(): void { $xml = <<
    END; $questionxml = xmlize($xml); $qo = new \stdClass(); $importer = new qformat_xml(); $importer->import_hints($qo, $questionxml['question'], 'html'); $this->assertFalse(isset($qo->hint)); } public function test_import_description(): void { $xml = ' A description The question text. Here is some general feedback. 0 0 0 tagDescription tagTest '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_description($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'description'; $expectedq->name = 'A description'; $expectedq->questiontext = 'The question text.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = 'Here is some general feedback.'; $expectedq->defaultmark = 0; $expectedq->length = 0; $expectedq->penalty = 0; $expectedq->tags = array('tagDescription', 'tagTest'); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_description(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'description'; $qdata->name = 'A description'; $qdata->questiontext = 'The question text.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'Here is some general feedback.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' A description The question text. Here is some general feedback. 0 0 0 '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_essay_20(): void { $xml = ' An essay Write something. I hope you wrote something interesting. 1 0 0 tagEssay tagEssay20 tagTest '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_essay($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'essay'; $expectedq->name = 'An essay'; $expectedq->questiontext = 'Write something.'; $expectedq->questiontextformat = FORMAT_MOODLE; $expectedq->generalfeedback = 'I hope you wrote something interesting.'; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0; $expectedq->responseformat = 'editor'; $expectedq->responserequired = 1; $expectedq->responsefieldlines = 15; $expectedq->minwordlimit = null; $expectedq->minwordenabled = false; $expectedq->maxwordlimit = null; $expectedq->maxwordenabled = false; $expectedq->attachments = 0; $expectedq->attachmentsrequired = 0; $expectedq->maxbytes = 0; $expectedq->filetypeslist = null; $expectedq->graderinfo['text'] = ''; $expectedq->graderinfo['format'] = FORMAT_MOODLE; $expectedq->responsetemplate['text'] = ''; $expectedq->responsetemplate['format'] = FORMAT_MOODLE; $expectedq->tags = array('tagEssay', 'tagEssay20', 'tagTest'); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_essay_21(): void { $xml = ' An essay Write something. I hope you wrote something interesting. 1 0 0 monospaced 0 42 -1 1 Grade generously!

    ]]>
    Here is something really interesting.

    ]]>
    tagEssay tagEssay21 tagTest
    '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_essay($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'essay'; $expectedq->name = 'An essay'; $expectedq->questiontext = 'Write something.'; $expectedq->questiontextformat = FORMAT_MOODLE; $expectedq->generalfeedback = 'I hope you wrote something interesting.'; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0; $expectedq->responseformat = 'monospaced'; $expectedq->responserequired = 0; $expectedq->responsefieldlines = 42; $expectedq->minwordlimit = null; $expectedq->minwordenabled = false; $expectedq->maxwordlimit = null; $expectedq->maxwordenabled = false; $expectedq->attachments = -1; $expectedq->attachmentsrequired = 1; $expectedq->maxbytes = 0; $expectedq->filetypeslist = null; $expectedq->graderinfo['text'] = '

    Grade generously!

    '; $expectedq->graderinfo['format'] = FORMAT_HTML; $expectedq->responsetemplate['text'] = '

    Here is something really interesting.

    '; $expectedq->responsetemplate['format'] = FORMAT_HTML; $expectedq->tags = array('tagEssay', 'tagEssay21', 'tagTest'); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_essay_311(): void { $xml = ' An essay Write something. I hope you wrote something interesting. 1 0 0 monospaced 0 42 10 20 -1 1 52428800 .pdf,.zip.,.docx Grade generously!

    ]]>
    Here is something really interesting.

    ]]>
    tagEssay tagEssay21 tagTest
    '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_essay($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'essay'; $expectedq->name = 'An essay'; $expectedq->questiontext = 'Write something.'; $expectedq->questiontextformat = FORMAT_MOODLE; $expectedq->generalfeedback = 'I hope you wrote something interesting.'; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0; $expectedq->responseformat = 'monospaced'; $expectedq->responserequired = 0; $expectedq->responsefieldlines = 42; $expectedq->minwordlimit = 10; $expectedq->minwordenabled = true; $expectedq->maxwordlimit = 20; $expectedq->maxwordenabled = true; $expectedq->attachments = -1; $expectedq->attachmentsrequired = 1; $expectedq->maxbytes = 52428800; // 50MB. $expectedq->filetypeslist = '.pdf,.zip.,.docx'; $expectedq->graderinfo['text'] = '

    Grade generously!

    '; $expectedq->graderinfo['format'] = FORMAT_HTML; $expectedq->responsetemplate['text'] = '

    Here is something really interesting.

    '; $expectedq->responsetemplate['format'] = FORMAT_HTML; $expectedq->tags = array('tagEssay', 'tagEssay21', 'tagTest'); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_essay(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'essay'; $qdata->name = 'An essay'; $qdata->questiontext = 'Write something.'; $qdata->questiontextformat = FORMAT_MOODLE; $qdata->generalfeedback = 'I hope you wrote something interesting.'; $qdata->generalfeedbackformat = FORMAT_MOODLE; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->id = 456; $qdata->options->questionid = 123; $qdata->options->responseformat = 'monospaced'; $qdata->options->responserequired = 0; $qdata->options->responsefieldlines = 42; $qdata->options->minwordlimit = 10; $qdata->options->maxwordlimit = 20; $qdata->options->attachments = -1; $qdata->options->attachmentsrequired = 1; $qdata->options->graderinfo = '

    Grade generously!

    '; $qdata->options->graderinfoformat = FORMAT_HTML; $qdata->options->responsetemplate = '

    Here is something really interesting.

    '; $qdata->options->responsetemplateformat = FORMAT_HTML; $qdata->options->maxbytes = 52428800; // 50MB. $qdata->options->filetypeslist = '.pdf,.zip.,.docx'; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' An essay Write something. I hope you wrote something interesting. 1 0 0 monospaced 0 42 10 20 -1 1 52428800 .pdf,.zip.,.docx Grade generously!

    ]]>
    Here is something really interesting.

    ]]>
    '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_match_19(): void { $xml = ' Matching question Match the upper and lower case letters. The answer is A -> a, B -> b and C -> c. 1 0.3333333 0 false Well done. Not entirely. Completely wrong! A a B b C c d Hint 1 tagMatching tagTest '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_match($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'match'; $expectedq->name = 'Matching question'; $expectedq->questiontext = 'Match the upper and lower case letters.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array('text' => 'Well done.', 'format' => FORMAT_HTML); $expectedq->partiallycorrectfeedback = array('text' => 'Not entirely.', 'format' => FORMAT_HTML); $expectedq->shownumcorrect = false; $expectedq->incorrectfeedback = array('text' => 'Completely wrong!', 'format' => FORMAT_HTML); $expectedq->generalfeedback = 'The answer is A -> a, B -> b and C -> c.'; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = 0; $expectedq->subquestions = array( array('text' => 'A', 'format' => FORMAT_HTML), array('text' => 'B', 'format' => FORMAT_HTML), array('text' => 'C', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML)); $expectedq->subanswers = array('a', 'b', 'c', 'd'); $expectedq->hint = array( array('text' => 'Hint 1', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML), ); $expectedq->hintshownumcorrect = array(true, true); $expectedq->hintclearwrong = array(false, true); $expectedq->tags = array('tagMatching', 'tagTest'); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_match(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'match'; $qdata->name = 'Matching question'; $qdata->questiontext = 'Match the upper and lower case letters.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'The answer is A -> a, B -> b and C -> c.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->shuffleanswers = 1; $qdata->options->correctfeedback = 'Well done.'; $qdata->options->correctfeedbackformat = FORMAT_HTML; $qdata->options->partiallycorrectfeedback = 'Not entirely.'; $qdata->options->partiallycorrectfeedbackformat = FORMAT_HTML; $qdata->options->shownumcorrect = false; $qdata->options->incorrectfeedback = 'Completely wrong!'; $qdata->options->incorrectfeedbackformat = FORMAT_HTML; $subq1 = new \stdClass(); $subq1->id = -4; $subq1->questiontext = 'A'; $subq1->questiontextformat = FORMAT_HTML; $subq1->answertext = 'a'; $subq2 = new \stdClass(); $subq2->id = -3; $subq2->questiontext = 'B'; $subq2->questiontextformat = FORMAT_HTML; $subq2->answertext = 'b'; $subq3 = new \stdClass(); $subq3->id = -2; $subq3->questiontext = 'C'; $subq3->questiontextformat = FORMAT_HTML; $subq3->answertext = 'c'; $subq4 = new \stdClass(); $subq4->id = -1; $subq4->questiontext = ''; $subq4->questiontextformat = FORMAT_HTML; $subq4->answertext = 'd'; $qdata->options->subquestions = array( $subq1, $subq2, $subq3, $subq4); $qdata->hints = array( new question_hint_with_parts(0, 'Hint 1', FORMAT_HTML, true, false), new question_hint_with_parts(0, '', FORMAT_HTML, true, true), ); $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Matching question Match the upper and lower case letters. a, B -> b and C -> c.]]> 1 0.3333333 0 true Well done. Not entirely. Completely wrong! A a B b C c d Hint 1 '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_multichoice_19(): void { $xml = ' Multiple choice question Which are the even numbers? The even numbers are 2 and 4. 2 0.3333333 0 false false abc Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    1 2 3 4 Hint 1. Hint 2.
    '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_multichoice($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'multichoice'; $expectedq->name = 'Multiple choice question'; $expectedq->questiontext = 'Which are the even numbers?'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array( 'text' => '

    Your answer is correct.

    ', 'format' => FORMAT_HTML); $expectedq->shownumcorrect = false; $expectedq->partiallycorrectfeedback = array( 'text' => '

    Your answer is partially correct.

    ', 'format' => FORMAT_HTML); $expectedq->shownumcorrect = true; $expectedq->incorrectfeedback = array( 'text' => '

    Your answer is incorrect.

    ', 'format' => FORMAT_HTML); $expectedq->generalfeedback = 'The even numbers are 2 and 4.'; $expectedq->defaultmark = 2; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = 0; $expectedq->single = false; $expectedq->answer = array( array('text' => '1', 'format' => FORMAT_HTML), array('text' => '2', 'format' => FORMAT_HTML), array('text' => '3', 'format' => FORMAT_HTML), array('text' => '4', 'format' => FORMAT_HTML)); $expectedq->fraction = array(0, 1, 0, 1); $expectedq->feedback = array( array('text' => '', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML)); $expectedq->hint = array( array('text' => 'Hint 1.', 'format' => FORMAT_HTML), array('text' => 'Hint 2.', 'format' => FORMAT_HTML), ); $expectedq->hintshownumcorrect = array(false, false); $expectedq->hintclearwrong = array(false, false); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_multichoice(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'multichoice'; $qdata->name = 'Multiple choice question'; $qdata->questiontext = 'Which are the even numbers?'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'The even numbers are 2 and 4.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 2; $qdata->length = 1; $qdata->penalty = 0.3333333; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->single = 0; $qdata->options->shuffleanswers = 0; $qdata->options->answernumbering = 'abc'; $qdata->options->showstandardinstruction = 0; $qdata->options->correctfeedback = '

    Your answer is correct.

    '; $qdata->options->correctfeedbackformat = FORMAT_HTML; $qdata->options->partiallycorrectfeedback = '

    Your answer is partially correct.

    '; $qdata->options->partiallycorrectfeedbackformat = FORMAT_HTML; $qdata->options->shownumcorrect = 1; $qdata->options->incorrectfeedback = '

    Your answer is incorrect.

    '; $qdata->options->incorrectfeedbackformat = FORMAT_HTML; $qdata->options->answers = array( 13 => new question_answer(13, '1', 0, '', FORMAT_HTML), 14 => new question_answer(14, '2', 1, '', FORMAT_HTML), 15 => new question_answer(15, '3', 0, '', FORMAT_HTML), 16 => new question_answer(16, '4', 1, '', FORMAT_HTML), ); $qdata->hints = array( new question_hint_with_parts(0, 'Hint 1.', FORMAT_HTML, false, false), new question_hint_with_parts(0, 'Hint 2.', FORMAT_HTML, false, false), ); $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Multiple choice question Which are the even numbers? The even numbers are 2 and 4. 2 0.3333333 0 false false abc 0 Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    1 2 3 4 Hint 1. Hint 2.
    '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_numerical_19(): void { $xml = ' Numerical question What is the answer? General feedback: Think Hitch-hikers guide to the Galaxy. 1 0.1 0 42 Well done! 0.001 13 What were you thinking?! 1 * Completely wrong. '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_numerical($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'numerical'; $expectedq->name = 'Numerical question'; $expectedq->questiontext = 'What is the answer?'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = 'General feedback: Think Hitch-hikers guide to the Galaxy.'; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.1; $expectedq->answer = array('42', '13', '*'); $expectedq->fraction = array(1, 0, 0); $expectedq->feedback = array( array('text' => 'Well done!', 'format' => FORMAT_HTML), array('text' => 'What were you thinking?!', 'format' => FORMAT_HTML), array('text' => 'Completely wrong.', 'format' => FORMAT_HTML)); $expectedq->tolerance = array(0.001, 1, ''); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_numerical(): void { question_bank::load_question_definition_classes('numerical'); $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'numerical'; $qdata->name = 'Numerical question'; $qdata->questiontext = 'What is the answer?'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'General feedback: Think Hitch-hikers guide to the Galaxy.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.1; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->answers = array( 13 => new qtype_numerical_answer(13, '42', 1, 'Well done!', FORMAT_HTML, 0.001), 14 => new qtype_numerical_answer(14, '13', 0, 'What were you thinking?!', FORMAT_HTML, 1), 15 => new qtype_numerical_answer(15, '*', 0, 'Completely wrong.', FORMAT_HTML, ''), ); $qdata->options->units = array(); $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Numerical question What is the answer? General feedback: Think Hitch-hikers guide to the Galaxy. 1 0.1 0 42 Well done! 0.001 13 What were you thinking?! 1 * Completely wrong. 0 '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_shortanswer_19(): void { $xml = ' Short answer question Fill in the gap in this sequence: Alpha, ________, Gamma. The answer is Beta. 1 0.3333333 0 0 Beta Well done! * Doh! Hint 1 Hint 2 '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_shortanswer($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'shortanswer'; $expectedq->name = 'Short answer question'; $expectedq->questiontext = 'Fill in the gap in this sequence: Alpha, ________, Gamma.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = 'The answer is Beta.'; $expectedq->usecase = false; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->answer = array('Beta', '*'); $expectedq->fraction = array(1, 0); $expectedq->feedback = array( array('text' => 'Well done!', 'format' => FORMAT_HTML), array('text' => 'Doh!', 'format' => FORMAT_HTML)); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_shortanswer(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'shortanswer'; $qdata->name = 'Short answer question'; $qdata->questiontext = 'Fill in the gap in this sequence: Alpha, ________, Gamma.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'The answer is Beta.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->usecase = 0; $qdata->options->answers = array( 13 => new question_answer(13, 'Beta', 1, 'Well done!', FORMAT_HTML), 14 => new question_answer(14, '*', 0, 'Doh!', FORMAT_HTML), ); $qdata->hints = array( new question_hint(0, 'Hint 1', FORMAT_HTML), new question_hint(0, 'Hint 2', FORMAT_HTML), ); $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Short answer question Fill in the gap in this sequence: Alpha, ________, Gamma. The answer is Beta. 1 0.3333333 0 0 Beta Well done! * Doh! Hint 1 Hint 2 '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_truefalse_19(): void { $xml = ' True false question The answer is true. General feedback: You should have chosen true. 1 1 0 true Well done! false Doh! '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_truefalse($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'truefalse'; $expectedq->name = 'True false question'; $expectedq->questiontext = 'The answer is true.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = 'General feedback: You should have chosen true.'; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 1; $expectedq->feedbacktrue = array('text' => 'Well done!', 'format' => FORMAT_HTML); $expectedq->feedbackfalse = array('text' => 'Doh!', 'format' => FORMAT_HTML); $expectedq->correctanswer = true; $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_truefalse_with_idnumber(): void { $xml = ' True false question The answer is true. General feedback: You should have chosen true. 1 1 0 TestIdNum1 true Well done! false Doh! '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_truefalse($xmldata['question']); $expectedq = new \stdClass(); $expectedq->qtype = 'truefalse'; $expectedq->name = 'True false question'; $expectedq->questiontext = 'The answer is true.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = 'General feedback: You should have chosen true.'; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 1; $expectedq->idnumber = 'TestIdNum1'; $expectedq->feedbacktrue = array('text' => 'Well done!', 'format' => FORMAT_HTML); $expectedq->feedbackfalse = array('text' => 'Doh!', 'format' => FORMAT_HTML); $expectedq->correctanswer = true; $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_export_truefalse(): void { $qdata = new \stdClass(); $qdata->id = 12; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'truefalse'; $qdata->name = 'True false question'; $qdata->questiontext = 'The answer is true.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'General feedback: You should have chosen true.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new \stdClass(); $qdata->options->answers = array( 1 => new question_answer(1, 'True', 1, 'Well done!', FORMAT_HTML), 2 => new question_answer(2, 'False', 0, 'Doh!', FORMAT_HTML), ); $qdata->options->trueanswer = 1; $qdata->options->falseanswer = 2; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' True false question The answer is true. General feedback: You should have chosen true. 1 1 0 true Well done! false Doh! '; $this->assert_same_xml($expectedxml, $xml); } public function test_export_truefalse_with_idnumber(): void { $qdata = new \stdClass(); $qdata->id = 12; $qdata->contextid = \context_system::instance()->id; $qdata->qtype = 'truefalse'; $qdata->name = 'True false question'; $qdata->questiontext = 'The answer is true.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'General feedback: You should have chosen true.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = 'TestIDNum2'; $qdata->options = new \stdClass(); $qdata->options->answers = array( 1 => new question_answer(1, 'True', 1, 'Well done!', FORMAT_HTML), 2 => new question_answer(2, 'False', 0, 'Doh!', FORMAT_HTML), ); $qdata->options->trueanswer = 1; $qdata->options->falseanswer = 2; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' True false question The answer is true. General feedback: You should have chosen true. 1 1 0 TestIDNum2 true Well done! false Doh! '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_multianswer(): void { $xml = ' Simple multianswer 0.5 0 id-101 Hint 1 Hint 2 tagCloze tagTest '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_multianswer($xmldata['question']); // Annoyingly, import works in a weird way (it duplicates code, rather // than just calling save_question) so we cannot use // \test_question_maker::get_question_form_data('multianswer', 'twosubq'). $expectedqa = new \stdClass(); $expectedqa->name = 'Simple multianswer'; $expectedqa->qtype = 'multianswer'; $expectedqa->questiontext = 'Complete this opening line of verse: "The {#1} and the {#2} went to sea".'; $expectedqa->idnumber = 'id-101'; $expectedqa->generalfeedback = 'General feedback: It\'s from "The Owl and the Pussy-cat" by Lear: "The owl and the pussycat went to sea".'; $expectedqa->defaultmark = 2; $expectedqa->penalty = 0.5; $expectedqa->hint = array( array('text' => 'Hint 1', 'format' => FORMAT_HTML), array('text' => 'Hint 2', 'format' => FORMAT_HTML), ); $sa = new \stdClass(); $sa->questiontext = array('text' => '{1:SHORTANSWER:Dog#Wrong, silly!~=Owl#Well done!~*#Wrong answer}', 'format' => FORMAT_HTML, 'itemid' => null); $sa->generalfeedback = array('text' => '', 'format' => FORMAT_HTML, 'itemid' => null); $sa->defaultmark = 1.0; $sa->qtype = 'shortanswer'; $sa->usecase = 0; $sa->answer = array('Dog', 'Owl', '*'); $sa->fraction = array(0, 1, 0); $sa->feedback = array( array('text' => 'Wrong, silly!', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Well done!', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Wrong answer', 'format' => FORMAT_HTML, 'itemid' => null), ); $mc = new \stdClass(); $mc->generalfeedback = ''; $mc->questiontext = array('text' => '{1:MULTICHOICE:Bow-wow#You seem to have a dog obsessions!~' . 'Wiggly worm#Now you are just being ridiculous!~=Pussy-cat#Well done!}', 'format' => FORMAT_HTML, 'itemid' => null); $mc->generalfeedback = array('text' => '', 'format' => FORMAT_HTML, 'itemid' => null); $mc->defaultmark = 1.0; $mc->qtype = 'multichoice'; $mc->layout = 0; $mc->single = 1; $mc->shuffleanswers = 0; $mc->correctfeedback = array('text' => '', 'format' => FORMAT_HTML, 'itemid' => null); $mc->partiallycorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'itemid' => null); $mc->incorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'itemid' => null); $mc->answernumbering = 0; $mc->answer = array( array('text' => 'Bow-wow', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Wiggly worm', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Pussy-cat', 'format' => FORMAT_HTML, 'itemid' => null), ); $mc->fraction = array(0, 0, 1); $mc->feedback = array( array('text' => 'You seem to have a dog obsessions!', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Now you are just being ridiculous!', 'format' => FORMAT_HTML, 'itemid' => null), array('text' => 'Well done!', 'format' => FORMAT_HTML, 'itemid' => null), ); $expectedqa->options = new \stdClass(); $expectedqa->options->questions = array( 1 => $sa, 2 => $mc, ); $expectedqa->tags = array('tagCloze', 'tagTest'); $this->assertEquals($expectedqa->hint, $q->hint); $this->assertEquals($expectedqa->options->questions[1], $q->options->questions[1]); $this->assertEquals($expectedqa->options->questions[2], $q->options->questions[2]); $this->assert(new question_check_specified_fields_expectation($expectedqa), $q); } public function test_export_multianswer(): void { $qdata = \test_question_maker::get_question_data('multianswer', 'twosubq'); $qdata->contextid = \context_system::instance()->id; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Simple multianswer 0.3333333 0 Hint 1 Hint 2 '; $this->assert_same_xml($expectedxml, $xml); } public function test_export_multianswer_withdollars(): void { $qdata = \test_question_maker::get_question_data('multianswer', 'dollarsigns'); $qdata->contextid = \context_system::instance()->id; $exporter = new qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' Multianswer with $s Which is the right order? {1:MULTICHOICE:=y,y,$3~$3,y,y} 0.3333333 0 '; $this->assert_same_xml($expectedxml, $xml); } public function test_import_files_as_draft(): void { $this->resetAfterTest(); $this->setAdminUser(); $xml = << This text file contains the word 'Moodle'.

    ]]>
    TW9vZGxl END; $textxml = xmlize($xml); $qo = new \stdClass(); $importer = new qformat_xml(); $draftitemid = $importer->import_files_as_draft($textxml['questiontext']['#']['file']); $files = file_get_drafarea_files($draftitemid); $this->assertEquals(1, count($files->list)); $file = $files->list[0]; $this->assertEquals('moodle.txt', $file->filename); $this->assertEquals('/', $file->filepath); $this->assertEquals(6, $file->size); } public function test_import_truefalse_wih_files(): void { $this->resetAfterTest(); $this->setAdminUser(); $xml = ' truefalse This text file contains the word Moodle.

    ]]>
    TW9vZGxl
    For further information, see the documentation about Moodle.

    ]]>
    1.0000000 1.0000000 0 true false
    '; $xmldata = xmlize($xml); $importer = new qformat_xml(); $q = $importer->import_truefalse($xmldata['question']); $draftitemid = $q->questiontextitemid; $files = file_get_drafarea_files($draftitemid, '/myfolder/'); $this->assertEquals(1, count($files->list)); $file = $files->list[0]; $this->assertEquals('moodle.txt', $file->filename); $this->assertEquals('/myfolder/', $file->filepath); $this->assertEquals(6, $file->size); } public function test_create_dummy_question(): void { $testobject = new mock_qformat_xml(); $categoryname = 'name1'; $categoryinfo = new \stdClass(); $categoryinfo->info = 'info1'; $categoryinfo->infoformat = 'infoformat1'; $categoryinfo->idnumber = null; $dummyquestion = $testobject->mock_create_dummy_question_representing_category($categoryname, $categoryinfo); $this->assertEquals('category', $dummyquestion->qtype); $this->assertEquals($categoryname, $dummyquestion->category); $this->assertEquals($categoryinfo->info, $dummyquestion->info); $this->assertEquals($categoryinfo->infoformat, $dummyquestion->infoformat); $this->assertEquals('Switch category to ' . $categoryname, $dummyquestion->name); $this->assertEquals(0, $dummyquestion->id); $this->assertEquals('', $dummyquestion->questiontextformat); $this->assertEquals(0, $dummyquestion->contextid); } } /** * Class mock_qformat_xml exists only to enable testing of the create dummy question category. * @package qformat_xml * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mock_qformat_xml extends qformat_xml { /** * Make public an otherwise protected function. * @param string $categoryname the name of the category * @param object $categoryinfo description of the category */ public function mock_create_dummy_question_representing_category(string $categoryname, $categoryinfo) { return $this->create_dummy_question_representing_category($categoryname, $categoryinfo); } } PK\W,format/xml/tests/behat/import_export.featurenu[@qformat @qformat_xml Feature: Test importing questions from Moodle XML format. In order to reuse questions As an teacher I need to be able to import them in XML format. Background: Given the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And the following "users" exist: | username | firstname | | teacher | Teacher | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | @javascript @_file_upload Scenario: import some true/false questions from Moodle XML format When I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" And I set the field "id_format_xml" to "1" And I upload "question/format/xml/tests/fixtures/truefalse.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 2 questions from file" And I should see "is an acronym for Modular Object-Oriented Dynamic Learning Education" And I should see "is an acronym for Modular Object-Oriented Dynamic Learning Environment" When I press "Continue" Then I should see "Moodle acronym (False)" Then I should see "Moodle acronym (True)" # Now export again. When I am on the "Qbank 1" "core_question > question export" page logged in as "teacher" And I set the field "id_format_xml" to "1" And I set the field "Export category" to "TrueFalse" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | Moodle acronym (True) | | Contains text in xml element | Moodle acronym (False) | @javascript @_file_upload Scenario: import some multiple choice questions from Moodle XML format When I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" And I set the field "id_format_xml" to "1" And I upload "question/format/xml/tests/fixtures/multichoice.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "What language is being spoken?" When I press "Continue" Then I should see "Greeting" @javascript @_file_upload Scenario: import some multi-answer questions from Moodle XML format When I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" And I set the field "id_format_xml" to "1" And I upload "question/format/xml/tests/fixtures/multianswer.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "Match the following cities with the correct state," When I press "Continue" Then I should see "cloze with images" @javascript @_file_upload Scenario: import some questions with legacy-style images from Moodle XML format When I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" And I set the field "id_format_xml" to "1" And I upload "question/format/xml/tests/fixtures/sample_questions_with_old_image_tag.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 2 questions from file" And I should see "This is a multianswer question with an image in the old" And I should see "This is a multichoice question with an image in the old" When I press "Continue" Then I should see "cloze question with image" Then I should see "mcq with image" PK\'ɫXXformat/xml/format.phpnu[. /** * Code for exporting questions as Moodle XML. * * @package qformat_xml * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/xmlize.php'); if (!class_exists('qformat_default')) { // This is ugly, but this class is also (ab)used by mod/lesson, which defines // a different base class in mod/lesson/format.php. Thefore, we can only // include the proper base class conditionally like this. (We have to include // the base class like this, otherwise it breaks third-party question types.) // This may be reviewd, and a better fix found one day. require_once($CFG->dirroot . '/question/format.php'); } /** * Importer for Moodle XML question format. * * See http://docs.moodle.org/en/Moodle_XML_format for a description of the format. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_xml extends qformat_default { /** @var array Array of files for question answers. */ protected $answerfiles = []; /** @var array Array of files for feedback to question answers. */ protected $feedbackfiles = []; public function provide_import() { return true; } public function provide_export() { return true; } public function mime_type() { return 'application/xml'; } /** * Validate the given file. * * For more expensive or detailed integrity checks. * * @param stored_file $file the file to check * @return string the error message that occurred while validating the given file */ public function validate_file(stored_file $file): string { return $this->validate_is_utf8_file($file); } // IMPORT FUNCTIONS START HERE. /** * Translate human readable format name * into internal Moodle code number * Note the reverse function is called get_format. * @param string name format name from xml file * @return int Moodle format code */ public function trans_format($name) { $name = trim($name); if ($name == 'moodle_auto_format') { return FORMAT_MOODLE; } else if ($name == 'html') { return FORMAT_HTML; } else if ($name == 'plain_text') { return FORMAT_PLAIN; } else if ($name == 'wiki_like') { return FORMAT_WIKI; } else if ($name == 'markdown') { return FORMAT_MARKDOWN; } else { debugging("Unrecognised text format '{$name}' in the import file. Assuming 'html'."); return FORMAT_HTML; } } /** * Translate human readable single answer option * to internal code number * @param string name true/false * @return int internal code number */ public function trans_single($name) { $name = trim($name); if ($name == "false" || !$name) { return 0; } else { return 1; } } /** * process text string from xml file * @param array $text bit of xml tree after ['text'] * @return string processed text. */ public function import_text($text) { // Quick sanity check. if (empty($text)) { return ''; } $data = $text[0]['#']; return trim($data); } /** * return the value of a node, given a path to the node * if it doesn't exist return the default value * @param array xml data to read * @param array path path to node expressed as array * @param mixed default * @param bool istext process as text * @param string error if set value must exist, return false and issue message if not * @return mixed value */ public function getpath($xml, $path, $default, $istext=false, $error='') { foreach ($path as $index) { if (!isset($xml[$index])) { if (!empty($error)) { $this->error($error); return false; } else { return $default; } } $xml = $xml[$index]; } if ($istext) { if (!is_string($xml)) { $this->error(get_string('invalidxml', 'qformat_xml')); return false; } $xml = trim($xml); } return $xml; } public function import_text_with_files($data, $path, $defaultvalue = '', $defaultformat = 'html') { $field = array(); $field['text'] = $this->getpath($data, array_merge($path, array('#', 'text', 0, '#')), $defaultvalue, true); $field['format'] = $this->trans_format($this->getpath($data, array_merge($path, array('@', 'format')), $defaultformat)); $itemid = $this->import_files_as_draft($this->getpath($data, array_merge($path, array('#', 'file')), array(), false)); if (!empty($itemid)) { $field['itemid'] = $itemid; } return $field; } public function import_files_as_draft($xml) { global $USER; if (empty($xml)) { return null; } $fs = get_file_storage(); $itemid = file_get_unused_draft_itemid(); $filepaths = array(); foreach ($xml as $file) { $filename = $this->getpath($file, array('@', 'name'), '', true); $filepath = $this->getpath($file, array('@', 'path'), '/', true); $fullpath = $filepath . $filename; if (in_array($fullpath, $filepaths)) { debugging('Duplicate file in XML: ' . $fullpath, DEBUG_DEVELOPER); continue; } $filerecord = array( 'contextid' => context_user::instance($USER->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $itemid, 'filepath' => $filepath, 'filename' => $filename, ); $fs->create_file_from_string($filerecord, base64_decode($file['#'])); $filepaths[] = $fullpath; } return $itemid; } /** * import parts of question common to all types * @param $question array question question array from xml tree * @return object question object */ public function import_headers($question) { global $USER; // This routine initialises the question object. $qo = $this->defaultquestion(); // Question name. $qo->name = $this->clean_question_name($this->getpath($question, array('#', 'name', 0, '#', 'text', 0, '#'), '', true, get_string('xmlimportnoname', 'qformat_xml'))); $questiontext = $this->import_text_with_files($question, array('#', 'questiontext', 0)); $qo->questiontext = $questiontext['text']; $qo->questiontextformat = $questiontext['format']; if (!empty($questiontext['itemid'])) { $qo->questiontextitemid = $questiontext['itemid']; } // Backwards compatibility, deal with the old image tag. $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false); $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false); if ($filedata && $filename) { $fs = get_file_storage(); if (empty($qo->questiontextitemid)) { $qo->questiontextitemid = file_get_unused_draft_itemid(); } $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE); $filerecord = array( 'contextid' => context_user::instance($USER->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $qo->questiontextitemid, 'filepath' => '/', 'filename' => $filename, ); $fs->create_file_from_string($filerecord, base64_decode($filedata)); $qo->questiontext .= ' '; } $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null); // Restore files in generalfeedback. $generalfeedback = $this->import_text_with_files($question, array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat)); $qo->generalfeedback = $generalfeedback['text']; $qo->generalfeedbackformat = $generalfeedback['format']; if (!empty($generalfeedback['itemid'])) { $qo->generalfeedbackitemid = $generalfeedback['itemid']; } $qo->defaultmark = $this->getpath($question, array('#', 'defaultgrade', 0, '#'), $qo->defaultmark); $qo->penalty = $this->getpath($question, array('#', 'penalty', 0, '#'), $qo->penalty); // Fix problematic rounding from old files. if (abs($qo->penalty - 0.3333333) < 0.005) { $qo->penalty = 0.3333333; } // Read the question tags. $this->import_question_tags($qo, $question); return $qo; } /** * Import the common parts of a single answer * @param array answer xml tree for single answer * @param bool $withanswerfiles if true, the answers are HTML (or $defaultformat) * and so may contain files, otherwise the answers are plain text. * @param array Default text format for the feedback, and the answers if $withanswerfiles * is true. * @return object answer object */ public function import_answer($answer, $withanswerfiles = false, $defaultformat = 'html') { $ans = new stdClass(); if ($withanswerfiles) { $ans->answer = $this->import_text_with_files($answer, array(), '', $defaultformat); } else { $ans->answer = array(); $ans->answer['text'] = $this->getpath($answer, array('#', 'text', 0, '#'), '', true); $ans->answer['format'] = FORMAT_PLAIN; } $ans->feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $defaultformat); $ans->fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100; return $ans; } /** * Import the common overall feedback fields. * @param object $question the part of the XML relating to this question. * @param object $qo the question data to add the fields to. * @param bool $withshownumpartscorrect include the shownumcorrect field. */ public function import_combined_feedback($qo, $questionxml, $withshownumpartscorrect = false) { $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); foreach ($fields as $field) { $qo->$field = $this->import_text_with_files($questionxml, array('#', $field, 0), '', $this->get_format($qo->questiontextformat)); } if ($withshownumpartscorrect) { $qo->shownumcorrect = array_key_exists('shownumcorrect', $questionxml['#']); // Backwards compatibility. if (array_key_exists('correctresponsesfeedback', $questionxml['#'])) { $qo->shownumcorrect = $this->trans_single($this->getpath($questionxml, array('#', 'correctresponsesfeedback', 0, '#'), 1)); } } } /** * Import a question hint * @param array $hintxml hint xml fragment. * @param string $defaultformat the text format to assume for hints that do not specify. * @return object hint for storing in the database. */ public function import_hint($hintxml, $defaultformat) { $hint = new stdClass(); if (array_key_exists('hintcontent', $hintxml['#'])) { // Backwards compatibility. $hint->hint = $this->import_text_with_files($hintxml, array('#', 'hintcontent', 0), '', $defaultformat); $hint->shownumcorrect = $this->getpath($hintxml, array('#', 'statenumberofcorrectresponses', 0, '#'), 0); $hint->clearwrong = $this->getpath($hintxml, array('#', 'clearincorrectresponses', 0, '#'), 0); $hint->options = $this->getpath($hintxml, array('#', 'showfeedbacktoresponses', 0, '#'), 0); return $hint; } $hint->hint = $this->import_text_with_files($hintxml, array(), '', $defaultformat); $hint->shownumcorrect = array_key_exists('shownumcorrect', $hintxml['#']); $hint->clearwrong = array_key_exists('clearwrong', $hintxml['#']); $hint->options = $this->getpath($hintxml, array('#', 'options', 0, '#'), '', true); return $hint; } /** * Import all the question hints * * @param object $qo the question data that is being constructed. * @param array $questionxml The xml representing the question. * @param bool $withparts whether the extra fields relating to parts should be imported. * @param bool $withoptions whether the extra options field should be imported. * @param string $defaultformat the text format to assume for hints that do not specify. * @return array of objects representing the hints in the file. */ public function import_hints($qo, $questionxml, $withparts = false, $withoptions = false, $defaultformat = 'html') { if (!isset($questionxml['#']['hint'])) { return; } foreach ($questionxml['#']['hint'] as $hintxml) { $hint = $this->import_hint($hintxml, $defaultformat); $qo->hint[] = $hint->hint; if ($withparts) { $qo->hintshownumcorrect[] = $hint->shownumcorrect; $qo->hintclearwrong[] = $hint->clearwrong; } if ($withoptions) { $qo->hintoptions[] = $hint->options; } } } /** * Import all the question tags * * @param object $qo the question data that is being constructed. * @param array $questionxml The xml representing the question. * @return array of objects representing the tags in the file. */ public function import_question_tags($qo, $questionxml) { global $CFG; if (core_tag_tag::is_enabled('core_question', 'question')) { $qo->tags = []; if (!empty($questionxml['#']['tags'][0]['#']['tag'])) { foreach ($questionxml['#']['tags'][0]['#']['tag'] as $tagdata) { $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true); } } $qo->coursetags = []; if (!empty($questionxml['#']['coursetags'][0]['#']['tag'])) { foreach ($questionxml['#']['coursetags'][0]['#']['tag'] as $tagdata) { $qo->coursetags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true); } } } } /** * Import files from a node in the XML. * @param array $xml an array of nodes from the the parsed XML. * @return array of things representing files - in the form that save_question expects. */ public function import_files($xml) { $files = array(); foreach ($xml as $file) { $data = new stdClass(); $data->content = $file['#']; $data->encoding = $file['@']['encoding']; $data->name = $file['@']['name']; $files[] = $data; } return $files; } /** * import multiple choice question * @param array question question array from xml tree * @return object question object */ public function import_multichoice($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to multichoice. $qo->qtype = 'multichoice'; $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true'); $qo->single = $this->trans_single($single); $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false'); $qo->answernumbering = $this->getpath($question, array('#', 'answernumbering', 0, '#'), 'abc'); $qo->shuffleanswers = $this->trans_single($shuffleanswers); $qo->showstandardinstruction = $this->getpath($question, array('#', 'showstandardinstruction', 0, '#'), '1'); // There was a time on the 1.8 branch when it could output an empty // answernumbering tag, so fix up any found. if (empty($qo->answernumbering)) { $qo->answernumbering = 'abc'; } // Run through the answers. $answers = $question['#']['answer']; $acount = 0; foreach ($answers as $answer) { $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat)); $qo->answer[$acount] = $ans->answer; $qo->fraction[$acount] = $ans->fraction; $qo->feedback[$acount] = $ans->feedback; ++$acount; } $this->import_combined_feedback($qo, $question, true); $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * Import cloze type question * @param array question question array from xml tree * @return object question object */ public function import_multianswer($question) { global $USER; question_bank::get_qtype('multianswer'); $questiontext = $this->import_text_with_files($question, array('#', 'questiontext', 0)); $qo = qtype_multianswer_extract_question($questiontext); $errors = qtype_multianswer_validate_question($qo); if ($errors) { $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors))); return null; } // Header parts particular to multianswer. $qo->qtype = 'multianswer'; // Only set the course if the data is available. if (isset($this->course)) { $qo->course = $this->course; } if (isset($question['#']['name'])) { $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text'])); } else { $qo->name = $this->create_default_question_name($qo->questiontext['text'], get_string('questionname', 'question')); } $qo->questiontextformat = $questiontext['format']; $qo->questiontext = $qo->questiontext['text']; if (!empty($questiontext['itemid'])) { $qo->questiontextitemid = $questiontext['itemid']; } // Backwards compatibility, deal with the old image tag. $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false); $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false); if ($filedata && $filename) { $fs = get_file_storage(); if (empty($qo->questiontextitemid)) { $qo->questiontextitemid = file_get_unused_draft_itemid(); } $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE); $filerecord = array( 'contextid' => context_user::instance($USER->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $qo->questiontextitemid, 'filepath' => '/', 'filename' => $filename, ); $fs->create_file_from_string($filerecord, base64_decode($filedata)); $qo->questiontext .= ' '; } $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null); // Restore files in generalfeedback. $generalfeedback = $this->import_text_with_files($question, array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat)); $qo->generalfeedback = $generalfeedback['text']; $qo->generalfeedbackformat = $generalfeedback['format']; if (!empty($generalfeedback['itemid'])) { $qo->generalfeedbackitemid = $generalfeedback['itemid']; } $qo->penalty = $this->getpath($question, array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty); // Fix problematic rounding from old files. if (abs($qo->penalty - 0.3333333) < 0.005) { $qo->penalty = 0.3333333; } $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat)); $this->import_question_tags($qo, $question); return $qo; } /** * Import true/false type question * @param array question question array from xml tree * @return object question object */ public function import_truefalse($question) { // Get common parts. global $OUTPUT; $qo = $this->import_headers($question); // Header parts particular to true/false. $qo->qtype = 'truefalse'; // In the past, it used to be assumed that the two answers were in the file // true first, then false. Howevever that was not always true. Now, we // try to match on the answer text, but in old exports, this will be a localised // string, so if we don't find true or false, we fall back to the old system. $first = true; $warning = false; foreach ($question['#']['answer'] as $answer) { $answertext = $this->getpath($answer, array('#', 'text', 0, '#'), '', true); $feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat)); if ($answertext != 'true' && $answertext != 'false') { // Old style file, assume order is true/false. $warning = true; if ($first) { $answertext = 'true'; } else { $answertext = 'false'; } } if ($answertext == 'true') { $qo->answer = ($answer['@']['fraction'] == 100); $qo->correctanswer = $qo->answer; $qo->feedbacktrue = $feedback; } else { $qo->answer = ($answer['@']['fraction'] != 100); $qo->correctanswer = $qo->answer; $qo->feedbackfalse = $feedback; } $first = false; } if ($warning) { $a = new stdClass(); $a->questiontext = $qo->questiontext; $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse'); echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a)); } $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * Import short answer type question * @param array question question array from xml tree * @return object question object */ public function import_shortanswer($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to shortanswer. $qo->qtype = 'shortanswer'; // Get usecase. $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase); // Run through the answers. $answers = $question['#']['answer']; $acount = 0; foreach ($answers as $answer) { $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat)); $qo->answer[$acount] = $ans->answer['text']; $qo->fraction[$acount] = $ans->fraction; $qo->feedback[$acount] = $ans->feedback; ++$acount; } $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * Import description type question * @param array question question array from xml tree * @return object question object */ public function import_description($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to shortanswer. $qo->qtype = 'description'; $qo->defaultmark = 0; $qo->length = 0; return $qo; } /** * Import numerical type question * @param array question question array from xml tree * @return object question object */ public function import_numerical($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to numerical. $qo->qtype = 'numerical'; // Get answers array. $answers = $question['#']['answer']; $qo->answer = array(); $qo->feedback = array(); $qo->fraction = array(); $qo->tolerance = array(); foreach ($answers as $answer) { // Answer outside of is deprecated. $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat)); $qo->answer[] = $obj->answer['text']; if (empty($qo->answer)) { $qo->answer = '*'; } $qo->feedback[] = $obj->feedback; $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0); // Fraction as a tag is deprecated. $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100; $qo->fraction[] = $this->getpath($answer, array('#', 'fraction', 0, '#'), $fraction); // Deprecated. } // Get the units array. $qo->unit = array(); $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array()); if (!empty($units)) { $qo->multiplier = array(); foreach ($units as $unit) { $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1); $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true); } } $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0); $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1); $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null); $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0); $qo->instructions['text'] = ''; $qo->instructions['format'] = FORMAT_HTML; $instructions = $this->getpath($question, array('#', 'instructions'), array()); if (!empty($instructions)) { $qo->instructions = $this->import_text_with_files($instructions, array('0'), '', $this->get_format($qo->questiontextformat)); } if (is_null($qo->showunits)) { // Set a good default, depending on whether there are any units defined. if (empty($qo->unit)) { $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here. } else { $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here. } } $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * Import matching type question * @param array question question array from xml tree * @return object question object */ public function import_match($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to matching. $qo->qtype = 'match'; $qo->shuffleanswers = $this->trans_single($this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 1)); // Run through subquestions. $qo->subquestions = array(); $qo->subanswers = array(); foreach ($question['#']['subquestion'] as $subqxml) { $qo->subquestions[] = $this->import_text_with_files($subqxml, array(), '', $this->get_format($qo->questiontextformat)); $answers = $this->getpath($subqxml, array('#', 'answer'), array()); $qo->subanswers[] = $this->getpath($subqxml, array('#', 'answer', 0, '#', 'text', 0, '#'), '', true); } $this->import_combined_feedback($qo, $question, true); $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * Import essay type question * @param array question question array from xml tree * @return object question object */ public function import_essay($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to essay. $qo->qtype = 'essay'; $qo->responseformat = $this->getpath($question, array('#', 'responseformat', 0, '#'), 'editor'); $qo->responsefieldlines = $this->getpath($question, array('#', 'responsefieldlines', 0, '#'), 15); $qo->responserequired = $this->getpath($question, array('#', 'responserequired', 0, '#'), 1); $qo->minwordlimit = $this->getpath($question, array('#', 'minwordlimit', 0, '#'), null); $qo->minwordenabled = !empty($qo->minwordlimit); $qo->maxwordlimit = $this->getpath($question, array('#', 'maxwordlimit', 0, '#'), null); $qo->maxwordenabled = !empty($qo->maxwordlimit); $qo->attachments = $this->getpath($question, array('#', 'attachments', 0, '#'), 0); $qo->attachmentsrequired = $this->getpath($question, array('#', 'attachmentsrequired', 0, '#'), 0); $qo->filetypeslist = $this->getpath($question, array('#', 'filetypeslist', 0, '#'), null); $qo->maxbytes = $this->getpath($question, array('#', 'maxbytes', 0, '#'), null); $qo->graderinfo = $this->import_text_with_files($question, array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat)); $qo->responsetemplate['text'] = $this->getpath($question, array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true); $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question, array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat))); return $qo; } /** * Import a calculated question * @param object $question the imported XML data. */ public function import_calculated($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to calculated. $qo->qtype = 'calculated'; $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0); $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true'); $qo->single = $this->trans_single($single); $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false'); $qo->answernumbering = $this->getpath($question, array('#', 'answernumbering', 0, '#'), 'abc'); $qo->shuffleanswers = $this->trans_single($shuffleanswers); $this->import_combined_feedback($qo, $question); $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0); $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null); $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0); $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0); $qo->instructions = $this->getpath($question, array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true); if (!empty($instructions)) { $qo->instructions = $this->import_text_with_files($instructions, array('0'), '', $this->get_format($qo->questiontextformat)); } // Get answers array. $answers = $question['#']['answer']; $qo->answer = array(); $qo->feedback = array(); $qo->fraction = array(); $qo->tolerance = array(); $qo->tolerancetype = array(); $qo->correctanswerformat = array(); $qo->correctanswerlength = array(); $qo->feedback = array(); foreach ($answers as $answer) { $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat)); // Answer outside of is deprecated. if (empty($ans->answer['text'])) { $ans->answer['text'] = '*'; } // The qtype_calculatedmulti allows HTML in answer options. if ($question['@']['type'] == 'calculatedmulti') { // If the import file contains a "format" attribute for the answer text, // then use it. Otherwise, we must set the answerformat to FORMAT_PLAIN, // because the question has been exported from a Moodle version that // did not yet allow HTML answer options. if (array_key_exists('format', $answer['@'])) { $ans->answer['format'] = $this->trans_format($answer['@']['format']); } else { $ans->answer['format'] = FORMAT_PLAIN; } $qo->answer[] = $ans->answer; } else { $qo->answer[] = $ans->answer['text']; } $qo->feedback[] = $ans->feedback; $qo->tolerance[] = $answer['#']['tolerance'][0]['#']; // Fraction as a tag is deprecated. if (!empty($answer['#']['fraction'][0]['#'])) { $qo->fraction[] = $answer['#']['fraction'][0]['#']; } else { $qo->fraction[] = $answer['@']['fraction'] / 100; } $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#']; $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#']; $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#']; } // Get units array. $qo->unit = array(); if (isset($question['#']['units'][0]['#']['unit'])) { $units = $question['#']['units'][0]['#']['unit']; $qo->multiplier = array(); foreach ($units as $unit) { $qo->multiplier[] = $unit['#']['multiplier'][0]['#']; $qo->unit[] = $unit['#']['unit_name'][0]['#']; } } $instructions = $this->getpath($question, array('#', 'instructions'), array()); if (!empty($instructions)) { $qo->instructions = $this->import_text_with_files($instructions, array('0'), '', $this->get_format($qo->questiontextformat)); } if (is_null($qo->unitpenalty)) { // Set a good default, depending on whether there are any units defined. if (empty($qo->unit)) { $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here. } else { $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here. } } $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition'] ?? []; $qo->dataset = array(); $qo->datasetindex= 0; foreach ($datasets as $dataset) { $qo->datasetindex++; $qo->dataset[$qo->datasetindex] = new stdClass(); $qo->dataset[$qo->datasetindex]->status = $this->import_text($dataset['#']['status'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->name = $this->import_text($dataset['#']['name'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->type = $dataset['#']['type'][0]['#']; $qo->dataset[$qo->datasetindex]->distribution = $this->import_text($dataset['#']['distribution'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->max = $this->import_text($dataset['#']['maximum'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->min = $this->import_text($dataset['#']['minimum'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->length = $this->import_text($dataset['#']['decimals'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->distribution = $this->import_text($dataset['#']['distribution'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#']; $qo->dataset[$qo->datasetindex]->datasetitem = array(); $qo->dataset[$qo->datasetindex]->itemindex = 0; $qo->dataset[$qo->datasetindex]->number_of_items = $this->getpath($dataset, array('#', 'number_of_items', 0, '#'), 0); $datasetitems = $this->getpath($dataset, array('#', 'dataset_items', 0, '#', 'dataset_item'), array()); foreach ($datasetitems as $datasetitem) { $qo->dataset[$qo->datasetindex]->itemindex++; $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass(); $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber = $datasetitem['#']['number'][0]['#']; $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex]->value = $datasetitem['#']['value'][0]['#']; } } $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * This is not a real question type. It's a dummy type used to specify the * import category. The format is: * * tom/dick/harry * Category description * */ protected function import_category($question) { $qo = new stdClass(); $qo->qtype = 'category'; $qo->category = $this->import_text($question['#']['category'][0]['#']['text']); $qo->info = ''; $qo->infoformat = FORMAT_MOODLE; if (array_key_exists('info', $question['#'])) { $qo->info = $this->import_text($question['#']['info'][0]['#']['text']); // The import should have the format in human readable form, so translate to machine readable format. $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']); } $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null); return $qo; } /** * Parse the array of lines into an array of questions * this *could* burn memory - but it won't happen that much * so fingers crossed! * @param array of lines from the input file. * @param stdClass $context * @return array (of objects) question objects. */ public function readquestions($lines) { // We just need it as one big string. $lines = implode('', $lines); // This converts xml to big nasty data structure // the 0 means keep white space as it is (important for markdown format). try { $xml = xmlize($lines, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } unset($lines); // No need to keep this in memory. return $this->import_questions($xml['quiz']['#']['question']); } /** * @param array $xml the xmlized xml * @return stdClass[] question objects to pass to question type save_question_options */ public function import_questions($xml) { $questions = array(); // Iterate through questions. foreach ($xml as $questionxml) { $qo = $this->import_question($questionxml); // Stick the result in the $questions array. if ($qo) { $questions[] = $qo; } } return $questions; } /** * @param array $questionxml xml describing the question * @return null|stdClass an object with data to be fed to question type save_question_options */ protected function import_question($questionxml) { $questiontype = $questionxml['@']['type']; if ($questiontype == 'multichoice') { return $this->import_multichoice($questionxml); } else if ($questiontype == 'truefalse') { return $this->import_truefalse($questionxml); } else if ($questiontype == 'shortanswer') { return $this->import_shortanswer($questionxml); } else if ($questiontype == 'numerical') { return $this->import_numerical($questionxml); } else if ($questiontype == 'description') { return $this->import_description($questionxml); } else if ($questiontype == 'matching' || $questiontype == 'match') { return $this->import_match($questionxml); } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') { return $this->import_multianswer($questionxml); } else if ($questiontype == 'essay') { return $this->import_essay($questionxml); } else if ($questiontype == 'calculated') { return $this->import_calculated($questionxml); } else if ($questiontype == 'calculatedsimple') { $qo = $this->import_calculated($questionxml); $qo->qtype = 'calculatedsimple'; return $qo; } else if ($questiontype == 'calculatedmulti') { $qo = $this->import_calculated($questionxml); $qo->qtype = 'calculatedmulti'; return $qo; } else if ($questiontype == 'category') { return $this->import_category($questionxml); } else { // Not a type we handle ourselves. See if the question type wants // to handle it. if (!$qo = $this->try_importing_using_qtypes($questionxml, null, null, $questiontype)) { $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype)); return null; } return $qo; } } // EXPORT FUNCTIONS START HERE. public function export_file_extension() { return '.xml'; } /** * Turn the internal question type name into a human readable form. * (In the past, the code used to use integers internally. Now, it uses * strings, so there is less need for this, but to maintain * backwards-compatibility we change two of the type names.) * @param string $qtype question type plugin name. * @return string $qtype string to use in the file. */ protected function get_qtype($qtype) { switch($qtype) { case 'match': return 'matching'; case 'multianswer': return 'cloze'; default: return $qtype; } } /** * Convert internal Moodle text format code into * human readable form * @param int id internal code * @return string format text */ public function get_format($id) { switch($id) { case FORMAT_MOODLE: return 'moodle_auto_format'; case FORMAT_HTML: return 'html'; case FORMAT_PLAIN: return 'plain_text'; case FORMAT_WIKI: return 'wiki_like'; case FORMAT_MARKDOWN: return 'markdown'; default: return 'unknown'; } } /** * Convert internal single question code into * human readable form * @param int id single question code * @return string single question string */ public function get_single($id) { switch($id) { case 0: return 'false'; case 1: return 'true'; default: return 'unknown'; } } /** * Take a string, and wrap it in a CDATA secion, if that is required to make * the output XML valid. * @param string $string a string * @return string the string, wrapped in CDATA if necessary. */ public function xml_escape($string) { if (!empty($string) && htmlspecialchars($string, ENT_COMPAT) != $string) { // If the string contains something that looks like the end // of a CDATA section, then we need to avoid errors by splitting // the string between two CDATA sections. $string = str_replace(']]>', ']]]]>', $string); return ""; } else { return $string; } } /** * Generates tags, processing raw text therein * @param string $raw the content to output. * @param int $indent the current indent level. * @param bool $short stick it on one line. * @return string formatted text. */ public function writetext($raw, $indent = 0, $short = true) { $indent = str_repeat(' ', $indent); $raw = $this->xml_escape($raw); if ($short) { $xml = "{$indent}{$raw}\n"; } else { $xml = "{$indent}\n{$raw}\n{$indent}\n"; } return $xml; } /** * Generte the XML to represent some files. * @param array of store array of stored_file objects. * @return string $string the XML. */ public function write_files($files) { if (empty($files)) { return ''; } $string = ''; foreach ($files as $file) { if ($file->is_directory()) { continue; } $string .= ''; $string .= base64_encode($file->get_content()); $string .= "\n"; } return $string; } protected function presave_process($content) { // Override to allow us to add xml headers and footers. return ' ' . $content . ''; } /** * Turns question into an xml segment * @param object $question the question data. * @return string xml segment */ public function writequestion($question) { $invalidquestion = false; $fs = get_file_storage(); $contextid = $question->contextid; $question->status = 0; // Get files used by the questiontext. $question->questiontextfiles = $fs->get_area_files( $contextid, 'question', 'questiontext', $question->id); // Get files used by the generalfeedback. $question->generalfeedbackfiles = $fs->get_area_files( $contextid, 'question', 'generalfeedback', $question->id); if (!empty($question->options->answers)) { foreach ($question->options->answers as $answer) { $this->answerfiles[$answer->id] = $fs->get_area_files( $contextid, 'question', 'answer', $answer->id); $this->feedbackfiles[$answer->id] = $fs->get_area_files( $contextid, 'question', 'answerfeedback', $answer->id); } } $expout = ''; // Add a comment linking this to the original question id. $expout .= "\n"; // Check question type. $questiontype = $this->get_qtype($question->qtype); $idnumber = ''; if (isset($question->idnumber)) { $idnumber = htmlspecialchars($question->idnumber, ENT_COMPAT); } // Categories are a special case. if ($question->qtype == 'category') { $categorypath = $this->writetext($question->category); $categoryinfo = $this->writetext($question->info); $infoformat = $this->format($question->infoformat); $expout .= " \n"; $expout .= " \n"; $expout .= " {$categorypath}"; $expout .= " \n"; $expout .= " \n"; $expout .= " {$categoryinfo}"; $expout .= " \n"; $expout .= " {$idnumber}\n"; $expout .= " \n"; return $expout; } // Now we know we are are handing a real question. // Output the generic information. $expout .= " \n"; $expout .= " \n"; $expout .= $this->writetext($question->name, 3); $expout .= " \n"; $expout .= " format($question->questiontextformat)}>\n"; $expout .= $this->writetext($question->questiontext, 3); $expout .= $this->write_files($question->questiontextfiles); $expout .= " \n"; $expout .= " format($question->generalfeedbackformat)}>\n"; $expout .= $this->writetext($question->generalfeedback, 3); $expout .= $this->write_files($question->generalfeedbackfiles); $expout .= " \n"; if ($question->qtype != 'multianswer') { $expout .= " {$question->defaultmark}\n"; } $expout .= " {$question->penalty}\n"; $expout .= " {$question->status}\n"; $expout .= " {$idnumber}\n"; // The rest of the output depends on question type. switch($question->qtype) { case 'category': // Not a qtype really - dummy used for category switching. break; case 'truefalse': $trueanswer = $question->options->answers[$question->options->trueanswer]; $trueanswer->answer = 'true'; $expout .= $this->write_answer($trueanswer); $falseanswer = $question->options->answers[$question->options->falseanswer]; $falseanswer->answer = 'false'; $expout .= $this->write_answer($falseanswer); break; case 'multichoice': $expout .= " " . $this->get_single($question->options->single) . "\n"; $expout .= " " . $this->get_single($question->options->shuffleanswers) . "\n"; $expout .= " " . $question->options->answernumbering . "\n"; $expout .= " " . $question->options->showstandardinstruction . "\n"; $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid); $expout .= $this->write_answers($question->options->answers); break; case 'shortanswer': $expout .= " {$question->options->usecase}\n"; $expout .= $this->write_answers($question->options->answers); break; case 'numerical': foreach ($question->options->answers as $answer) { $expout .= $this->write_answer($answer, " {$answer->tolerance}\n"); } $units = $question->options->units; if (count($units)) { $expout .= "\n"; foreach ($units as $unit) { $expout .= " \n"; $expout .= " {$unit->multiplier}\n"; $expout .= " {$unit->unit}\n"; $expout .= " \n"; } $expout .= "\n"; } if (isset($question->options->unitgradingtype)) { $expout .= " " . $question->options->unitgradingtype . "\n"; } if (isset($question->options->unitpenalty)) { $expout .= " {$question->options->unitpenalty}\n"; } if (isset($question->options->showunits)) { $expout .= " {$question->options->showunits}\n"; } if (isset($question->options->unitsleft)) { $expout .= " {$question->options->unitsleft}\n"; } if (!empty($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, 'qtype_numerical', 'instruction', $question->id); $expout .= " format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " \n"; } break; case 'match': $expout .= " " . $this->get_single($question->options->shuffleanswers) . "\n"; $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid); foreach ($question->options->subquestions as $subquestion) { $files = $fs->get_area_files($contextid, 'qtype_match', 'subquestion', $subquestion->id); $expout .= " format($subquestion->questiontextformat) . ">\n"; $expout .= $this->writetext($subquestion->questiontext, 3); $expout .= $this->write_files($files); $expout .= " \n"; $expout .= $this->writetext($subquestion->answertext, 4); $expout .= " \n"; $expout .= " \n"; } break; case 'description': // Nothing else to do. break; case 'multianswer': foreach ($question->options->questions as $index => $subq) { $expout = str_replace('{#' . $index . '}', $subq->questiontext, $expout); } break; case 'essay': $expout .= " " . $question->options->responseformat . "\n"; $expout .= " " . $question->options->responserequired . "\n"; $expout .= " " . $question->options->responsefieldlines . "\n"; $expout .= " " . $question->options->minwordlimit . "\n"; $expout .= " " . $question->options->maxwordlimit . "\n"; $expout .= " " . $question->options->attachments . "\n"; $expout .= " " . $question->options->attachmentsrequired . "\n"; $expout .= " " . $question->options->maxbytes . "\n"; $expout .= " " . $question->options->filetypeslist . "\n"; $expout .= " format($question->options->graderinfoformat) . ">\n"; $expout .= $this->writetext($question->options->graderinfo, 3); $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay', 'graderinfo', $question->id)); $expout .= " \n"; $expout .= " format($question->options->responsetemplateformat) . ">\n"; $expout .= $this->writetext($question->options->responsetemplate, 3); $expout .= " \n"; break; case 'calculated': case 'calculatedsimple': case 'calculatedmulti': $expout .= " {$question->options->synchronize}\n"; $expout .= " {$question->options->single}\n"; $expout .= " " . $question->options->answernumbering . "\n"; $expout .= " " . $question->options->shuffleanswers . "\n"; $component = 'qtype_' . $question->qtype; $files = $fs->get_area_files($contextid, $component, 'correctfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->correctfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; $files = $fs->get_area_files($contextid, $component, 'partiallycorrectfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; $files = $fs->get_area_files($contextid, $component, 'incorrectfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->incorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; foreach ($question->options->answers as $answer) { $percent = 100 * $answer->fraction; // For qtype_calculatedmulti, answer options (choices) can be in plain text or in HTML // format, so we need to specify when exporting a question. if ($component == 'qtype_calculatedmulti') { $expout .= "format($answer->answerformat)}>\n"; } else { $expout .= "\n"; } // The "" tags are an added feature, old files won't have them. $expout .= $this->writetext($answer->answer); $expout .= $this->write_files($this->answerfiles[$answer->id]); $expout .= " {$answer->tolerance}\n"; $expout .= " {$answer->tolerancetype}\n"; $expout .= " " . $answer->correctanswerformat . "\n"; $expout .= " " . $answer->correctanswerlength . "\n"; $expout .= " format($answer->feedbackformat)}>\n"; $expout .= $this->writetext($answer->feedback, 4); $expout .= $this->write_files($this->feedbackfiles[$answer->id]); $expout .= " \n"; $expout .= " \n"; } if (isset($question->options->unitgradingtype)) { $expout .= " " . $question->options->unitgradingtype . "\n"; } if (isset($question->options->unitpenalty)) { $expout .= " " . $question->options->unitpenalty . "\n"; } if (isset($question->options->showunits)) { $expout .= " {$question->options->showunits}\n"; } if (isset($question->options->unitsleft)) { $expout .= " {$question->options->unitsleft}\n"; } if (isset($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, $component, 'instruction', $question->id); $expout .= " format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " \n"; } if (isset($question->options->units)) { $units = $question->options->units; if (count($units)) { $expout .= "\n"; foreach ($units as $unit) { $expout .= " \n"; $expout .= " {$unit->multiplier}\n"; $expout .= " {$unit->unit}\n"; $expout .= " \n"; } $expout .= "\n"; } } // The tag $question->export_process has been set so we get all the // data items in the database from the function // qtype_calculated::get_question_options calculatedsimple defaults // to calculated. if (isset($question->options->datasets) && count($question->options->datasets)) { $expout .= "\n"; foreach ($question->options->datasets as $def) { $expout .= "\n"; $expout .= " ".$this->writetext($def->status)."\n"; $expout .= " ".$this->writetext($def->name)."\n"; if ($question->qtype == 'calculated') { $expout .= " calculated\n"; } else { $expout .= " calculatedsimple\n"; } $expout .= " " . $this->writetext($def->distribution) . "\n"; $expout .= " " . $this->writetext($def->minimum) . "\n"; $expout .= " " . $this->writetext($def->maximum) . "\n"; $expout .= " " . $this->writetext($def->decimals) . "\n"; $expout .= " {$def->itemcount}\n"; if ($def->itemcount > 0) { $expout .= " \n"; foreach ($def->items as $item) { $expout .= " \n"; $expout .= " ".$item->itemnumber."\n"; $expout .= " ".$item->value."\n"; $expout .= " \n"; } $expout .= " \n"; $expout .= " " . $def->number_of_items . "\n"; } $expout .= "\n"; } $expout .= "\n"; } break; default: // Try support by optional plugin. if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) { $invalidquestion = true; } else { $expout .= $data; } } // Output any hints. $expout .= $this->write_hints($question); // Write the question tags. if (core_tag_tag::is_enabled('core_question', 'question')) { $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id); if (!empty($tagobjects)) { $context = context::instance_by_id($contextid); $sortedtagobjects = question_sort_tags($tagobjects, $context); if (!empty($sortedtagobjects->coursetags)) { // Set them on the form to be rendered as existing tags. $expout .= " \n"; foreach ($sortedtagobjects->coursetags as $coursetag) { $expout .= " " . $this->writetext($coursetag, 0, true) . "\n"; } $expout .= " \n"; } if (!empty($sortedtagobjects->tags)) { $expout .= " \n"; foreach ($sortedtagobjects->tags as $tag) { $expout .= " " . $this->writetext($tag, 0, true) . "\n"; } $expout .= " \n"; } } } // Close the question tag. $expout .= " \n"; if ($invalidquestion) { return ''; } else { return $expout; } } public function write_answers($answers) { if (empty($answers)) { return; } $output = ''; foreach ($answers as $answer) { $output .= $this->write_answer($answer); } return $output; } public function write_answer($answer, $extra = '') { $percent = $answer->fraction * 100; $output = ''; $output .= " format($answer->answerformat)}>\n"; $output .= $this->writetext($answer->answer, 3); $output .= $this->write_files($this->answerfiles[$answer->id]); $output .= " format($answer->feedbackformat)}>\n"; $output .= $this->writetext($answer->feedback, 4); $output .= $this->write_files($this->feedbackfiles[$answer->id]); $output .= " \n"; $output .= $extra; $output .= " \n"; return $output; } /** * Write out the hints. * @param object $question the question definition data. * @return string XML to output. */ public function write_hints($question) { if (empty($question->hints)) { return ''; } $output = ''; foreach ($question->hints as $hint) { $output .= $this->write_hint($hint, $question->contextid); } return $output; } /** * @param int $format a FORMAT_... constant. * @return string the attribute to add to an XML tag. */ public function format($format) { return 'format="' . $this->get_format($format) . '"'; } public function write_hint($hint, $contextid) { $fs = get_file_storage(); $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id); $output = ''; $output .= " format($hint->hintformat)}>\n"; $output .= ' ' . $this->writetext($hint->hint); if (!empty($hint->shownumcorrect)) { $output .= " \n"; } if (!empty($hint->clearwrong)) { $output .= " \n"; } if (!empty($hint->options)) { $output .= ' ' . $this->xml_escape($hint->options) . "\n"; } $output .= $this->write_files($files); $output .= " \n"; return $output; } /** * Output the combined feedback fields. * @param object $questionoptions the question definition data. * @param int $questionid the question id. * @param int $contextid the question context id. * @return string XML to output. */ public function write_combined_feedback($questionoptions, $questionid, $contextid) { $fs = get_file_storage(); $output = ''; $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); foreach ($fields as $field) { $formatfield = $field . 'format'; $files = $fs->get_area_files($contextid, 'question', $field, $questionid); $output .= " <{$field} {$this->format($questionoptions->$formatfield)}>\n"; $output .= ' ' . $this->writetext($questionoptions->$field); $output .= $this->write_files($files); $output .= " \n"; } if (!empty($questionoptions->shownumcorrect)) { $output .= " \n"; } return $output; } } PK\x8format/blackboard_six/lang/en/qformat_blackboard_six.phpnu[. /** * Strings for component 'qformat_blackboard_six', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_blackboard_six * @copyright 2010 Helen Foster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['defaultname'] = 'Imported question {$a}'; $string['errormanifest'] = 'Error while parsing the IMS manifest document'; $string['importnotext'] = 'Missing question text in XML file'; $string['filenothandled'] = 'This archive contains reference to a file material {$a} which is not currently handled by import'; $string['imagenotfound'] = 'Image file with path {$a} was not found in the import.'; $string['importedcategory'] = 'Imported category {$a}'; $string['notenoughtsubans'] = 'Unable to import matching question \'{$a}\' because a matching question must comprise at least two questions and three answers.'; $string['pluginname'] = 'Blackboard'; $string['pluginname_help'] = 'Blackboard format enables questions saved in all Blackboard export formats to be imported via a dat or zip file. For zip files, images import is supported.'; $string['privacy:metadata'] = 'The Blackbard question format plugin does not store any personal data.'; $string['unhandledpresblock'] = 'Unhandled presentation block'; PK\ ӡYY!format/blackboard_six/version.phpnu[. /** * Version information for the blackboard_six question import format. * * @package qformat_blackboard_six * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_blackboard_six'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\_#format/blackboard_six/formatqti.phpnu[. /** * Blackboard V5 and V6 question importer. * * @package qformat_blackboard_six * @copyright 2005 Michael Penney * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/xmlize.php'); /** * Blackboard 6.0 question importer. * * @copyright 2005 Michael Penney * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard_six_qti extends qformat_blackboard_six_base { /** * Parse the xml document into an array of questions * this *could* burn memory - but it won't happen that much * so fingers crossed! * @param array $text array of lines from the input file. * @return array (of objects) questions objects. */ protected function readquestions($text) { // This converts xml to big nasty data structure, // the 0 means keep white space as it is. try { $xml = xmlize($text, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } $questions = array(); // Treat the assessment title as a category title. $this->process_category($xml, $questions); // First step : we are only interested in the tags. $rawquestions = $this->getpath($xml, array('questestinterop', '#', 'assessment', 0, '#', 'section', 0, '#', 'item'), array(), false); // Each tag contains data related to a single question. foreach ($rawquestions as $quest) { // Second step : parse each question data into the intermediate // rawquestion structure array. // Warning : rawquestions are not Moodle questions. $question = $this->create_raw_question($quest); // Third step : convert a rawquestion into a Moodle question. switch($question->qtype) { case "Matching": $this->process_matching($question, $questions); break; case "Multiple Choice": $this->process_mc($question, $questions); break; case "Essay": $this->process_essay($question, $questions); break; case "Multiple Answer": $this->process_ma($question, $questions); break; case "True/False": $this->process_tf($question, $questions); break; case 'Fill in the Blank': $this->process_fblank($question, $questions); break; case 'Short Response': $this->process_essay($question, $questions); break; default: $this->error(get_string('unknownorunhandledtype', 'question', $question->qtype)); break; } } return $questions; } /** * Creates a cleaner object to deal with for processing into Moodle. * The object returned is NOT a moodle question object. * @param array $quest XML question data * @return object rawquestion */ public function create_raw_question($quest) { $rawquestion = new stdClass(); $rawquestion->qtype = $this->getpath($quest, array('#', 'itemmetadata', 0, '#', 'bbmd_questiontype', 0, '#'), '', true); $rawquestion->id = $this->getpath($quest, array('#', 'itemmetadata', 0, '#', 'bbmd_asi_object_id', 0, '#'), '', true); $presentation = new stdClass(); $presentation->blocks = $this->getpath($quest, array('#', 'presentation', 0, '#', 'flow', 0, '#', 'flow'), array(), false); foreach ($presentation->blocks as $pblock) { $block = new stdClass(); $block->type = $this->getpath($pblock, array('@', 'class'), '', true); switch($block->type) { case 'QUESTION_BLOCK': $subblocks = $this->getpath($pblock, array('#', 'flow'), array(), false); foreach ($subblocks as $sblock) { $this->process_block($sblock, $block); } break; case 'RESPONSE_BLOCK': $choices = null; switch($rawquestion->qtype) { case 'Matching': $bbsubquestions = $this->getpath($pblock, array('#', 'flow'), array(), false); foreach ($bbsubquestions as $bbsubquestion) { $subquestion = new stdClass(); $subquestion->ident = $this->getpath($bbsubquestion, array('#', 'response_lid', 0, '@', 'ident'), '', true); $this->process_block($this->getpath($bbsubquestion, array('#', 'flow', 0), false, false), $subquestion); $bbchoices = $this->getpath($bbsubquestion, array('#', 'response_lid', 0, '#', 'render_choice', 0, '#', 'flow_label', 0, '#', 'response_label'), array(), false); $choices = array(); $this->process_choices($bbchoices, $choices); $subquestion->choices = $choices; if (!isset($block->subquestions)) { $block->subquestions = array(); } $block->subquestions[] = $subquestion; } break; case 'Multiple Answer': $bbchoices = $this->getpath($pblock, array('#', 'response_lid', 0, '#', 'render_choice', 0, '#', 'flow_label'), array(), false); $choices = array(); $this->process_choices($bbchoices, $choices); $block->choices = $choices; break; case 'Essay': // Doesn't apply since the user responds with text input. break; case 'Multiple Choice': $mcchoices = $this->getpath($pblock, array('#', 'response_lid', 0, '#', 'render_choice', 0, '#', 'flow_label'), array(), false); foreach ($mcchoices as $mcchoice) { $choices = new stdClass(); $choices = $this->process_block($mcchoice, $choices); $block->choices[] = $choices; } break; case 'Short Response': // Do nothing? break; case 'Fill in the Blank': // Do nothing? break; default: $bbchoices = $this->getpath($pblock, array('#', 'response_lid', 0, '#', 'render_choice', 0, '#', 'flow_label', 0, '#', 'response_label'), array(), false); $choices = array(); $this->process_choices($bbchoices, $choices); $block->choices = $choices; } break; case 'RIGHT_MATCH_BLOCK': $matchinganswerset = $this->getpath($pblock, array('#', 'flow'), false, false); $answerset = array(); foreach ($matchinganswerset as $answer) { $bbanswer = new stdClass; $bbanswer->text = $this->getpath($answer, array('#', 'flow', 0, '#', 'material', 0, '#', 'mat_extension', 0, '#', 'mat_formattedtext', 0, '#'), false, false); $answerset[] = $bbanswer; } $block->matchinganswerset = $answerset; break; default: $this->error(get_string('unhandledpresblock', 'qformat_blackboard_six')); break; } $rawquestion->{$block->type} = $block; } // Determine response processing. // There is a section called 'outcomes' that I don't know what to do with. $resprocessing = $this->getpath($quest, array('#', 'resprocessing'), array(), false); $respconditions = $this->getpath($resprocessing[0], array('#', 'respcondition'), array(), false); $responses = array(); if ($rawquestion->qtype == 'Matching') { $this->process_matching_responses($respconditions, $responses); } else { $this->process_responses($respconditions, $responses); } $rawquestion->responses = $responses; $feedbackset = $this->getpath($quest, array('#', 'itemfeedback'), array(), false); $feedbacks = array(); $this->process_feedback($feedbackset, $feedbacks); $rawquestion->feedback = $feedbacks; return $rawquestion; } /** * Helper function to process an XML block into an object. * Can call himself recursively if necessary to parse this branch of the XML tree. * @param array $curblock XML block to parse * @param object $block block already parsed so far * @return object $block parsed */ public function process_block($curblock, $block) { $curtype = $this->getpath($curblock, array('@', 'class'), '', true); switch($curtype) { case 'FORMATTED_TEXT_BLOCK': $text = $this->getpath($curblock, array('#', 'material', 0, '#', 'mat_extension', 0, '#', 'mat_formattedtext', 0, '#'), '', true); $block->text = $this->strip_applet_tags_get_mathml($text); break; case 'FILE_BLOCK': $block->filename = $this->getpath($curblock, array('#', 'material', 0, '#'), '', true); if ($block->filename != '') { // TODO : determine what to do with the file's content. $this->error(get_string('filenothandled', 'qformat_blackboard_six', $block->filename)); } break; case 'Block': if ($this->getpath($curblock, array('#', 'material', 0, '#', 'mattext'), false, false)) { $block->text = $this->getpath($curblock, array('#', 'material', 0, '#', 'mattext', 0, '#'), '', true); } else if ($this->getpath($curblock, array('#', 'material', 0, '#', 'mat_extension', 0, '#', 'mat_formattedtext'), false, false)) { $block->text = $this->getpath($curblock, array('#', 'material', 0, '#', 'mat_extension', 0, '#', 'mat_formattedtext', 0, '#'), '', true); } else if ($this->getpath($curblock, array('#', 'response_label'), false, false)) { // This is a response label block. $subblocks = $this->getpath($curblock, array('#', 'response_label', 0), array(), false); if (!isset($block->ident)) { if ($this->getpath($subblocks, array('@', 'ident'), '', true)) { $block->ident = $this->getpath($subblocks, array('@', 'ident'), '', true); } } foreach ($this->getpath($subblocks, array('#', 'flow_mat'), array(), false) as $subblock) { $this->process_block($subblock, $block); } } else { if ($this->getpath($curblock, array('#', 'flow_mat'), false, false) || $this->getpath($curblock, array('#', 'flow'), false, false)) { if ($this->getpath($curblock, array('#', 'flow_mat'), false, false)) { $subblocks = $this->getpath($curblock, array('#', 'flow_mat'), array(), false); } else if ($this->getpath($curblock, array('#', 'flow'), false, false)) { $subblocks = $this->getpath($curblock, array('#', 'flow'), array(), false); } foreach ($subblocks as $sblock) { // This will recursively grab the sub blocks which should be of one of the other types. $this->process_block($sblock, $block); } } } break; case 'LINK_BLOCK': // Not sure how this should be included? $link = $this->getpath($curblock, array('#', 'material', 0, '#', 'mattext', 0, '@', 'uri'), '', true); if (!empty($link)) { $block->link = $link; } else { $block->link = ''; } break; } return $block; } /** * Preprocess XML blocks containing data for questions' choices. * Called by {@link create_raw_question()} * for matching, multichoice and fill in the blank questions. * @param array $bbchoices XML block to parse * @param array $choices array of choices suitable for a rawquestion. */ protected function process_choices($bbchoices, &$choices) { foreach ($bbchoices as $choice) { if ($this->getpath($choice, array('@', 'ident'), '', true)) { $curchoice = $this->getpath($choice, array('@', 'ident'), '', true); } else { // For multiple answers. $curchoice = $this->getpath($choice, array('#', 'response_label', 0), array(), false); } if ($this->getpath($choice, array('#', 'flow_mat', 0), false, false)) { // For multiple answers. $curblock = $this->getpath($choice, array('#', 'flow_mat', 0), false, false); // Reset $curchoice to new stdClass because process_block is expecting an object // for the second argument and not a string, // which is what is was set as originally - CT 8/7/06. $curchoice = new stdClass(); $this->process_block($curblock, $curchoice); } else if ($this->getpath($choice, array('#', 'response_label'), false, false)) { // Reset $curchoice to new stdClass because process_block is expecting an object // for the second argument and not a string, // which is what is was set as originally - CT 8/7/06. $curchoice = new stdClass(); $this->process_block($choice, $curchoice); } $choices[] = $curchoice; } } /** * Preprocess XML blocks containing data for subanswers * Called by {@link create_raw_question()} * for matching questions only. * @param array $bbresponses XML block to parse * @param array $responses array of responses suitable for a matching rawquestion. */ protected function process_matching_responses($bbresponses, &$responses) { foreach ($bbresponses as $bbresponse) { $response = new stdClass; if ($this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'varequal'), false, false)) { $response->correct = $this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'varequal', 0, '#'), '', true); $response->ident = $this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'varequal', 0, '@', 'respident'), '', true); } // Suppressed an else block because if the above if condition is false, // the question is not necessary a broken one, most of the time it's an tag. $response->feedback = $this->getpath($bbresponse, array('#', 'displayfeedback', 0, '@', 'linkrefid'), '', true); $responses[] = $response; } } /** * Preprocess XML blocks containing data for responses processing. * Called by {@link create_raw_question()} * for all questions types. * @param array $bbresponses XML block to parse * @param array $responses array of responses suitable for a rawquestion. */ protected function process_responses($bbresponses, &$responses) { foreach ($bbresponses as $bbresponse) { $response = new stdClass(); if ($this->getpath($bbresponse, array('@', 'title'), '', true)) { $response->title = $this->getpath($bbresponse, array('@', 'title'), '', true); } else { $response->title = $this->getpath($bbresponse, array('#', 'displayfeedback', 0, '@', 'linkrefid'), '', true); } $response->ident = array(); if ($this->getpath($bbresponse, array('#', 'conditionvar', 0, '#'), false, false)) { $response->ident[0] = $this->getpath($bbresponse, array('#', 'conditionvar', 0, '#'), array(), false); } else if ($this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'other', 0, '#'), false, false)) { $response->ident[0] = $this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'other', 0, '#'), array(), false); } if ($this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'and'), false, false)) { $responseset = $this->getpath($bbresponse, array('#', 'conditionvar', 0, '#', 'and'), array(), false); foreach ($responseset as $rs) { $response->ident[] = $this->getpath($rs, array('#'), array(), false); if (!isset($response->feedback) and $this->getpath($rs, array('@'), false, false)) { $response->feedback = $this->getpath($rs, array('@', 'respident'), '', true); } } } else { $response->feedback = $this->getpath($bbresponse, array('#', 'displayfeedback', 0, '@', 'linkrefid'), '', true); } // Determine what fraction to give response. if ($this->getpath($bbresponse, array('#', 'setvar'), false, false)) { switch ($this->getpath($bbresponse, array('#', 'setvar', 0, '#'), false, false)) { case "SCORE.max": $response->fraction = 1; break; default: // I have only seen this being 0 or unset. // There are probably fractional values of SCORE.max, but I'm not sure what they look like. $response->fraction = 0; break; } } else { // Just going to assume this is the case this is probably not correct. $response->fraction = 0; } $responses[] = $response; } } /** * Preprocess XML blocks containing data for responses feedbacks. * Called by {@link create_raw_question()} * for all questions types. * @param array $feedbackset XML block to parse * @param array $feedbacks array of feedbacks suitable for a rawquestion. */ public function process_feedback($feedbackset, &$feedbacks) { foreach ($feedbackset as $bbfeedback) { $feedback = new stdClass(); $feedback->ident = $this->getpath($bbfeedback, array('@', 'ident'), '', true); $feedback->text = ''; if ($this->getpath($bbfeedback, array('#', 'flow_mat', 0), false, false)) { $this->process_block($this->getpath($bbfeedback, array('#', 'flow_mat', 0), false, false), $feedback); } else if ($this->getpath($bbfeedback, array('#', 'solution', 0, '#', 'solutionmaterial', 0, '#', 'flow_mat', 0), false, false)) { $this->process_block($this->getpath($bbfeedback, array('#', 'solution', 0, '#', 'solutionmaterial', 0, '#', 'flow_mat', 0), false, false), $feedback); } $feedbacks[$feedback->ident] = $feedback; } } /** * Create common parts of question * @param object $quest rawquestion * @return object Moodle question. */ public function process_common($quest) { $question = $this->defaultquestion(); $text = $quest->QUESTION_BLOCK->text; $questiontext = $this->cleaned_text_field($text); $question->questiontext = $questiontext['text']; $question->questiontextformat = $questiontext['format']; // Needed because add_blank_combined_feedback uses it. if (isset($questiontext['itemid'])) { $question->questiontextitemid = $questiontext['itemid']; } $question->name = $this->create_default_question_name($question->questiontext, get_string('defaultname', 'qformat_blackboard_six' , $quest->id)); $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_HTML; $question->generalfeedbackfiles = array(); return $question; } /** * Process True / False Questions * Parse a truefalse rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done */ protected function process_tf($quest, &$questions) { $question = $this->process_common($quest); $question->qtype = 'truefalse'; $question->single = 1; // Only one answer is allowed. $question->penalty = 1; // Penalty = 1 for truefalse questions. // 0th [response] is the correct answer. $responses = $quest->responses; $correctresponse = $this->getpath($responses[0]->ident[0], array('varequal', 0, '#'), '', true); if ($correctresponse != 'false') { $correct = true; } else { $correct = false; } $fback = new stdClass(); foreach ($quest->feedback as $fb) { $fback->{$fb->ident} = $fb->text; } if ($correct) { // True is correct. $question->answer = 1; $question->feedbacktrue = $this->cleaned_text_field($fback->correct); $question->feedbackfalse = $this->cleaned_text_field($fback->incorrect); } else { // False is correct. $question->answer = 0; $question->feedbacktrue = $this->cleaned_text_field($fback->incorrect); $question->feedbackfalse = $this->cleaned_text_field($fback->correct); } $question->correctanswer = $question->answer; $questions[] = $question; } /** * Process Fill in the Blank Questions * Parse a fillintheblank rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done. */ protected function process_fblank($quest, &$questions) { $question = $this->process_common($quest); $question->qtype = 'shortanswer'; $question->usecase = 0; // Ignore case. $answers = array(); $fractions = array(); $feedbacks = array(); // Extract the feedback. $feedback = array(); foreach ($quest->feedback as $fback) { if (isset($fback->ident)) { if ($fback->ident == 'correct' || $fback->ident == 'incorrect') { $feedback[$fback->ident] = $fback->text; } } } foreach ($quest->responses as $response) { if (isset($response->title)) { if ($this->getpath($response->ident[0], array('varequal', 0, '#'), false, false)) { // For BB Fill in the Blank, only interested in correct answers. if ($response->feedback = 'correct') { $answers[] = $this->getpath($response->ident[0], array('varequal', 0, '#'), '', true); $fractions[] = 1; if (isset($feedback['correct'])) { $feedbacks[] = $this->cleaned_text_field($feedback['correct']); } else { $feedbacks[] = $this->text_field(''); } } } } } // Adding catchall to so that students can see feedback for incorrect answers when they enter something, // the instructor did not enter. $answers[] = '*'; $fractions[] = 0; if (isset($feedback['incorrect'])) { $feedbacks[] = $this->cleaned_text_field($feedback['incorrect']); } else { $feedbacks[] = $this->text_field(''); } $question->answer = $answers; $question->fraction = $fractions; $question->feedback = $feedbacks; // Changed to assign $feedbacks to $question->feedback instead of. if (!empty($question)) { $questions[] = $question; } } /** * Process Multichoice Questions * Parse a multichoice single answer rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done. */ protected function process_mc($quest, &$questions) { $question = $this->process_common($quest); $question->qtype = 'multichoice'; $question = $this->add_blank_combined_feedback($question); $question->single = 1; $feedback = array(); foreach ($quest->feedback as $fback) { $feedback[$fback->ident] = $fback->text; } foreach ($quest->responses as $response) { if (isset($response->title)) { if ($response->title == 'correct') { // Only one answer possible for this qtype so first index is correct answer. $correct = $this->getpath($response->ident[0], array('varequal', 0, '#'), '', true); } } else { // Fallback method for when the title is not set. if ($response->feedback == 'correct') { // Only one answer possible for this qtype so first index is correct answer. $correct = $this->getpath($response->ident[0], array('varequal', 0, '#'), '', true); } } } $i = 0; foreach ($quest->RESPONSE_BLOCK->choices as $response) { $question->answer[$i] = $this->cleaned_text_field($response->text); if ($correct == $response->ident) { $question->fraction[$i] = 1; // This is a bit of a hack to catch the feedback... first we see if a 'specific' // feedback for this response exists, then if a 'correct' feedback exists. if (!empty($feedback[$response->ident]) ) { $question->feedback[$i] = $this->cleaned_text_field($feedback[$response->ident]); } else if (!empty($feedback['correct'])) { $question->feedback[$i] = $this->cleaned_text_field($feedback['correct']); } else if (!empty($feedback[$i])) { $question->feedback[$i] = $this->cleaned_text_field($feedback[$i]); } else { $question->feedback[$i] = $this->cleaned_text_field(get_string('correct', 'question')); } } else { $question->fraction[$i] = 0; if (!empty($feedback[$response->ident]) ) { $question->feedback[$i] = $this->cleaned_text_field($feedback[$response->ident]); } else if (!empty($feedback['incorrect'])) { $question->feedback[$i] = $this->cleaned_text_field($feedback['incorrect']); } else if (!empty($feedback[$i])) { $question->feedback[$i] = $this->cleaned_text_field($feedback[$i]); } else { $question->feedback[$i] = $this->cleaned_text_field(get_string('incorrect', 'question')); } } $i++; } if (!empty($question)) { $questions[] = $question; } } /** * Process Multiple Choice Questions With Multiple Answers. * Parse a multichoice multianswer rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done. */ public function process_ma($quest, &$questions) { $question = $this->process_common($quest); $question->qtype = 'multichoice'; $question = $this->add_blank_combined_feedback($question); $question->single = 0; // More than one answer allowed. $answers = $quest->responses; $correctanswers = array(); foreach ($answers as $answer) { if ($answer->title == 'correct') { $answerset = $this->getpath($answer->ident[0], array('and', 0, '#', 'varequal'), array(), false); foreach ($answerset as $ans) { $correctanswers[] = $ans['#']; } } } $feedback = new stdClass(); foreach ($quest->feedback as $fb) { $feedback->{$fb->ident} = trim($fb->text); } $correctanswercount = count($correctanswers); $fraction = 1 / $correctanswercount; $choiceset = $quest->RESPONSE_BLOCK->choices; $i = 0; foreach ($choiceset as $choice) { $question->answer[$i] = $this->cleaned_text_field(trim($choice->text)); if (in_array($choice->ident, $correctanswers)) { // Correct answer. $question->fraction[$i] = $fraction; $question->feedback[$i] = $this->cleaned_text_field($feedback->correct); } else { // Wrong answer. $question->fraction[$i] = 0; $question->feedback[$i] = $this->cleaned_text_field($feedback->incorrect); } $i++; } $questions[] = $question; } /** * Process Essay Questions * Parse an essay rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done. */ public function process_essay($quest, &$questions) { $question = $this->process_common($quest); $question->qtype = 'essay'; $question->feedback = array(); // Not sure where to get the correct answer from? foreach ($quest->feedback as $feedback) { // Added this code to put the possible solution that the // instructor gives as the Moodle answer for an essay question. if ($feedback->ident == 'solution') { $question->graderinfo = $this->cleaned_text_field($feedback->text); } } // Added because essay/questiontype.php:save_question_option is expecting a // fraction property - CT 8/10/06. $question->fraction[] = 1; $question->defaultmark = 1; $question->responseformat = 'editor'; $question->responserequired = 1; $question->responsefieldlines = 15; $question->attachments = 0; $question->attachmentsrequired = 0; $question->responsetemplate = $this->text_field(''); $questions[] = $question; } /** * Process Matching Questions * Parse a matching rawquestion and add the result * to the array of questions already parsed. * @param object $quest rawquestion * @param array $questions array of Moodle questions already done. */ public function process_matching($quest, &$questions) { // Blackboard matching questions can't be imported in core Moodle without a loss in data, // as core match question don't allow HTML in subanswers. The contributed ddmatch // question type support HTML in subanswers. // The ddmatch question type is not part of core, so we need to check if it is defined. $ddmatchisinstalled = question_bank::is_qtype_installed('ddmatch'); $question = $this->process_common($quest); $question = $this->add_blank_combined_feedback($question); $question->valid = true; if ($ddmatchisinstalled) { $question->qtype = 'ddmatch'; } else { $question->qtype = 'match'; } // Construction of the array holding mappings between subanswers and subquestions. foreach ($quest->RESPONSE_BLOCK->subquestions as $qid => $subq) { foreach ($quest->responses as $rid => $resp) { if (isset($resp->ident) && $resp->ident == $subq->ident) { $correct = $resp->correct; } } foreach ($subq->choices as $cid => $choice) { if ($choice == $correct) { $mappings[$subq->ident] = $cid; } } } foreach ($subq->choices as $choiceid => $choice) { $subanswertext = $quest->RIGHT_MATCH_BLOCK->matchinganswerset[$choiceid]->text; if ($ddmatchisinstalled) { $subanswer = $this->cleaned_text_field($subanswertext); } else { $subanswertext = html_to_text($this->cleaninput($subanswertext), 0); $subanswer = $subanswertext; } if ($subanswertext != '') { // Only import non empty subanswers. $subquestion = ''; $fiber = moodle_array_keys_filter($mappings, $choiceid); foreach ($fiber as $correctanswerid) { // We have found a correspondance for this subanswer so we need to take the associated subquestion. foreach ($quest->RESPONSE_BLOCK->subquestions as $qid => $subq) { $currentsubqid = $subq->ident; if (strcmp ($currentsubqid, $correctanswerid) == 0) { $subquestion = $subq->text; break; } } $question->subquestions[] = $this->cleaned_text_field($subquestion); $question->subanswers[] = $subanswer; } if ($subquestion == '') { // Then in this case, $choice is a distractor. $question->subquestions[] = $this->text_field(''); $question->subanswers[] = $subanswer; } } } // Verify that this matching question has enough subquestions and subanswers. $subquestioncount = 0; $subanswercount = 0; $subanswers = $question->subanswers; foreach ($question->subquestions as $key => $subquestion) { $subquestion = $subquestion['text']; $subanswer = $subanswers[$key]; if ($subquestion != '') { $subquestioncount++; } $subanswercount++; } if ($subquestioncount < 2 || $subanswercount < 3) { $this->error(get_string('notenoughtsubans', 'qformat_blackboard_six', $question->questiontext)); } else { $questions[] = $question; } } /** * Add a category question entry based on the assessment title * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_category($xml, &$questions) { $title = $this->getpath($xml, array('questestinterop', '#', 'assessment', 0, '@', 'title'), '', true); $dummyquestion = new stdClass(); $dummyquestion->qtype = 'category'; $dummyquestion->category = $this->cleaninput($this->clean_question_name($title)); $questions[] = $dummyquestion; } /** * Strip the applet tag used by Blackboard to render mathml formulas, * keeping the mathml tag. * @param string $string * @return string */ public function strip_applet_tags_get_mathml($string) { if (stristr($string, '') === false) { return $string; } else { // Strip all applet tags keeping stuff before/after and inbetween (if mathml) them. while (stristr($string, '') !== false) { preg_match("/(.*)\.*\<\/math\>)\".*\<\/applet\>(.*)/i", $string, $mathmls); $string = $mathmls[1].$mathmls[2].$mathmls[3]; } return $string; } } } PK\$2format/blackboard_six/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_blackboard_six. * * @package qformat_blackboard_six * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_blackboard_six\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_blackboard_six implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\dc׀QQ$format/blackboard_six/formatpool.phpnu[. /** * Blackboard V5 and V6 question importer. * * @package qformat_blackboard_six * @copyright 2003 Scott Elliott * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/xmlize.php'); /** * Blackboard pool question importer class. * * @package qformat_blackboard_six * @copyright 2003 Scott Elliott * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard_six_pool extends qformat_blackboard_six_base { /** * @var bool Is the current question's question text escaped HTML * (true for most if not all Blackboard files). */ public $ishtml = true; /** * Parse the xml document into an array of questions * * This *could* burn memory - but it won't happen that much * so fingers crossed! * * @param array $text array of lines from the input file. * @return array (of objects) questions objects. */ protected function readquestions($text) { // This converts xml to big nasty data structure, // the 0 means keep white space as it is. try { $xml = xmlize($text, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } $questions = array(); $this->process_category($xml, $questions); $this->process_tf($xml, $questions); $this->process_mc($xml, $questions); $this->process_ma($xml, $questions); $this->process_fib($xml, $questions); $this->process_matching($xml, $questions); $this->process_essay($xml, $questions); return $questions; } /** * Do question import processing common to every qtype. * * @param array $questiondata the xml tree related to the current question * @return object initialized question object. */ public function process_common($questiondata) { // This routine initialises the question object. $question = $this->defaultquestion(); // Determine if the question is already escaped html. $this->ishtml = $this->getpath($questiondata, array('#', 'BODY', 0, '#', 'FLAGS', 0, '#', 'ISHTML', 0, '@', 'value'), false, false); // Put questiontext in question object. $text = $this->getpath($questiondata, array('#', 'BODY', 0, '#', 'TEXT', 0, '#'), '', true, get_string('importnotext', 'qformat_blackboard_six')); $questiontext = $this->cleaned_text_field($text); $question->questiontext = $questiontext['text']; $question->questiontextformat = $questiontext['format']; // Needed because add_blank_combined_feedback uses it. if (isset($questiontext['itemid'])) { $question->questiontextitemid = $questiontext['itemid']; } // Put name in question object. We must ensure it is not empty and it is less than 250 chars. $id = $this->getpath($questiondata, array('@', 'id'), '', true); $question->name = $this->create_default_question_name($question->questiontext, get_string('defaultname', 'qformat_blackboard_six' , $id)); $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_HTML; $question->generalfeedbackfiles = array(); // TODO : read the mark from the POOL TITLE QUESTIONLIST section. $question->defaultmark = 1; return $question; } /** * Add a category question entry based on the pool file title * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_category($xml, &$questions) { $title = $this->getpath($xml, array('POOL', '#', 'TITLE', 0, '@', 'value'), '', true); $dummyquestion = new stdClass(); $dummyquestion->qtype = 'category'; $dummyquestion->category = $this->cleaninput($this->clean_question_name($title)); $questions[] = $dummyquestion; } /** * Process Essay Questions * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_essay($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false)) { $essayquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false); } else { return; } foreach ($essayquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'essay'; $question->answer = ''; $answer = $this->getpath($thisquestion, array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true); $question->graderinfo = $this->cleaned_text_field($answer); $question->responsetemplate = $this->text_field(''); $question->feedback = ''; $question->responseformat = 'editor'; $question->responserequired = 1; $question->responsefieldlines = 15; $question->attachments = 0; $question->attachmentsrequired = 0; $question->fraction = 0; $questions[] = $question; } } /** * Process True / False Questions * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_tf($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false)) { $tfquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false); } else { return; } foreach ($tfquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'truefalse'; $question->single = 1; // Only one answer is allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false); $correctanswer = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'), '', true); // First choice is true, second is false. $id = $this->getpath($choices[0], array('@', 'id'), '', true); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); if (strcmp($id, $correctanswer) == 0) { // True is correct. $question->answer = 1; $question->feedbacktrue = $this->cleaned_text_field($correctfeedback); $question->feedbackfalse = $this->cleaned_text_field($incorrectfeedback); } else { // False is correct. $question->answer = 0; $question->feedbacktrue = $this->cleaned_text_field($incorrectfeedback); $question->feedbackfalse = $this->cleaned_text_field($correctfeedback); } $question->correctanswer = $question->answer; $questions[] = $question; } } /** * Process Multiple Choice Questions with single answer * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_mc($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false)) { $mcquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false); } else { return; } foreach ($mcquestions as $thisquestion) { $question = $this->process_common($thisquestion); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->cleaned_text_field($correctfeedback); $question->partiallycorrectfeedback = $this->text_field(''); $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback); $question->qtype = 'multichoice'; $question->single = 1; // Only one answer is allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); $correctanswerid = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'), '', true); foreach ($choices as $choice) { $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true); // Put this choice in the question object. $question->answer[] = $this->cleaned_text_field($choicetext); $choiceid = $this->getpath($choice, array('@', 'id'), '', true); // If choice is the right answer, give 100% mark, otherwise give 0%. if (strcmp ($choiceid, $correctanswerid) == 0) { $question->fraction[] = 1; } else { $question->fraction[] = 0; } // There is never feedback specific to each choice. $question->feedback[] = $this->text_field(''); } $questions[] = $question; } } /** * Process Multiple Choice Questions With Multiple Answers * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_ma($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false)) { $maquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false); } else { return; } foreach ($maquestions as $thisquestion) { $question = $this->process_common($thisquestion); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->cleaned_text_field($correctfeedback); // As there is no partially correct feedback we use incorrect one. $question->partiallycorrectfeedback = $this->cleaned_text_field($incorrectfeedback); $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback); $question->qtype = 'multichoice'; $question->defaultmark = 1; $question->single = 0; // More than one answers allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); $correctanswerids = array(); foreach ($this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false) as $correctanswer) { if ($correctanswer) { $correctanswerids[] = $this->getpath($correctanswer, array('@', 'answer_id'), '', true); } } $fraction = 1 / count($correctanswerids); foreach ($choices as $choice) { $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true); // Put this choice in the question object. $question->answer[] = $this->cleaned_text_field($choicetext); $choiceid = $this->getpath($choice, array('@', 'id'), '', true); $iscorrect = in_array($choiceid, $correctanswerids); if ($iscorrect) { $question->fraction[] = $fraction; } else { $question->fraction[] = 0; } // There is never feedback specific to each choice. $question->feedback[] = $this->text_field(''); } $questions[] = $question; } } /** * Process Fill in the Blank Questions * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_fib($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false)) { $fibquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false); } else { return; } foreach ($fibquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'shortanswer'; $question->usecase = 0; // Ignore case. $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); foreach ($answers as $answer) { $question->answer[] = $this->getpath($answer, array('#', 'TEXT', 0, '#'), '', true); $question->fraction[] = 1; $question->feedback[] = $this->cleaned_text_field($correctfeedback); } $question->answer[] = '*'; $question->fraction[] = 0; $question->feedback[] = $this->cleaned_text_field($incorrectfeedback); $questions[] = $question; } } /** * Process Matching Questions * @param array $xml the xml tree * @param array $questions the questions already parsed */ public function process_matching($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false)) { $matchquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false); } else { return; } // Blackboard questions can't be imported in core Moodle without a loss in data, // as core match question don't allow HTML in subanswers. The contributed ddmatch // question type support HTML in subanswers. // The ddmatch question type is not part of core, so we need to check if it is defined. $ddmatchisinstalled = question_bank::is_qtype_installed('ddmatch'); foreach ($matchquestions as $thisquestion) { $question = $this->process_common($thisquestion); if ($ddmatchisinstalled) { $question->qtype = 'ddmatch'; } else { $question->qtype = 'match'; } $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->cleaned_text_field($correctfeedback); // As there is no partially correct feedback we use incorrect one. $question->partiallycorrectfeedback = $this->cleaned_text_field($incorrectfeedback); $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback); $choices = $this->getpath($thisquestion, array('#', 'CHOICE'), false, false); // Blackboard "choices" are Moodle subanswers. $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); // Blackboard "answers" are Moodle subquestions. $correctanswers = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false); // Mapping between choices and answers. $mappings = array(); foreach ($correctanswers as $correctanswer) { if ($correctanswer) { $correctchoiceid = $this->getpath($correctanswer, array('@', 'choice_id'), '', true); $correctanswerid = $this->getpath($correctanswer, array('@', 'answer_id'), '', true); $mappings[$correctanswerid] = $correctchoiceid; } } foreach ($choices as $choice) { if ($ddmatchisinstalled) { $choicetext = $this->cleaned_text_field($this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true)); } else { $choicetext = trim(strip_tags($this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true))); } if ($choicetext != '') { // Only import non empty subanswers. $subquestion = ''; $choiceid = $this->getpath($choice, array('@', 'id'), '', true); $fiber = array_search($choiceid, $mappings); $fiber = moodle_array_keys_filter($mappings, $choiceid); foreach ($fiber as $correctanswerid) { // We have found a correspondance for this choice so we need to take the associated answer. foreach ($answers as $answer) { $currentanswerid = $this->getpath($answer, array('@', 'id'), '', true); if (strcmp ($currentanswerid, $correctanswerid) == 0) { $subquestion = $this->getpath($answer, array('#', 'TEXT', 0, '#'), '', true); break; } } $question->subquestions[] = $this->cleaned_text_field($subquestion); $question->subanswers[] = $choicetext; } if ($subquestion == '') { // Then in this case, $choice is a distractor. $question->subquestions[] = $this->text_field(''); $question->subanswers[] = $choicetext; } } } // Verify that this matching question has enough subquestions and subanswers. $subquestioncount = 0; $subanswercount = 0; $subanswers = $question->subanswers; foreach ($question->subquestions as $key => $subquestion) { $subquestion = $subquestion['text']; $subanswer = $subanswers[$key]; if ($subquestion != '') { $subquestioncount++; } $subanswercount++; } if ($subquestioncount < 2 || $subanswercount < 3) { $this->error(get_string('notenoughtsubans', 'qformat_blackboard_six', $question->questiontext)); } else { $questions[] = $question; } } } } PK\jmm$format/blackboard_six/formatbase.phpnu[. /** * Blackboard V5 and V6 question importer. * * @package qformat_blackboard_six * @copyright 2012 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Base class question import format for zip files with images * * @package qformat_blackboard_six * @copyright 2012 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard_six_base extends qformat_based_on_xml { /** @var string path to path to root of image tree in unzipped archive. */ public $filebase = ''; /** @var string path to the temporary directory. */ public $tempdir = ''; /** * This plugin provide import * @return bool true */ public function provide_import() { return true; } /** * Check if the given file is capable of being imported by this plugin. * As {@link file_storage::mimetype()} may use finfo PHP extension if available, * the value returned by $file->get_mimetype for a .dat file is not the same on all servers. * So we must made 2 checks to verify if the plugin can import the file. * @param stored_file $file the file to check * @return bool whether this plugin can import the file */ public function can_import_file($file) { $mimetypes = array( mimeinfo('type', '.dat'), mimeinfo('type', '.zip') ); return in_array($file->get_mimetype(), $mimetypes) || in_array(mimeinfo('type', $file->get_filename()), $mimetypes); } public function mime_type() { return mimeinfo('type', '.zip'); } /** * Does any post-processing that may be desired * Clean the temporary directory if a zip file was imported * @return bool success */ public function importpostprocess() { if ($this->tempdir != '') { fulldelete($this->tempdir); } return true; } /** * Set the path to the root of images tree * @param string $path path to images root */ public function set_filebase($path) { $this->filebase = $path; } /** * Store an image file in a draft filearea * @param array $text, if itemid element don't exists it will be created * @param string $tempdir path to root of image tree * @param string $filepathinsidetempdir path to image in the tree * @param string $filename image's name * @return string new name of the image as it was stored */ protected function store_file_for_text_field(&$text, $tempdir, $filepathinsidetempdir, $filename) { global $USER; $fs = get_file_storage(); if (empty($text['itemid'])) { $text['itemid'] = file_get_unused_draft_itemid(); } // As question file areas don't support subdirs, // convert path to filename. // So that images with same name can be imported. $newfilename = clean_param(str_replace('/', '__', $filepathinsidetempdir . '__' . $filename), PARAM_FILE); $filerecord = array( 'contextid' => context_user::instance($USER->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $text['itemid'], 'filepath' => '/', 'filename' => $newfilename, ); $fs->create_file_from_pathname($filerecord, $tempdir . '/' . $filepathinsidetempdir . '/' . $filename); return $newfilename; } /** * Given an HTML text with references to images files, * store all images in a draft filearea, * and return an array with all urls in text recoded, * format set to FORMAT_HTML, and itemid set to filearea itemid * @param string $text text to parse and recode * @return array with keys text, format, itemid. */ public function text_field($text) { $data = array(); // Step one, find all file refs then add to array. preg_match_all('|]+src="([^"]*)"|i', $text, $out); // Find all src refs. $filepaths = array(); foreach ($out[1] as $path) { $fullpath = $this->filebase . '/' . $path; if (is_readable($fullpath) && !in_array($path, $filepaths)) { $dirpath = dirname($path); $filename = basename($path); $newfilename = $this->store_file_for_text_field($data, $this->filebase, $dirpath, $filename); $text = preg_replace("|{$path}|", "@@PLUGINFILE@@/" . $newfilename, $text); $filepaths[] = $path; } } $data['text'] = $text; $data['format'] = FORMAT_HTML; return $data; } /** * Same as text_field but text is cleaned. * @param string $text text to parse and recode * @return array with keys text, format, itemid. */ public function cleaned_text_field($text) { return $this->text_field($this->cleaninput($text)); } } PK\[9KK>format/blackboard_six/tests/fixtures/sample_blackboard_qti.datnu[
    E03EE0034702442C9306629CBF618049 Item Pool Subsection True/False false 0.0,1.0 0.0 1.0 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete <span style="font-size:12pt">42 is the Absolute Answer to everything.</span> true false false SCORE.max 0.0 You gave the right answer. 42 is the <b>Ultimate</b> Answer. C74698725FFD4F85A692662108608D53 Item Pool Subsection Multiple Choice false 0.0,1.0 0.0 1.0 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete <span style="font-size:12pt">What's between orange and green in the spectrum?</span> <span style="font-size:12pt">red</span> <span style="font-size:12pt">yellow</span> <span style="font-size:12pt">blue</span> 2CBE1E044DE54F8395BDE7877A57837A SCORE.max 0.0 Red is not between orange and green in the spectrum but yellow is. You gave the right answer. Blue is not between orange and green in the spectrum but yellow is. C18C56154AA04D56A4AE2FE430F4F49D Item Pool Subsection Multiple Answer false 0.0,1.0 0.0 1.0 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete <i>What's between orange and green in the spectrum?</i> <span style="font-size:12pt">yellow</span> <span style="font-size:12pt">red</span> <span style="font-size:12pt">off-beige</span> <span style="font-size:12pt">blue</span> 76CA08C366984445AC94B0244D1DBF4A FEC2A9886C8B498787A573C9181C9698 7F66D24D2CAA472EA728773D46706DF3 547B16C1D788446396618EDD0A41D623 SCORE.max 0.0 9C5DBA6A142A4B5887C61D333CFFEDA9 Item Pool Subsection Matching false 0.0,3 0.0 3 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete Classify the animals. cat frog newt insect mammal amphibian D75FEB705DCE41D59106659A2F94D819 8A9B69A93B0943AFAB890702199AB290 4D1F5B3DB0EB4C41A0012625750DF86C 0.0 DD76E663D4244C598FC91CFC433F6D5B Item Pool Subsection Fill in the Blank false 0.0,1.0 0.0 1.0 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete <span style="font-size:12pt">Name an amphibian&#58; __________.</span> frog 0.0 A frog is an amphibian. A frog is an amphibian. A frog is an amphibian. 39970AD8D5AE425A82338E17D42B7845 Item Pool Subsection Essay false 0.0,1.0 0.0 1.0 Proprietary Logical Identifier School 0.0 0 Proprietary Single Absolute Normal 0 0.0 Complete How are you? SCORE.max 0.0 Blackboard answer for essay questions will be imported as informations for graders.
    PK\6rr?format/blackboard_six/tests/fixtures/sample_blackboard_pool.datnu[ <QUESTIONLIST> <QUESTION id='q1' class='QUESTION_TRUEFALSE' points='1'/> <QUESTION id='q7' class='QUESTION_MULTIPLECHOICE' points='1'/> <QUESTION id='q8' class='QUESTION_MULTIPLEANSWER' points='1'/> <QUESTION id='q39-44' class='QUESTION_MATCH' points='1'/> <QUESTION id='q9' class='QUESTION_ESSAY' points='1'/> <QUESTION id='q27' class='QUESTION_FILLINBLANK' points='1'/> </QUESTIONLIST> <QUESTION_TRUEFALSE id='q1'> <BODY> <TEXT><![CDATA[<span style="font-size:12pt">42 is the Absolute Answer to everything.</span>]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q1_a1'> <TEXT>False</TEXT> </ANSWER> <ANSWER id='q1_a2'> <TEXT>True</TEXT> </ANSWER> <GRADABLE> <CORRECTANSWER answer_id='q1_a2'/> <FEEDBACK_WHEN_CORRECT><![CDATA[You gave the right answer.]]></FEEDBACK_WHEN_CORRECT> <FEEDBACK_WHEN_INCORRECT><![CDATA[42 is the Ultimate Answer.]]></FEEDBACK_WHEN_INCORRECT> </GRADABLE> </QUESTION_TRUEFALSE> <QUESTION_MULTIPLECHOICE id='q7'> <BODY> <TEXT><![CDATA[<span style="font-size:12pt">What's between orange and green in the spectrum?</span>]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q7_a1' position='1'> <TEXT><![CDATA[<span style="font-size:12pt">red</span>]]></TEXT> </ANSWER> <ANSWER id='q7_a2' position='2'> <TEXT><![CDATA[<span style="font-size:12pt">yellow</span>]]></TEXT> </ANSWER> <ANSWER id='q7_a3' position='3'> <TEXT><![CDATA[<span style="font-size:12pt">blue</span>]]></TEXT> </ANSWER> <GRADABLE> <CORRECTANSWER answer_id='q7_a2'/> <FEEDBACK_WHEN_CORRECT><![CDATA[You gave the right answer.]]></FEEDBACK_WHEN_CORRECT> <FEEDBACK_WHEN_INCORRECT><![CDATA[Only yellow is between orange and green in the spectrum.]]></FEEDBACK_WHEN_INCORRECT> </GRADABLE> </QUESTION_MULTIPLECHOICE> <QUESTION_MULTIPLEANSWER id='q8'> <BODY> <TEXT><![CDATA[<span style="font-size:12pt">What's between orange and green in the spectrum?</span>]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q8_a1' position='1'> <TEXT><![CDATA[<span style="font-size:12pt">yellow</span>]]></TEXT> </ANSWER> <ANSWER id='q8_a2' position='2'> <TEXT><![CDATA[<span style="font-size:12pt">red</span>]]></TEXT> </ANSWER> <ANSWER id='q8_a3' position='3'> <TEXT><![CDATA[<span style="font-size:12pt">off-beige</span>]]></TEXT> </ANSWER> <ANSWER id='q8_a4' position='4'> <TEXT><![CDATA[<span style="font-size:12pt">blue</span>]]></TEXT> </ANSWER> <GRADABLE> <CORRECTANSWER answer_id='q8_a1'/> <CORRECTANSWER answer_id='q8_a3'/> <FEEDBACK_WHEN_CORRECT><![CDATA[You gave the right answer.]]></FEEDBACK_WHEN_CORRECT> <FEEDBACK_WHEN_INCORRECT><![CDATA[Only yellow and off-beige are between orange and green in the spectrum.]]></FEEDBACK_WHEN_INCORRECT> </GRADABLE> </QUESTION_MULTIPLEANSWER> <QUESTION_MATCH id='q39-44'> <BODY> <TEXT><![CDATA[<i>Classify the animals.</i>]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q39-44_a1' position='1'> <TEXT><![CDATA[frog]]></TEXT> </ANSWER> <ANSWER id='q39-44_a2' position='2'> <TEXT><![CDATA[cat]]></TEXT> </ANSWER> <ANSWER id='q39-44_a3' position='3'> <TEXT><![CDATA[newt]]></TEXT> </ANSWER> <CHOICE id='q39-44_c1' position='1'> <TEXT><![CDATA[mammal]]></TEXT> </CHOICE> <CHOICE id='q39-44_c2' position='2'> <TEXT><![CDATA[insect]]></TEXT> </CHOICE> <CHOICE id='q39-44_c3' position='3'> <TEXT><![CDATA[amphibian]]></TEXT> </CHOICE> <GRADABLE> <CORRECTANSWER answer_id='q39-44_a1' choice_id='q39-44_c3'/> <CORRECTANSWER answer_id='q39-44_a2' choice_id='q39-44_c1'/> <CORRECTANSWER answer_id='q39-44_a3' choice_id='q39-44_c3'/> </GRADABLE> </QUESTION_MATCH> <QUESTION_ESSAY id='q9'> <BODY> <TEXT><![CDATA[How are you?]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q9_a1'> <TEXT><![CDATA[Blackboard answer for essay questions will be imported as informations for graders.]]></TEXT> </ANSWER> <GRADABLE> </GRADABLE> </QUESTION_ESSAY> <QUESTION_FILLINBLANK id='q27'> <BODY> <TEXT><![CDATA[<span style="font-size:12pt">Name an amphibian: __________.</span>]]></TEXT> <FLAGS> <ISHTML value='true'/> <ISNEWLINELITERAL value='false'/> </FLAGS> </BODY> <ANSWER id='q27_a1' position='1'> <TEXT>frog</TEXT> </ANSWER> <GRADABLE> </GRADABLE> </QUESTION_FILLINBLANK> </POOL> PK�����\sv8T3��3��;��format/blackboard_six/tests/blackboardsixformatqti_test.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. namespace qformat_blackboard_six; use qformat_blackboard_six; use qformat_blackboard_six_file; use question_bank; use question_check_specified_fields_expectation; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/blackboard_six/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the blackboard v6+ question import format. * * @package qformat_blackboard_six * @copyright 2012 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class blackboardsixformatqti_test extends \question_testcase { public function make_test_xml() { $xmlfile = new qformat_blackboard_six_file(); $xmlfile->filetype = 1; $xmlfile->text = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'sample_blackboard_qti.dat')); return array(0 => $xmlfile); } public function test_import_match(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[4]; // If qtype_ddmatch is installed, the formatter produces ddmatch // qtypes, not match ones. $ddmatchisinstalled = question_bank::is_qtype_installed('ddmatch'); $expectedq = new \stdClass(); $expectedq->qtype = $ddmatchisinstalled ? 'ddmatch' : 'match'; $expectedq->name = 'Classify the animals.'; $expectedq->questiontext = 'Classify the animals.'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->partiallycorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->incorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->subquestions = array( array('text' => '', 'format' => FORMAT_HTML), array('text' => 'cat', 'format' => FORMAT_HTML), array('text' => 'frog', 'format' => FORMAT_HTML), array('text' => 'newt', 'format' => FORMAT_HTML)); if ($ddmatchisinstalled) { $expectedq->subanswers = array( array('text' => 'insect', 'format' => FORMAT_HTML), array('text' => 'mammal', 'format' => FORMAT_HTML), array('text' => 'amphibian', 'format' => FORMAT_HTML), array('text' => 'amphibian', 'format' => FORMAT_HTML), ); } else { $expectedq->subanswers = array('insect', 'mammal', 'amphibian', 'amphibian'); } $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_multichoice_single(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[2]; $expectedq = new \stdClass(); $expectedq->qtype = 'multichoice'; $expectedq->single = 1; $expectedq->name = 'What\'s between orange and green in the spectrum?'; $expectedq->questiontext = '<span style="font-size:12pt">What\'s between orange and green in the spectrum?</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->partiallycorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->incorrectfeedback = array('text' => '', 'format' => FORMAT_HTML, 'files' => array()); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->answer = array( 0 => array( 'text' => '<span style="font-size:12pt">red</span>', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '<span style="font-size:12pt">yellow</span>', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '<span style="font-size:12pt">blue</span>', 'format' => FORMAT_HTML, ) ); $expectedq->fraction = array(0, 1, 0); $expectedq->feedback = array( 0 => array( 'text' => 'Red is not between orange and green in the spectrum but yellow is.', 'format' => FORMAT_HTML, ), 1 => array( 'text' => 'You gave the right answer.', 'format' => FORMAT_HTML, ), 2 => array( 'text' => 'Blue is not between orange and green in the spectrum but yellow is.', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_multichoice_multi(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[3]; $expectedq = new \stdClass(); $expectedq->qtype = 'multichoice'; $expectedq->single = 0; $expectedq->name = 'What\'s between orange and green in the spectrum?'; $expectedq->questiontext = '<i>What\'s between orange and green in the spectrum?</i>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ); $expectedq->partiallycorrectfeedback = array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ); $expectedq->incorrectfeedback = array( 'text' => '', 'format' => FORMAT_HTML, 'files' => array(), ); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->answer = array( 0 => array( 'text' => '<span style="font-size:12pt">yellow</span>', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '<span style="font-size:12pt">red</span>', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '<span style="font-size:12pt">off-beige</span>', 'format' => FORMAT_HTML, ), 3 => array( 'text' => '<span style="font-size:12pt">blue</span>', 'format' => FORMAT_HTML, ) ); $expectedq->fraction = array(0.5, 0, 0.5, 0); $expectedq->feedback = array( 0 => array( 'text' => '', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '', 'format' => FORMAT_HTML, ), 3 => array( 'text' => '', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_truefalse(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[1]; $expectedq = new \stdClass(); $expectedq->qtype = 'truefalse'; $expectedq->name = '42 is the Absolute Answer to everything.'; $expectedq->questiontext = '<span style="font-size:12pt">42 is the Absolute Answer to everything.</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->correctanswer = 0; $expectedq->feedbacktrue = array( 'text' => '42 is the <b>Ultimate</b> Answer.', 'format' => FORMAT_HTML, ); $expectedq->feedbackfalse = array( 'text' => 'You gave the right answer.', 'format' => FORMAT_HTML, ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_fill_in_the_blank(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[5]; $expectedq = new \stdClass(); $expectedq->qtype = 'shortanswer'; $expectedq->name = 'Name an amphibian: __________.'; $expectedq->questiontext = '<span style="font-size:12pt">Name an amphibian: __________.</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->usecase = 0; $expectedq->answer = array('frog', '*'); $expectedq->fraction = array(1, 0); $expectedq->feedback = array( 0 => array( 'text' => 'A frog is an amphibian.', 'format' => FORMAT_HTML, ), 1 => array( 'text' => 'A frog is an amphibian.', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_essay(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[6]; $expectedq = new \stdClass(); $expectedq->qtype = 'essay'; $expectedq->name = 'How are you?'; $expectedq->questiontext = 'How are you?'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->responseformat = 'editor'; $expectedq->responsefieldlines = 15; $expectedq->attachments = 0; $expectedq->graderinfo = array( 'text' => 'Blackboard answer for essay questions will be imported as informations for graders.', 'format' => FORMAT_HTML, ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_category(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[0]; $expectedq = new \stdClass(); $expectedq->qtype = 'category'; $expectedq->category = 'sample_blackboard_six'; $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } } PK�����\ 2��2��9��format/blackboard_six/tests/blackboardformatpool_test.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. namespace qformat_blackboard_six; use qformat_blackboard_six; use qformat_blackboard_six_file; use question_bank; use question_check_specified_fields_expectation; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/blackboard_six/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the blackboard v6+ question import format. * * @package qformat_blackboard_six * @copyright 2012 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class blackboardformatpool_test extends \question_testcase { public function make_test_xml() { $xmlfile = new qformat_blackboard_six_file(); $xmlfile->filetype = 2; $xmlfile->text = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'sample_blackboard_pool.dat')); return array(0 => $xmlfile); } public function test_import_match(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[5]; // If qtype_ddmatch is installed, the formatter produces ddmatch // qtypes, not match ones. $ddmatchisinstalled = question_bank::is_qtype_installed('ddmatch'); $expectedq = new \stdClass(); $expectedq->qtype = $ddmatchisinstalled ? 'ddmatch' : 'match'; $expectedq->name = 'Classify the animals.'; $expectedq->questiontext = '<i>Classify the animals.</i>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array('text' => '', 'format' => FORMAT_HTML); $expectedq->partiallycorrectfeedback = array('text' => '', 'format' => FORMAT_HTML); $expectedq->incorrectfeedback = array('text' => '', 'format' => FORMAT_HTML); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->subquestions = array( array('text' => 'cat', 'format' => FORMAT_HTML), array('text' => '', 'format' => FORMAT_HTML), array('text' => 'frog', 'format' => FORMAT_HTML), array('text' => 'newt', 'format' => FORMAT_HTML)); if ($ddmatchisinstalled) { $expectedq->subanswers = array( array('text' => 'mammal', 'format' => FORMAT_HTML), array('text' => 'insect', 'format' => FORMAT_HTML), array('text' => 'amphibian', 'format' => FORMAT_HTML), array('text' => 'amphibian', 'format' => FORMAT_HTML), ); } else { $expectedq->subanswers = array('mammal', 'insect', 'amphibian', 'amphibian'); } $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_multichoice_single(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[2]; $expectedq = new \stdClass(); $expectedq->qtype = 'multichoice'; $expectedq->single = 1; $expectedq->name = 'What\'s between orange and green in the spectrum?'; $expectedq->questiontext = '<span style="font-size:12pt">What\'s between orange and green in the spectrum?</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array('text' => 'You gave the right answer.', 'format' => FORMAT_HTML); $expectedq->partiallycorrectfeedback = array('text' => '', 'format' => FORMAT_HTML); $expectedq->incorrectfeedback = array('text' => 'Only yellow is between orange and green in the spectrum.', 'format' => FORMAT_HTML); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->answer = array( 0 => array( 'text' => '<span style="font-size:12pt">red</span>', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '<span style="font-size:12pt">yellow</span>', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '<span style="font-size:12pt">blue</span>', 'format' => FORMAT_HTML, ) ); $expectedq->fraction = array(0, 1, 0); $expectedq->feedback = array( 0 => array( 'text' => '', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_multichoice_multi(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[3]; $expectedq = new \stdClass(); $expectedq->qtype = 'multichoice'; $expectedq->single = 0; $expectedq->name = 'What\'s between orange and green in the spectrum?'; $expectedq->questiontext = '<span style="font-size:12pt">What\'s between orange and green in the spectrum?</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->correctfeedback = array( 'text' => 'You gave the right answer.', 'format' => FORMAT_HTML, ); $expectedq->partiallycorrectfeedback = array( 'text' => 'Only yellow and off-beige are between orange and green in the spectrum.', 'format' => FORMAT_HTML, ); $expectedq->incorrectfeedback = array( 'text' => 'Only yellow and off-beige are between orange and green in the spectrum.', 'format' => FORMAT_HTML, ); $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = get_config('quiz', 'shuffleanswers'); $expectedq->answer = array( 0 => array( 'text' => '<span style="font-size:12pt">yellow</span>', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '<span style="font-size:12pt">red</span>', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '<span style="font-size:12pt">off-beige</span>', 'format' => FORMAT_HTML, ), 3 => array( 'text' => '<span style="font-size:12pt">blue</span>', 'format' => FORMAT_HTML, ) ); $expectedq->fraction = array(0.5, 0, 0.5, 0); $expectedq->feedback = array( 0 => array( 'text' => '', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '', 'format' => FORMAT_HTML, ), 2 => array( 'text' => '', 'format' => FORMAT_HTML, ), 3 => array( 'text' => '', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_truefalse(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[1]; $expectedq = new \stdClass(); $expectedq->qtype = 'truefalse'; $expectedq->name = '42 is the Absolute Answer to everything.'; $expectedq->questiontext = '<span style="font-size:12pt">42 is the Absolute Answer to everything.</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->correctanswer = 0; $expectedq->feedbacktrue = array( 'text' => '42 is the Ultimate Answer.', 'format' => FORMAT_HTML, ); $expectedq->feedbackfalse = array( 'text' => 'You gave the right answer.', 'format' => FORMAT_HTML, ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_fill_in_the_blank(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[4]; $expectedq = new \stdClass(); $expectedq->qtype = 'shortanswer'; $expectedq->name = 'Name an amphibian: __________.'; $expectedq->questiontext = '<span style="font-size:12pt">Name an amphibian: __________.</span>'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->usecase = 0; $expectedq->answer = array('frog', '*'); $expectedq->fraction = array(1, 0); $expectedq->feedback = array( 0 => array( 'text' => '', 'format' => FORMAT_HTML, ), 1 => array( 'text' => '', 'format' => FORMAT_HTML, ) ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_essay(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[6]; $expectedq = new \stdClass(); $expectedq->qtype = 'essay'; $expectedq->name = 'How are you?'; $expectedq->questiontext = 'How are you?'; $expectedq->questiontextformat = FORMAT_HTML; $expectedq->generalfeedback = ''; $expectedq->generalfeedbackformat = FORMAT_HTML; $expectedq->defaultmark = 1; $expectedq->length = 1; $expectedq->responseformat = 'editor'; $expectedq->responsefieldlines = 15; $expectedq->attachments = 0; $expectedq->graderinfo = array( 'text' => 'Blackboard answer for essay questions will be imported as informations for graders.', 'format' => FORMAT_HTML, ); $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } public function test_import_category(): void { $xml = $this->make_test_xml(); $importer = new qformat_blackboard_six(); $questions = $importer->readquestions($xml); $q = $questions[0]; $expectedq = new \stdClass(); $expectedq->qtype = 'category'; $expectedq->category = 'exam 3 2008-9'; $this->assert(new question_check_specified_fields_expectation($expectedq), $q); } } PK�����\#��#�� ��format/blackboard_six/format.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Blackboard V5 and V6 question importer. * * @package qformat_blackboard_six * @copyright 2005 Michael Penney * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/xmlize.php'); require_once($CFG->dirroot . '/question/format/blackboard_six/formatbase.php'); require_once($CFG->dirroot . '/question/format/blackboard_six/formatqti.php'); require_once($CFG->dirroot . '/question/format/blackboard_six/formatpool.php'); /** * Class to represent a Blackboard file. * * @package qformat_blackboard_six * @copyright 2005 Michael Penney * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard_six_file { /** @var int type of file being imported, one of the constants FILETYPE_QTI or FILETYPE_POOL. */ public $filetype; /** @var string the xml text */ public $text; /** @var string path to path to root of image tree in unzipped archive. */ public $filebase = ''; } /** * Blackboard Six QTI file importer class. * * @package qformat_blackboard_six * @copyright 2005 Michael Penney * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard_six extends qformat_blackboard_six_base { /** @var int Blackboard assessment qti files were always imported by the blackboard_six plugin. */ const FILETYPE_QTI = 1; /** @var int Blackboard question pool files were previously handled by the blackboard plugin. */ const FILETYPE_POOL = 2; /** @var string temporary directory/folder. */ public $temp_dir; /** * Return the content of a file given by its path in the tempdir directory. * * @param string $path path to the file inside tempdir * @return mixed contents array or false on failure */ public function get_filecontent($path) { $fullpath = $this->tempdir . '/' . clean_param($path, PARAM_PATH); if (is_file($fullpath) && is_readable($fullpath)) { return file_get_contents($fullpath); } return false; } /** * Return content of all files containing questions, * as an array one element for each file found, * For each file, the corresponding element is an array of lines. * * @param string $filename name of file * @return mixed contents array or false on failure */ public function readdata($filename) { global $CFG; // Find if we are importing a .dat file. if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) == 'dat') { if (!is_readable($filename)) { $this->error(get_string('filenotreadable', 'error')); return false; } $fileobj = new qformat_blackboard_six_file(); // As we are not importing a .zip file, // there is no imsmanifest, and it is not possible // to parse it to find the file type. // So we need to guess the file type by looking at the content. // For now we will do that searching for a required tag. // This is certainly not bullet-proof but works for all usual files. $fileobj->text = file_get_contents($filename); if (strpos($fileobj->text, '<questestinterop>')) { $fileobj->filetype = self::FILETYPE_QTI; } if (strpos($fileobj->text, '<POOL>')) { $fileobj->filetype = self::FILETYPE_POOL; } // In all other cases we are not able to handle this question file. // Readquestions is now expecting an array of strings. return array($fileobj); } // We are importing a zip file. // Create name for temporary directory. $uniquecode = time(); $this->tempdir = make_temp_directory('bbquiz_import/' . $uniquecode); if (is_readable($filename)) { if (!copy($filename, $this->tempdir . '/bboard.zip')) { $this->error(get_string('cannotcopybackup', 'question')); fulldelete($this->tempdir); return false; } $packer = get_file_packer('application/zip'); if ($packer->extract_to_pathname($this->tempdir . '/bboard.zip', $this->tempdir)) { $dom = new DomDocument(); if (!$dom->load($this->tempdir . '/imsmanifest.xml')) { $this->error(get_string('errormanifest', 'qformat_blackboard_six')); fulldelete($this->tempdir); return false; } $xpath = new DOMXPath($dom); // We starts from the root element. $query = '//resources/resource'; $qfile = array(); $examfiles = $xpath->query($query); foreach ($examfiles as $examfile) { $fileobj = new qformat_blackboard_six_file(); if ($examfile->getAttribute('type') == 'assessment/x-bb-qti-test' || $examfile->getAttribute('type') == 'assessment/x-bb-qti-pool') { if ($content = $this->get_filecontent($examfile->getAttribute('bb:file'))) { $fileobj->filetype = self::FILETYPE_QTI; $fileobj->filebase = $this->tempdir; $fileobj->text = $content; $qfile[] = $fileobj; } } if ($examfile->getAttribute('type') == 'assessment/x-bb-pool') { if ($examfile->getAttribute('baseurl')) { $fileobj->filebase = $this->tempdir. '/' . clean_param($examfile->getAttribute('baseurl'), PARAM_PATH); } if ($content = $this->get_filecontent($examfile->getAttribute('file'))) { $fileobj->filetype = self::FILETYPE_POOL; $fileobj->text = $content; $qfile[] = $fileobj; } } } if ($qfile) { return $qfile; } else { $this->error(get_string('cannotfindquestionfile', 'question')); fulldelete($this->tempdir); } } else { $this->error(get_string('cannotunzip', 'question')); fulldelete($this->temp_dir); } } else { $this->error(get_string('cannotreaduploadfile', 'error')); fulldelete($this->tempdir); } return false; } /** * Parse the array of objects into an array of questions. * Each object is the content of a .dat questions file. * This *could* burn memory - but it won't happen that much * so fingers crossed! * * @param array $lines array of qformat_blackboard_six_file objects for each input file. * @return array (of objects) question objects. */ public function readquestions($lines) { // Set up array to hold all our questions. $questions = array(); // Each element of $lines is a qformat_blackboard_six_file object. foreach ($lines as $fileobj) { if ($fileobj->filetype == self::FILETYPE_QTI) { $importer = new qformat_blackboard_six_qti(); } else if ($fileobj->filetype == self::FILETYPE_POOL) { $importer = new qformat_blackboard_six_pool(); } else { // In all other cases we are not able to import the file. debugging('fileobj type not recognised', DEBUG_DEVELOPER); continue; } $importer->set_filebase($fileobj->filebase); $questions = array_merge($questions, $importer->readquestions($fileobj->text)); } // Give any unnamed categories generated names. $unnamedcount = 0; foreach ($questions as $question) { if ($question->qtype == 'category' && $question->category == '') { $question->category = get_string('importedcategory', 'qformat_blackboard_six', ++$unnamedcount); } } return $questions; } } PK�����\z;b ��b ����format/upgrade.txtnu�[��������=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes for question import/export format plugins. === 4.0 === 1) The major question bank changes should not affect import/export plugins. The navigation changes may affect Behat tests. If you encounter this, the best way to fix it is to use the new navigation steps in MDL-74130. 2) The new validate_file() method in question/format.php can be overwritten to implement more expensive or detailed file integrity checks for question imports. There is a simple way to do this if you just want to check that the file is valid UTF-8, which you can see an example of in format_gift. 3) The ExamView question format has been completely removed. (The last posts in http://forum.examview.com/index.php? are from over 10 years ago and there are no moodle.org discussions about Examview in the last 10 years.) 4) The WebCT question format has been completely removed (WebCT was acquired by Blackboard in 2006). === 3.6 === * Saving question category descriptions (info) is now supported in Moodle XML import/export format. New xml-structure snippet for a question category: <question type="category"> <category> <text>${$contexttypename}$/{$category_path}</text> </category> <info format="{$format}"> <text>{$info_categorydescription}</text> </info> </question> * The method importprocess() in question/format.php no longer accepts $category as a parameter. If required in a plugin then please override this method. === 2.3 === * This plugin type now supports cron in the standard way. If required, Create a lib.php file containing function qformat_mypluginname_cron() {}; === 2.1.5 / 2.2.3 / 2.3 === * The readquestions method used to take a second argument $context. However, at the point where this method was called, it was impossible to know what context the quetsions were going to be saved into, so the value could be wrong. Also, none of the standard question formats were using this argument, so it was removed. See MDL-32220. === 2.2 === * The plugin name used to be defined in a string called the same thing as the format, with assoicated help strings, for example: $string['aiken'] = 'Aiken format'; $string['aiken_help'] = 'This is a simple format ...'; $string['aiken_link'] = 'qformat/aiken'; This needs to be changed to use the standard string name pluginname, as for other plugin types. $string['pluginname'] = 'Aiken format'; $string['pluginname_help'] = 'This is a simple format ...'; $string['pluginname_link'] = 'qformat/aiken'; PK�����\"����&��format/xhtml/lang/en/qformat_xhtml.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Strings for component 'qformat_xhtml', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_xhtml * @copyright 2010 Helen Foster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'XHTML format'; $string['pluginname_help'] = 'XHTML format enables all questions in the category to be exported to a single page of strict XHTML for possible use in another application.'; $string['pluginname_link'] = 'qformat/xhtml'; $string['privacy:metadata'] = 'The XHTML question format plugin does not store any personal data.'; PK�����\rf��f����format/xhtml/lib.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Standard plugin entry points of the HTML question export format. * * @package qformat_xhtml * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Serve question files when they are displayed in this export format. * * @param context $previewcontext the quiz context * @param int $questionid the question id. * @param context $filecontext the file (question) context * @param string $filecomponent the component the file belongs to. * @param string $filearea the file area. * @param array $args remaining file args. * @param bool $forcedownload. * @param array $options additional options affecting the file serving. */ function qformat_xhtml_question_preview_pluginfile($previewcontext, $questionid, $filecontext, $filecomponent, $filearea, $args, $forcedownload, $options = array()) { global $CFG; list($context, $course, $cm) = get_context_info_array($previewcontext->id); require_login($course, false, $cm); question_require_capability_on($questionid, 'view'); $fs = get_file_storage(); $relativepath = implode('/', $args); $fullpath = "/{$filecontext->id}/{$filecomponent}/{$filearea}/{$relativepath}"; if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) { send_file_not_found(); } send_stored_file($file, 0, 0, $forcedownload, $options); } PK�����\Ee:��:����format/xhtml/version.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Version information for the calculated question type. * * @package qformat_xhtml * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_xhtml'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK�����\o����)��format/xhtml/classes/privacy/provider.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Privacy Subsystem implementation for qformat_xhtml. * * @package qformat_xhtml * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_xhtml\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_xhtml implementing null_provider. * * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK�����\g#"��"����format/xhtml/xhtml.cssnu�[��������body { font-family: verdana, helvetica, sans-serif; background-color: #fff; color: #000; } .question { border: 1px solid #ddd; margin: 5px; padding: 3px; } .question h3 { font-weight: normal; font-size: 125%; } .question ul { list-style-type: none; } PK�����\B8<��<����format/xhtml/format.phpnu�[��������<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * XHTML question exporter. * * @package qformat_xhtml * @copyright 2005 Howard Miller * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * XHTML question exporter. * * Exports questions as static HTML. * * @copyright 2005 Howard Miller * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_xhtml extends qformat_default { public function provide_export() { return true; } protected function repchar($text) { return $text; } protected function writequestion($question) { global $OUTPUT; // Turns question into string. // Question reflects database fields for general question and specific to type. // If a category switch, just ignore. if ($question->qtype=='category') { return ''; } // Initial string. $expout = ""; $id = $question->id; // Add comment and div tags. $expout .= "<!-- question: {$id} name: {$question->name} -->\n"; $expout .= "<div class=\"question\">\n"; // Add header. $expout .= "<h3>{$question->name}</h3>\n"; // Format and add the question text. $text = question_rewrite_question_preview_urls($question->questiontext, $question->id, $question->contextid, 'question', 'questiontext', $question->id, $question->contextid, 'qformat_xhtml'); $expout .= '<p class="questiontext">' . format_text($text, $question->questiontextformat, array('noclean' => true)) . "</p>\n"; // Selection depends on question type. switch($question->qtype) { case 'truefalse': $sttrue = get_string('true', 'qtype_truefalse'); $stfalse = get_string('false', 'qtype_truefalse'); $expout .= "<ul class=\"truefalse\">\n"; $expout .= " <li><input name=\"quest_{$id}\" type=\"radio\" value=\"{$sttrue}\" />{$sttrue}</li>\n"; $expout .= " <li><input name=\"quest_{$id}\" type=\"radio\" value=\"{$stfalse}\" />{$stfalse}</li>\n"; $expout .= "</ul>\n"; break; case 'multichoice': $expout .= "<ul class=\"multichoice\">\n"; foreach ($question->options->answers as $answer) { $answertext = $this->repchar( $answer->answer ); if ($question->options->single) { $expout .= " <li><input name=\"quest_{$id}\" type=\"radio\" value=\"" . s($answertext) . "\" />{$answertext}</li>\n"; } else { $expout .= " <li><input name=\"quest_{$id}\" type=\"checkbox\" value=\"" . s($answertext) . "\" />{$answertext}</li>\n"; } } $expout .= "</ul>\n"; break; case 'shortanswer': $expout .= html_writer::start_tag('ul', array('class' => 'shortanswer')); $expout .= html_writer::start_tag('li'); $expout .= html_writer::label(get_string('answer'), 'quest_'.$id, false, array('class' => 'accesshide')); $expout .= html_writer::empty_tag('input', array('id' => "quest_{$id}", 'name' => "quest_{$id}", 'type' => 'text')); $expout .= html_writer::end_tag('li'); $expout .= html_writer::end_tag('ul'); break; case 'numerical': $expout .= html_writer::start_tag('ul', array('class' => 'numerical')); $expout .= html_writer::start_tag('li'); $expout .= html_writer::label(get_string('answer'), 'quest_'.$id, false, array('class' => 'accesshide')); $expout .= html_writer::empty_tag('input', array('id' => "quest_{$id}", 'name' => "quest_{$id}", 'type' => 'text')); $expout .= html_writer::end_tag('li'); $expout .= html_writer::end_tag('ul'); break; case 'match': $expout .= html_writer::start_tag('ul', array('class' => 'match')); // Build answer list. $answerlist = array(); foreach ($question->options->subquestions as $subquestion) { $answerlist[] = $this->repchar( $subquestion->answertext ); } shuffle( $answerlist ); // Random display order. // Build select options. $selectoptions = array(); foreach ($answerlist as $ans) { $selectoptions[s($ans)] = s($ans); } // Display. $option = 0; foreach ($question->options->subquestions as $subquestion) { // Build drop down for answers. $questiontext = $this->repchar( $subquestion->questiontext ); if ($questiontext != '') { $dropdown = html_writer::label(get_string('answer', 'qtype_match', $option+1), 'quest_'.$id.'_'.$option, false, array('class' => 'accesshide')); $dropdown .= html_writer::select($selectoptions, "quest_{$id}_{$option}", '', false, array('id' => "quest_{$id}_{$option}")); $expout .= html_writer::tag('li', $questiontext); $expout .= $dropdown; $option++; } } $expout .= html_writer::end_tag('ul'); break; case 'description': break; case 'multianswer': default: $expout .= "<!-- export of {$question->qtype} type is not supported -->\n"; } // Close off div. $expout .= "</div>\n\n\n"; return $expout; } protected function presave_process($content) { // Override method to allow us to add xhtml headers and footers. global $CFG; // Get css bit. $csslines = file( "{$CFG->dirroot}/question/format/xhtml/xhtml.css" ); $css = implode( ' ', $csslines ); $xp = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n"; $xp .= " \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\n"; $xp .= "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n"; $xp .= "<head>\n"; $xp .= "<meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\" />\n"; $xp .= "<title>Moodle Quiz XHTML Export\n"; $xp .= "\n"; $xp .= "\n"; $xp .= "\n"; $xp .= "
    \n\n"; $xp .= $content; $xp .= "

    \n"; $xp .= " \n"; $xp .= "

    \n"; $xp .= "
    \n"; $xp .= "\n"; $xp .= "\n"; return $xp; } public function export_file_extension() { return '.html'; } } PK\M&format/aiken/lang/en/qformat_aiken.phpnu[. /** * Strings for component 'qformat_aiken', language 'en', branch 'MOODLE_20_STABLE' * * @package qformat_aiken * @copyright 2010 Helen Foster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Aiken format'; $string['pluginname_help'] = 'This is a simple format for importing multiple choice questions from a text file.'; $string['pluginname_link'] = 'qformat/aiken'; $string['privacy:metadata'] = 'The Aiken question format plugin does not store any personal data.'; $string['questionmissinganswers'] = 'Question must have at least 2 answers on line {$a}'; $string['questionnotcomplete'] = 'Question not completed before next question start on line {$a}'; $string['questionnotstarted'] = 'Question not started on line {$a}'; PK\ ,::format/aiken/version.phpnu[. /** * Version information for the calculated question type. * * @package qformat_aiken * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qformat_aiken'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\|;)format/aiken/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qformat_aiken. * * @package qformat_aiken * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qformat_aiken\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qformat_aiken implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\oG/format/aiken/tests/fixtures/questions.aiken.txtnu[The Moodle project was started by: A) Petr Skoda B) Martin Dougiamas C) Eloy Lafuente D) Tim Hunt ANSWER: B Moodle's abilities include handling user input that includes & images: A) True B) False ANSWER: A PK\X,format/aiken/tests/fixtures/aiken_errors.txtnu[A question with too few answers A) Only answer ANSWER: A A) Question not started ANSWER: Question not started A good question A) Correct B) Incorrect ANSWER: A A question started but not finished A) Correct-ish B) Incorrect-ish A second good question A)Incorrect (No space) B)Correct (No space) ANSWER: B A third good question with HTML chars such as > < & A) Correct (< > &) B) Incorrect (< > &) ANSWER: APK\$Y660format/aiken/tests/qformat_aiken_export_test.phpnu[. /** * Unit tests for export/import description (info) for question category in the Moodle XML format. * * @package qformat_aiken * @copyright 2018 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_question\local\bank\question_version_status; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/aiken/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/editlib.php'); /** * Unit tests for the Aiken question format export. * * @copyright 2018 Jean-Michel vedrine) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qformat_aiken */ final class qformat_aiken_export_test extends advanced_testcase { /** * Assert that 2 strings are the same, ignoring ends of line. * We need to override this function because we don't want any output * @param string $expectedtext The expected string. * @param string $text The actual string. */ public function assert_same_aiken($expectedtext, $text) { $this->assertEquals( phpunit_util::normalise_line_endings($expectedtext), phpunit_util::normalise_line_endings($text) ); } public function test_export_questions(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a new course category and and a new course in that. $category = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(array('category' => $category->id)); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $context = context_module::instance($qbank->cmid); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(array('contextid' => $context->id)); $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); $question2 = $generator->create_question('essay', null, array('category' => $cat->id)); $question3 = $generator->create_question('numerical', null, array('category' => $cat->id)); $question4 = $generator->create_question('multichoice', 'one_of_four', array('category' => $cat->id)); $question4 = $generator->create_question('multichoice', 'two_of_four', array('category' => $cat->id)); $exporter = new qformat_aiken(); $exporter->category = $cat; $exporter->setCourse($course); $expectedoutput = <<assert_same_aiken($expectedoutput, $exporter->exportprocess()); } public function test_export_multiline_question(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a new course category and and a new course in that. $category = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(array('category' => $category->id)); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $context = context_module::instance($qbank->cmid); $cat = $generator->create_question_category(array('contextid' => $context->id)); $question = $generator->create_question('multichoice', 'one_of_four', array('category' => $cat->id)); $question->questiontext = <<Which is the

    oddest number?

    EOT; $exporter = new qformat_aiken(); $exporter->category = $cat; $exporter->setCourse($course); $expectedoutput = <<assert_same_aiken($expectedoutput, $exporter->exportprocess()); } public function test_hidden_question_not_exported(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a new course category and a new course in that. $category = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(['category' => $category->id]); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $context = context_coursecat::instance($category->id); $cat = $generator->create_question_category(['contextid' => $context->id]); // Create a visible and a hidden question. $generator->create_question('multichoice', 'one_of_four', [ 'category' => $cat->id, 'questiontext' => ['text' => 'This question is the visible one.', 'format' => FORMAT_HTML], ]); $generator->create_question('multichoice', 'one_of_four', [ 'category' => $cat->id, 'questiontext' => ['text' => 'This question is the hidden one.', 'format' => FORMAT_HTML], 'status' => question_version_status::QUESTION_STATUS_HIDDEN, ]); // Prepared the expected result. $expectedoutput = <<category = $cat; $exporter->setCourse($course); $this->assert_same_aiken($expectedoutput, $exporter->exportprocess()); } } PK\$\]'format/aiken/tests/aikenformat_test.phpnu[. /** * Unit tests for the Moodle Aiken format. * * @package qformat_aiken * @copyright 2018 Eric Merrill (eric.a.merrill@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/aiken/format.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the matching question definition class. * * @copyright 2018 Eric Merrill (eric.a.merrill@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class aikenformat_test extends question_testcase { public function test_readquestions(): void { global $CFG; $lines = file($CFG->dirroot.'/question/format/aiken/tests/fixtures/aiken_errors.txt'); $importer = new qformat_aiken($lines); // The importer echos some errors, so we need to capture and check that. ob_start(); $questions = $importer->readquestions($lines); $output = ob_get_contents(); ob_end_clean(); // Check that there were some expected errors. $this->assertStringContainsString('Error importing question A question with too few answers', $output); $this->assertStringContainsString('Question must have at least 2 answers on line 3', $output); $this->assertStringContainsString('Question not started on line 5', $output); $this->assertStringContainsString('Question not started on line 7', $output); $this->assertStringContainsString('Error importing question A question started but not finished', $output); $this->assertStringContainsString('Question not completed before next question start on line 18', $output); // There are three expected questions. $this->assertCount(3, $questions); $q1 = null; $q2 = null; $q3 = null; foreach ($questions as $question) { if ($question->name === 'A good question') { $q1 = $question; } else if ($question->name === 'A second good question') { $q2 = $question; } else if ($question->name === 'A third good question with HTML chars such as > < &') { $q3 = $question; } } // Check the first good question. $this->assertCount(2, $q1->answer); $this->assertEquals(1, $q1->fraction[0]); $this->assertEquals('Correct', $q1->answer[0]['text']); $this->assertEquals('Incorrect', $q1->answer[1]['text']); // Check the second good question. $this->assertCount(2, $q2->answer); $this->assertEquals(1, $q2->fraction[1]); $this->assertEquals('Incorrect (No space)', $q2->answer[0]['text']); $this->assertEquals('Correct (No space)', $q2->answer[1]['text']); // Check the third good question that has anwsers with special HTML chars such as <, >, and &. $this->assertCount(2, $q3->answer); $this->assertEquals(1, $q3->fraction[0]); $this->assertEquals('Correct (< > &)', $q3->answer[0]['text']); $this->assertEquals('Incorrect (< > &)', $q3->answer[1]['text']); } } PK\ -format/aiken/tests/behat/aiken_import.featurenu[@qformat @qformat_aiken Feature: Test importing questions from Aiken format. In order to reuse questions As an teacher I need to be able to import them in Aiken format. Background: Given the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And the following "users" exist: | username | firstname | | teacher | Teacher | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | @javascript @_file_upload Scenario: import some Aiken questions Given I am on the "Qbank 1" "core_question > question import" page logged in as "teacher" And I set the field "id_format_aiken" to "1" And I upload "question/format/aiken/tests/fixtures/questions.aiken.txt" file to "Import" filemanager When I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 2 questions from file" And I should see "The Moodle project was started by:" And I press "Continue" And I should see "The Moodle project was started by:" PK\-|=:DD-format/aiken/tests/behat/aiken_export.featurenu[@qformat @qformat_aiken Feature: Test exporting questions using Aiken format. In order to reuse questions As an teacher I need to be able to export them in Aiken format. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Default for Qbank 1 | multichoice | Multi-choice-001 | two_of_four | | Default for Qbank 1 | multichoice | Multi-choice-002 | one_of_four | Scenario: Aiken export When I am on the "Qbank 1" "core_question > question export" page logged in as "teacher1" And I set the field "id_format_aiken" to "1" When I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/plain | | Contains text | Which is the oddest number? | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\~mformat/aiken/format.phpnu[. /** * Aiken format question importer. * * @package qformat_aiken * @copyright 2003 Tom Robb * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Aiken format - a simple format for creating multiple choice questions (with * only one correct choice, and no feedback). * * The format looks like this: * * Question text * A) Choice #1 * B) Choice #2 * C) Choice #3 * D) Choice #4 * ANSWER: B * * That is, * + question text all one one line. * + then a number of choices, one to a line. Each line must comprise a letter, * then ')' or '.', then a space, then the choice text. * + Then a line of the form 'ANSWER: X' to indicate the correct answer. * * Be sure to word "All of the above" type choices like "All of these" in * case choices are being shuffled. * * @copyright 2003 Tom Robb * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_aiken extends qformat_default { public function provide_import() { return true; } public function provide_export() { return true; } public function validate_file(stored_file $file): string { return $this->validate_is_utf8_file($file); } public function readquestions($lines) { $questions = array(); $question = null; $endchar = chr(13); $linenumber = 0; foreach ($lines as $line) { $stp = strpos($line, $endchar, 0); $newlines = explode($endchar, $line); $linescount = count($newlines); for ($i=0; $i < $linescount; $i++) { $linenumber++; $nowline = trim($newlines[$i]); // Go through the array and build an object called $question // When done, add $question to $questions. if (strlen($nowline) < 2) { continue; } if (preg_match('/^[A-Z][).][ \t]?/', $nowline)) { if (is_null($question)) { // We have a response line, but we aren't currently in a question. $this->error(get_string('questionnotstarted', 'qformat_aiken', $linenumber)); continue; } // A choice. Trim off the label and space, then save. $question->answer[] = $this->text_field(substr($nowline, 2)); $question->fraction[] = 0; $question->feedback[] = $this->text_field(''); } else if (preg_match('/^ANSWER:/', $nowline)) { if (is_null($question)) { // We have an answer line, but we aren't currently in a question. $this->error(get_string('questionnotstarted', 'qformat_aiken', $linenumber)); continue; } // The line that indicates the correct answer. This question is finised. $ans = trim(substr($nowline, strpos($nowline, ':') + 1)); $ans = substr($ans, 0, 1); // We want to map A to 0, B to 1, etc. $rightans = ord($ans) - ord('A'); if (count($question->answer) < 2) { // The multichoice question requires at least 2 answers, or there will be a failure later. $this->error(get_string('questionmissinganswers', 'qformat_aiken', $linenumber), '', $question->name); $question = null; continue; } $question->fraction[$rightans] = 1; $questions[] = $question; // Clear variable for next question set. $question = null; continue; } else { // Must be the first line of a new question, since no recognised prefix. if (!is_null($question)) { // In this case, there was already an open question that we didn't complete. It is being discarded. $this->error(get_string('questionnotcomplete', 'qformat_aiken', $linenumber), '', $question->name); } $question = $this->defaultquestion(); $question->qtype = 'multichoice'; $question->name = $this->create_default_question_name($nowline, get_string('questionname', 'question')); $question->questiontext = htmlspecialchars(trim($nowline), ENT_NOQUOTES); $question->questiontextformat = FORMAT_HTML; $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_HTML; $question->single = 1; $question->answer = array(); $question->fraction = array(); $question->feedback = array(); $question->correctfeedback = $this->text_field(''); $question->partiallycorrectfeedback = $this->text_field(''); $question->incorrectfeedback = $this->text_field(''); } } } return $questions; } protected function text_field($text) { return array( 'text' => htmlspecialchars(trim($text), ENT_NOQUOTES), 'format' => FORMAT_HTML, 'files' => array(), ); } public function readquestion($lines) { // This is no longer needed but might still be called by default.php. return; } public function exportpreprocess() { // This format is not able to export categories. $this->setCattofile(false); return true; } public function writequestion($question) { $endchar = "\n"; // Only export multichoice questions. if ($question->qtype != 'multichoice') { return null; } // Do not export multichoice multi questions. if (!$question->options->single) { return null; } // Aiken format is not able to handle question with more than 26 answers. if (count($question->options->answers) > 26) { return null; } // Export the question displaying message. $expout = str_replace("\n", '', question_utils::to_plain_text($question->questiontext, $question->questiontextformat, array('para' => false, 'newlines' => false))) . $endchar; $num = 0; foreach ($question->options->answers as $answer) { $number = chr(ord('A') + $num); $expout .= $number . ') ' . str_replace("\n", '', question_utils::to_plain_text($answer->answer, $answer->answerformat, array('para' => false, 'newlines' => false))) . $endchar; if ($answer->fraction > .99) { $correctanswer = $number; } $num++; } // Add the correct answer. $expout .= 'ANSWER: ' . $correctanswer; return $expout; } } PK\){UUformat/README.txtnu[Question import/export formats ============================== This directory contains plug-ins to supprt importing and exporting questions in a variety of formats. Each sub-module must contain at least a format.php file containing a class that contains functions for reading, writing, importing and exporting questions. For correct operation the class name must be based on the name of the plugin. For example: plugin: webct class: class qformat_webct extends qformat_default { Most of them are based on the class found in question/format.php. See the comments therein for more information. PK\r9$$&classes/question_reference_manager.phpnu[. namespace core_question; use core_question\local\bank\question_version_status; /** * This class should provide an API for managing question_references. * * Unfortunately, question_references were introduced in the DB structure * without an nice API. This class is being added later, and is currently * terribly incomplete, but hopefully it can be improved in time. * * @package core_question * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_reference_manager { /** * Return a list of those questions from the list passed in, which are referenced. * * A question is referenced if either: * - There is a question_reference pointing at exactly that version of that question; or * - There is an 'always latest' reference, and the question id is the latest non-draft version * of that question_bank_entry. * * @param array $questionids a list of question ids to check. * @return array a list of the question ids from the input array which are referenced. */ public static function questions_with_references(array $questionids): array { global $DB; if (empty($questionids)) { return []; } [$qidtest, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'outerqid'); [$lqidtest, $lparams] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'innerqid'); return $DB->get_fieldset_sql(" SELECT qv.questionid FROM {question_versions} qv -- This is a performant to get the latest non-draft version for each -- question_bank_entry that relates to one of our questionids. LEFT JOIN ( SELECT lqv.questionbankentryid, MAX(lv.version) AS latestusableversion FROM {question_versions} lqv JOIN {question_versions} lv ON lv.questionbankentryid = lqv.questionbankentryid WHERE lqv.questionid $lqidtest AND lv.status <> :draft GROUP BY lqv.questionbankentryid ) latestversions ON latestversions.questionbankentryid = qv.questionbankentryid JOIN {question_references} qr ON qr.questionbankentryid = qv.questionbankentryid AND (qr.version = qv.version OR qr.version IS NULL AND qv.version = latestversions.latestusableversion) WHERE qv.questionid $qidtest ", array_merge($params, $lparams, ['draft' => question_version_status::QUESTION_STATUS_DRAFT])); } /** * This will transform set reference filter conditions to use the new filter structure. * * Previously filterconditions had questioncategoryid, includesubcategories and tags options. * These have been replaced by the new category and tags filters. This function convers the old * pre-4.3 filter condition structure to the new one. * * @param array $filtercondition Pre-4.3 filter condition. * @return array Post-4.3 filter condition. */ public static function convert_legacy_set_reference_filter_condition(array $filtercondition): array { global $DB; if (!isset($filtercondition['filter'])) { $filtercondition['filter'] = []; // Question category filter. if (isset($filtercondition['questioncategoryid'])) { $filtercondition['filter']['category'] = [ 'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT, 'values' => [$filtercondition['questioncategoryid']], 'filteroptions' => ['includesubcategories' => $filtercondition['includingsubcategories']], ]; } // Tag filters. if (isset($filtercondition['tags'])) { // Get the names of the tags in the condition. Find or create corresponding tags, // and set their ids in the new condition. $oldtags = array_map(fn($oldtag) => explode(',', $oldtag)[1], $filtercondition['tags']); $questiontagcollid = \core_tag_area::get_collection('core_question', 'question'); $newtags = \core_tag_tag::create_if_missing($questiontagcollid, $oldtags); $newtagids = array_map(fn($newtag) => $newtag->id, $newtags); $filtercondition['filter']['qtagids'] = [ 'jointype' => \qbank_tagquestion\tag_condition::JOINTYPE_DEFAULT, 'values' => array_values($newtagids), ]; unset($filtercondition['tags']); } // Add additional default properties to the filtercondition. if (isset($filtercondition['questioncategoryid'])) { $category = $DB->get_record('question_categories', ['id' => $filtercondition['questioncategoryid']]); if ($category) { $filtercondition['cat'] = "{$category->id},{$category->contextid}"; } else { $filtercondition['cat'] = ''; } } $filtercondition['tabname'] = 'questions'; $filtercondition['qpage'] = 0; $filtercondition['qperpage'] = 100; $filtercondition['jointype'] = \core\output\datafilter::JOINTYPE_ALL; unset($filtercondition['questioncategoryid']); unset($filtercondition['includingsubcategories']); } else if (isset($filtercondition['filter']['category']['includesubcategories'])) { $filtercondition['filter']['category']['filteroptions'] = ['includesubcategories' => $filtercondition['filter']['category']['includesubcategories']]; unset($filtercondition['filter']['category']['includesubcategories']); } return $filtercondition; } } PK\16}$&$&classes/external.phpnu[. /** * External question API * * @package core_question * @category external * @copyright 2016 Pau Ferrer * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\context; use core\notification; use core_external\external_api; use core_external\external_description; use core_external\external_value; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_external\external_function_parameters; use core_external\external_warnings; use core_question\local\bank\filter_condition_manager; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); require_once($CFG->dirroot . '/question/engine/datalib.php'); require_once($CFG->libdir . '/questionlib.php'); /** * Question external functions * * @package core_question * @category external * @copyright 2016 Pau Ferrer * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ class core_question_external extends external_api { /** * Returns description of method parameters * * @return external_function_parameters * @since Moodle 3.1 */ public static function update_flag_parameters() { return new external_function_parameters( array( 'qubaid' => new external_value(PARAM_INT, 'the question usage id.'), 'questionid' => new external_value(PARAM_INT, 'the question id'), 'qaid' => new external_value(PARAM_INT, 'the question_attempt id'), 'slot' => new external_value(PARAM_INT, 'the slot number within the usage'), 'checksum' => new external_value(PARAM_ALPHANUM, 'computed checksum with the last three arguments and the users username'), 'newstate' => new external_value(PARAM_BOOL, 'the new state of the flag. true = flagged') ) ); } /** * Update the flag state of a question attempt. * * @param int $qubaid the question usage id. * @param int $questionid the question id. * @param int $qaid the question_attempt id. * @param int $slot the slot number within the usage. * @param string $checksum checksum, as computed by {@link get_toggle_checksum()} * corresponding to the last three arguments and the users username. * @param bool $newstate the new state of the flag. true = flagged. * @return array (success infos and fail infos) * @since Moodle 3.1 */ public static function update_flag($qubaid, $questionid, $qaid, $slot, $checksum, $newstate) { global $CFG, $DB; $params = self::validate_parameters(self::update_flag_parameters(), array( 'qubaid' => $qubaid, 'questionid' => $questionid, 'qaid' => $qaid, 'slot' => $slot, 'checksum' => $checksum, 'newstate' => $newstate ) ); $warnings = array(); self::validate_context(context_system::instance()); // The checksum will be checked to provide security flagging other users questions. question_flags::update_flag($params['qubaid'], $params['questionid'], $params['qaid'], $params['slot'], $params['checksum'], $params['newstate']); $result = array(); $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Returns description of method result value * * @return external_description * @since Moodle 3.1 */ public static function update_flag_returns() { return new external_single_structure( array( 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings() ) ); } /** * Returns description of method parameters. * * @return external_function_parameters. */ public static function get_random_question_summaries_parameters() { return new external_function_parameters([ 'categoryid' => new external_value(PARAM_INT, 'Category id to find random questions'), 'includesubcategories' => new external_value(PARAM_BOOL, 'Include the subcategories in the search'), 'tagids' => new external_multiple_structure( new external_value(PARAM_INT, 'Tag id') ), 'contextid' => new external_value(PARAM_INT, 'Context id that the questions will be rendered in (used for exporting)'), 'limit' => new external_value(PARAM_INT, 'Maximum number of results to return', VALUE_DEFAULT, 0), 'offset' => new external_value(PARAM_INT, 'Number of items to skip from the begging of the result set', VALUE_DEFAULT, 0) ]); } /** * Gets the list of random questions for the given criteria. The questions * will be exported in a summaries format and won't include all of the * question data. * * @param int $categoryid Category id to find random questions * @param bool $includesubcategories Include the subcategories in the search * @param int[] $tagids Only include questions with these tags * @param int $contextid The context id where the questions will be rendered * @param int $limit Maximum number of results to return * @param int $offset Number of items to skip from the beginning of the result set. * @return array The list of questions and total question count. */ public static function get_random_question_summaries( $categoryid, $includesubcategories, $tagids, $contextid, $limit = 0, $offset = 0 ) { global $DB, $PAGE; // Parameter validation. $params = self::validate_parameters( self::get_random_question_summaries_parameters(), [ 'categoryid' => $categoryid, 'includesubcategories' => $includesubcategories, 'tagids' => $tagids, 'contextid' => $contextid, 'limit' => $limit, 'offset' => $offset ] ); $categoryid = $params['categoryid']; $includesubcategories = $params['includesubcategories']; $tagids = $params['tagids']; $contextid = $params['contextid']; $limit = $params['limit']; $offset = $params['offset']; $context = \context::instance_by_id($contextid); self::validate_context($context); $categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST); $categorycontext = \context::instance_by_id($categorycontextid); $editcontexts = new \core_question\local\bank\question_edit_contexts($categorycontext); // The user must be able to view all questions in the category that they are requesting. $editcontexts->require_cap('moodle/question:viewall'); $loader = new \core_question\local\bank\random_question_loader(new qubaid_list([])); // Only load the properties we require from the DB. $properties = \core_question\external\question_summary_exporter::get_mandatory_properties(); // Transform to filters. $filters = [ 'category' => [ 'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT, 'values' => [$categoryid], 'filteroptions' => ['includesubcategories' => $includesubcategories], ], 'qtagids' => [ 'jointype' => \qbank_tagquestion\tag_condition::JOINTYPE_DEFAULT, 'values' => $tagids, ], ]; $questions = $loader->get_filtered_questions($filters, $limit, $offset, $properties); $totalcount = $loader->count_filtered_questions($filters); $renderer = $PAGE->get_renderer('core'); $formattedquestions = array_map(function($question) use ($context, $renderer) { $exporter = new \core_question\external\question_summary_exporter($question, ['context' => $context]); return $exporter->export($renderer); }, $questions); return [ 'totalcount' => $totalcount, 'questions' => $formattedquestions ]; } /** * Returns description of method result value. */ public static function get_random_question_summaries_returns() { return new external_single_structure([ 'totalcount' => new external_value(PARAM_INT, 'total number of questions in result set'), 'questions' => new external_multiple_structure( \core_question\external\question_summary_exporter::get_read_structure() ) ]); } } PK\{)" " classes/versions.phpnu[. namespace core_question; /** * Methods for finding and manipulating question versions * * @package core_question * @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 versions { /** * Get the next version number for a question bank entry. * * This uses the value in the question bank entry record, but if it's not set, it will calculate it based on the * current highest version in question_versions. * * If calling this with $increment = true, it is a good idea to do this inside a transaction which only commits after the * new version number has been used to save a new version of the question. This avoids wasting version numbers if an * error happens. * * @param int $questionbankentryid * @param bool $increment If true, increment the version number by 1 after it is read. * @return int The number of the next version. */ public static function get_next_version(int $questionbankentryid, bool $increment = true): int { global $DB; $transaction = $DB->start_delegated_transaction(); $nextversion = $DB->get_field('question_bank_entries', 'nextversion', ['id' => $questionbankentryid]); if (is_null($nextversion)) { $nextversion = $DB->get_field_sql( "SELECT COALESCE(MAX(qv.version), 0) + 1 FROM {question_versions} qv WHERE qv.questionbankentryid = :qbeid", ['qbeid' => $questionbankentryid], ); $DB->set_field('question_bank_entries', 'nextversion', $nextversion, ['id' => $questionbankentryid]); } if ($increment) { self::increment_next_version($questionbankentryid); } $transaction->allow_commit(); return $nextversion; } /** * Increment the next version by 1 for the question bank entry * * @param int $questionbankentryid */ protected static function increment_next_version(int $questionbankentryid): bool { global $DB; return $DB->execute( "UPDATE {question_bank_entries} SET nextversion = nextversion + 1 WHERE id = :id", ['id' => $questionbankentryid], ); } } PK\22+classes/external/question_icon_exporter.phpnu[. /** * Class for exporting a question icon from an stdClass. * * @package core_question * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\external; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/bank.php'); /** * Class for exporting a question from an stdClass. * * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_icon_exporter extends \core\external\exporter { /** * Constructor. * * @param \stdClass $question * @param array $related The related data. */ public function __construct(\stdClass $question, $related = []) { $qtype = \question_bank::get_qtype($question->qtype, false); parent::__construct((object) [ 'key' => 'icon', 'component' => $qtype->plugin_name(), 'alttext' => $qtype->local_name() ], $related); } /** * Set the moodle context as a required related object. * * @return array Required related objects. */ protected static function define_related() { return ['context' => '\\context']; } /** * Return the list of properties. * * @return array */ protected static function define_properties() { return [ 'key' => ['type' => PARAM_TEXT], 'component' => ['type' => PARAM_COMPONENT], 'alttext' => ['type' => PARAM_TEXT], ]; } } PK\2fWW#classes/external/move_questions.phpnu[. namespace core_question\external; use core\context; use core\exception\moodle_exception; use core\notification; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; use core_external\restricted_context_exception; use core_question\local\bank\filter_condition_manager; use moodle_url; /** * API for moving questions from one question bank category to another. * * @package core_question * @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 move_questions extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters( [ 'newcontextid' => new external_value(PARAM_INT, 'Contextid of the target question bank'), 'newcategoryid' => new external_value(PARAM_INT, 'ID of the target question category'), 'questionids' => new external_value(PARAM_SEQUENCE, 'Comma separated list of question ids to move'), 'returnurl' => new external_value(PARAM_LOCALURL, desc: 'A URL to add/update the filter param with the new category', default: '' ), ] ); } /** * Define the webservice response. * * @return external_value */ public static function execute_returns(): external_value { return new external_value(PARAM_URL, 'Modified URL with filter param containing the new question category', VALUE_OPTIONAL); } /** * Move questions to a new question category. * Optionally provide a url to add/update it with the filter param containing the new category. * * @param int $newcontextid of the target question bank * @param int $newcategoryid of the target category * @param string $questionids comma separated list of question ids to move * @param string $returnurlstring optional, provide this to have the filter url param added/updated to reflect the new category * @return null|string if $returnurlstring was provided then an updated url which filters to the new category */ public static function execute( int $newcontextid, int $newcategoryid, string $questionids, string $returnurlstring = '' ): ?string { global $DB; [ 'newcontextid' => $newcontextid, 'newcategoryid' => $newcategoryid, 'questionids' => $questionids, 'returnurl' => $returnurlstring, ] = self::validate_parameters(self::execute_parameters(), [ 'newcontextid' => $newcontextid, 'newcategoryid' => $newcategoryid, 'questionids' => $questionids, 'returnurl' => $returnurlstring, ]); $newcontext = context::instance_by_id($newcontextid); self::validate_context($newcontext); \core_question\local\bank\helper::require_plugin_enabled('qbank_bulkmove'); $contexts = new \core_question\local\bank\question_edit_contexts($newcontext); $contexts->require_cap('moodle/question:add'); if (!$targetcategory = $DB->get_record('question_categories', ['id' => $newcategoryid, 'contextid' => $newcontextid])) { throw new \moodle_exception('cannotfindcate', 'question'); } \qbank_bulkmove\helper::bulk_move_questions($questionids, $targetcategory); notification::success(get_string('questionsmoved', 'qbank_bulkmove')); if ($returnurlstring) { $returnurl = new moodle_url($returnurlstring); $returnurl->param('cmid', $newcontext->instanceid); $returnurl->param('cat', "{$newcategoryid},{$newcontextid}"); $returnurl->remove_params('category'); // We can only highlight 1 question, so only highlight if we're moving a single question. $qids = explode(',', $questionids); if (count($qids) === 1) { $returnurl->param('lastchanged', reset($qids)); } else { $returnurl->remove_params('lastchanged'); }; $filter = $returnurl->param('filter'); if ($filter) { $returnfilters = filter_condition_manager::update_filter_param_to_category( $filter, $newcategoryid, ); } else { $returnfilters = json_encode( filter_condition_manager::get_default_filter("{$newcategoryid},{$newcontextid}"), JSON_THROW_ON_ERROR ); } $returnurl->param('filter', $returnfilters); return $returnurl->out(false); } } } PK\a(classes/external/search_shared_banks.phpnu[. namespace core_question\external; use core\exception\coding_exception; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_external\external_value; use core_question\local\bank\question_bank_helper; use core\context; /** * Return a filtered of the user's shared question banks * * For use with core_question/question_banks_datasource as a source for autocomplete suggestions. * * @package core_question * @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 search_shared_banks extends external_api { /** * @var int The maximum number of banks to return. */ const MAX_RESULTS = 20; /** * Define parameters for external function. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters( [ 'contextid' => new external_value(PARAM_INT, 'The current context ID for applying text filters to bank names.'), 'search' => new external_value(PARAM_TEXT, 'Search terms by which to filter the shared banks.', default: ''), 'requiredcapabilities' => new external_multiple_structure( new external_value(PARAM_TEXT, 'Capability'), 'Array of abbreviated "moodle/question:" capabilities that the user must have at least one of in the context ' . 'of each matching question bank. Valid options are "add", "managecategory", "flag", "config", "edit", ' . '"view", "use", "move" or "tag". For "edit", "view", "use", "move" and "tag", the -all and -mine ' . "suffixed versions of the capabilty will both be checked.", VALUE_DEFAULT, ['use'], ), ] ); } /** * Expand a list of abbreviated capaibilities into an array of full capability strings. * * Each abbreviation must match a capability with the 'moodle/question:' prefix. Capabilities that have an "all" and "mine" * variant will have both variants included in the returned array. * * These abbreviations are copied from {@see question_has_capability_on()} * * @param array $abbreviations Abbreviated capabilities. Must match capabilities with the 'moodle/question:' prefix. * @return array The expanded capabilities */ protected static function expand_capabilities(array $abbreviations): array { $capabilitieswithallandmine = ['edit', 'view', 'use', 'move', 'tag']; $prefix = 'moodle/question:'; $suffixes = ['all', 'mine']; $capabilities = []; foreach ($abbreviations as $abbreviation) { if (in_array($abbreviation, $capabilitieswithallandmine)) { foreach ($suffixes as $suffix) { $capability = $prefix . $abbreviation . $suffix; if (is_null(get_capability_info($capability))) { throw new coding_exception("Capability {$capability} does not exist."); } $capabilities[] = $capability; } } else { $capability = $prefix . $abbreviation; if (is_null(get_capability_info($capability))) { throw new coding_exception("Capability {$capability} does not exist."); } $capabilities[] = $capability; } } return $capabilities; } /** * Return ID and formatted name of question banks accessible by the user, in courses other than the one $contextid is in. * * @param int $contextid Context ID of the current activity * @param string $search String to filter results by question bank name * @param array $requiredcapabilities List of abbreviated capabilities to check, {@see self::expand_capabilities()} * @return array */ public static function execute( int $contextid, string $search = '', array $requiredcapabilities = ['use'], ): array { [ 'contextid' => $contextid, 'search' => $search, 'requiredcapabilities' => $requiredcapabilities, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, 'search' => $search, 'requiredcapabilities' => $requiredcapabilities, ]); $modulecontext = context::instance_by_id($contextid); self::validate_context($modulecontext); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( havingcap: self::expand_capabilities($requiredcapabilities), filtercontext: $modulecontext, search: $search, limit: self::MAX_RESULTS + 1, // Return up to 1 extra result, so we know there are more. ); $suggestions = array_map( fn($sharedbank) => [ 'value' => $sharedbank->modid, 'label' => $sharedbank->coursenamebankname, ], $sharedbanks, ); if (count($suggestions) > self::MAX_RESULTS) { // If there are too many results, replace the last one with a placeholder. $suggestions[array_key_last($suggestions)] = [ 'value' => 0, 'label' => get_string('otherquestionbankstoomany', 'question', self::MAX_RESULTS), ]; } return [ 'sharedbanks' => $suggestions, ]; } /** * Define return values. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure([ 'sharedbanks' => new external_multiple_structure( new external_single_structure([ 'value' => new external_value(PARAM_INT, 'Course Module ID of the shared bank.'), 'label' => new external_value(PARAM_TEXT, 'Formatted bank name'), ]), 'List of shared banks', ), ]); } } PK\qq.classes/external/question_summary_exporter.phpnu[. /** * Class for exporting a question summary from an stdClass. * * @package core_question * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\external; defined('MOODLE_INTERNAL') || die(); use \renderer_base; /** * Class for exporting a question summary from an stdClass. * * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_summary_exporter extends \core\external\exporter { /** * @var \stdClass $question */ protected $question; /** * Constructor. * * @param \stdClass $question * @param array $related The related data. */ public function __construct(\stdClass $question, $related = []) { $this->question = $question; return parent::__construct($question, $related); } /** * Set the moodle context as a required related object. * * @return array Required related objects. */ protected static function define_related() { return ['context' => '\\context']; } /** * The list of mandatory properties required on the question object to * export. * * @return string[] List of properties. */ public static function get_mandatory_properties() { $properties = self::define_properties(); $mandatoryproperties = array_filter($properties, function($property) { return empty($property['optional']); }); return array_keys($mandatoryproperties); } /** * The list of static properties returned. * * @return array List of properties. */ public static function define_properties() { return [ 'id' => [ 'type' => PARAM_INT, ], 'category' => [ 'type' => PARAM_INT, ], 'parent' => [ 'type' => PARAM_INT, ], 'name' => [ 'type' => PARAM_TEXT, ], 'qtype' => [ 'type' => PARAM_COMPONENT, ] ]; } /** * Define the list of calculated properties. * * @return array The list of properties. */ protected static function define_other_properties() { return [ 'icon' => [ 'type' => question_icon_exporter::read_properties_definition(), ] ]; } /** * Calculate the values for the properties defined in the define_other_properties * function. * * @param renderer_base $output A renderer. * @return array The list of properties. */ protected function get_other_values(\renderer_base $output) { $iconexporter = new question_icon_exporter($this->question, $this->related); return [ 'icon' => $iconexporter->export($output), ]; } } PK\ .~RoRoclasses/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for core_question. * * @package core_question * @category privacy * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\transform; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/question/engine/datalib.php'); /** * Privacy Subsystem implementation for core_question. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This component has data. // We need to return all question information where the user is // listed in either the question.createdby or question.modifiedby fields. // We may also need to fetch this informtion from individual plugins in some cases. // e.g. to fetch the full and other question-specific meta-data. \core_privacy\local\metadata\provider, // This is a subsysytem which provides information to core. \core_privacy\local\request\subsystem\provider, // This is a subsysytem which provides information to plugins. \core_privacy\local\request\subsystem\plugin_provider, // This plugin is capable of determining which users have data within it. \core_privacy\local\request\core_userlist_provider, // This plugin is capable of determining which users have data within it for the plugins it provides data to. \core_privacy\local\request\shared_userlist_provider { /** * Describe the types of data stored by the question subsystem. * * @param collection $items The collection to add metadata to. * @return collection The array of metadata */ public static function get_metadata(collection $items): collection { // Other tables link against it. // The 'question_usages' table does not contain any user data. // The table links the but doesn't store itself. // The 'question_attempts' table contains data about question attempts. // It does not contain any user ids - these are stored by the caller. $items->add_database_table('question_attempts', [ 'flagged' => 'privacy:metadata:database:question_attempts:flagged', 'responsesummary' => 'privacy:metadata:database:question_attempts:responsesummary', 'timemodified' => 'privacy:metadata:database:question_attempts:timemodified', ], 'privacy:metadata:database:question_attempts');; // The 'question_attempt_steps' table contains data about changes to the state of a question attempt. $items->add_database_table('question_attempt_steps', [ 'state' => 'privacy:metadata:database:question_attempt_steps:state', 'timecreated' => 'privacy:metadata:database:question_attempt_steps:timecreated', 'fraction' => 'privacy:metadata:database:question_attempt_steps:fraction', 'userid' => 'privacy:metadata:database:question_attempt_steps:userid', ], 'privacy:metadata:database:question_attempt_steps'); // The 'question_attempt_step_data' table contains specific all metadata for each state. $items->add_database_table('question_attempt_step_data', [ 'name' => 'privacy:metadata:database:question_attempt_step_data:name', 'value' => 'privacy:metadata:database:question_attempt_step_data:value', ], 'privacy:metadata:database:question_attempt_step_data'); // These are all part of the set of the question definition // The 'question' table is used to store instances of each question. // It contains a createdby and modifiedby which related to specific users. $items->add_database_table('question', [ 'name' => 'privacy:metadata:database:question:name', 'questiontext' => 'privacy:metadata:database:question:questiontext', 'generalfeedback' => 'privacy:metadata:database:question:generalfeedback', 'timecreated' => 'privacy:metadata:database:question:timecreated', 'timemodified' => 'privacy:metadata:database:question:timemodified', 'createdby' => 'privacy:metadata:database:question:createdby', 'modifiedby' => 'privacy:metadata:database:question:modifiedby', ], 'privacy:metadata:database:question'); // The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question. // It does not contain user data. // The 'question_hints' table is used to store hints about the correct answer for a question. // It does not contain user data. // The 'question_categories' table contains structural information about how questions are presented in the UI. // It does not contain user data. // The 'question_statistics' table contains aggregated statistics about responses. // It does not contain any identifiable user data. $items->add_database_table('question_bank_entries', [ 'ownerid' => 'privacy:metadata:database:question_bank_entries:ownerid', ], 'privacy:metadata:database:question_bank_entries'); // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types. $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype'); $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat'); $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour'); return $items; } /** * Export the data for all question attempts on this question usage. * * Where a user is the owner of the usage, then the full detail of that usage will be included. * Where a user has been involved in the usage, but it is not their own usage, then only their specific * involvement will be exported. * * @param int $userid The userid to export. * @param \context $context The context that the question was used within. * @param array $usagecontext The subcontext of this usage. * @param int $usage The question usage ID. * @param \question_display_options $options The display options used for formatting. * @param bool $isowner Whether the user being exported is the user who used the question. */ public static function export_question_usage( int $userid, \context $context, array $usagecontext, int $usage, \question_display_options $options, bool $isowner ) { // Determine the questions in this usage. $quba = \question_engine::load_questions_usage_by_activity($usage); $basepath = $usagecontext; $questionscontext = array_merge($usagecontext, [ get_string('questions', 'core_question'), ]); foreach ($quba->get_attempt_iterator() as $qa) { $question = $qa->get_question(); $slotno = $qa->get_slot(); $questionnocontext = array_merge($questionscontext, [$slotno]); if ($isowner) { // This user is the overal owner of the question attempt and all data wil therefore be exported. // // Respect _some_ of the question_display_options to ensure that they don't have access to // generalfeedback and mark if the display options prevent this. // This is defensible because they can submit questions without completing a quiz and perform an SAR to // get prior access to the feedback and mark to improve upon it. // Export the response. $data = (object) [ 'name' => $question->name, 'question' => $qa->get_question_summary(), 'answer' => $qa->get_response_summary(), 'timemodified' => transform::datetime($qa->timemodified), ]; if ($options->marks >= \question_display_options::MARK_AND_MAX) { $data->mark = $qa->format_mark($options->markdp); } if ($options->flags != \question_display_options::HIDDEN) { $data->flagged = transform::yesno($qa->is_flagged()); } if ($options->generalfeedback != \question_display_options::HIDDEN) { $data->generalfeedback = $question->format_generalfeedback($qa); } if ($options->manualcomment != \question_display_options::HIDDEN) { if ($qa->has_manual_comment()) { // Note - the export of the step data will ensure that the files are exported. // No need to do it again here. list($comment, $commentformat, $step) = $qa->get_manual_comment(); $comment = writer::with_context($context) ->rewrite_pluginfile_urls( $questionnocontext, 'question', 'response_bf_comment', $step->get_id(), $comment ); $data->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat); } } writer::with_context($context) ->export_data($questionnocontext, $data); // Export the step data. static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner); } } } /** * Export the data for each step transition for each question in each question attempt. * * Where a user is the owner of the usage, then all steps in the question usage will be exported. * Where a user is not the owner, but has been involved in the usage, then only their specific * involvement will be exported. * * @param int $userid The user to export for * @param \context $context The context that the question was used within. * @param array $questionnocontext The subcontext of this question number. * @param \question_attempt $qa The attempt being checked * @param \question_display_options $options The display options used for formatting. * @param bool $isowner Whether the user being exported is the user who used the question. */ public static function export_question_attempt_steps( int $userid, \context $context, array $questionnocontext, \question_attempt $qa, \question_display_options $options, $isowner ) { $attemptdata = (object) [ 'steps' => [], ]; $stepno = 0; foreach ($qa->get_step_iterator() as $i => $step) { $stepno++; if ($isowner || ($step->get_user_id() != $userid)) { // The user is the owner, or the author of the step. $restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null); $stepdata = (object) [ // Note: Do not include the user here. 'time' => transform::datetime($step->get_timecreated()), 'action' => $qa->summarise_action($step), ]; if ($options->marks >= \question_display_options::MARK_AND_MAX) { $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp); } if ($options->correctness != \question_display_options::HIDDEN) { $stepdata->state = $restrictedqa->get_state_string($options->correctness); } if ($step->has_behaviour_var('comment')) { $comment = $step->get_behaviour_var('comment'); $commentformat = $step->get_behaviour_var('commentformat'); if (empty(trim($comment))) { // Skip empty comments. continue; } // Format the comment. $comment = writer::with_context($context) ->rewrite_pluginfile_urls( $questionnocontext, 'question', 'response_bf_comment', $step->get_id(), $comment ); // Export any files associated with the comment files area. writer::with_context($context) ->export_area_files( $questionnocontext, 'question', "response_bf_comment", $step->get_id() ); $stepdata->comment = $qa->get_behaviour()->format_comment($comment, $commentformat); } // Export any response files associated with this step. foreach (\question_engine::get_all_response_file_areas() as $filearea) { writer::with_context($context) ->export_area_files( $questionnocontext, 'question', $filearea, $step->get_id() ); } $attemptdata->steps[$stepno] = $stepdata; } } if (!empty($attemptdata->steps)) { writer::with_context($context) ->export_related_data($questionnocontext, 'steps', $attemptdata); } } /** * Get the list of contexts where the specified user has either created, or edited a question. * * To export usage of a question, please call {@link provider::export_question_usage()} from the module which * instantiated the usage of the question. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new contextlist(); // A user may have created or updated a question. // Questions are linked against a question category, which has a contextid field. $sql = "SELECT qc.contextid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.createdby = :useridcreated OR q.modifiedby = :useridmodified"; $params = [ 'useridcreated' => $userid, 'useridmodified' => $userid, ]; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { $context = $userlist->get_context(); // A user may have created or updated a question. // Questions are linked against a question category, which has a contextid field. $sql = "SELECT q.createdby, q.modifiedby FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = :contextid"; $params = [ 'contextid' => $context->id ]; $userlist->add_from_sql('createdby', $sql, $params); $userlist->add_from_sql('modifiedby', $sql, $params); } /** * Determine related question usages for a user. * * @param string $prefix A unique prefix to add to the table alias * @param string $component The name of the component to fetch usages for. * @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid * @param int $userid The user to search. * @return \qubaid_join */ public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid): \qubaid_join { return new \qubaid_join(" JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield} AND {$prefix}_qu.component = :{$prefix}_usagecomponent JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id", "{$prefix}_qu.id", "{$prefix}_qas.userid = :{$prefix}_stepuserid", [ "{$prefix}_stepuserid" => $userid, "{$prefix}_usagecomponent" => $component, ]); } /** * Add the list of users who have rated in the specified constraints. * * @param userlist $userlist The userlist to add the users to. * @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql. * @param string $insql The SQL to use in a sub-select for the question_usages.id query. * @param array $params The params required for the insql. * @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that. */ public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params, ?int $contextid = null) { $sql = "SELECT {$prefix}_qas.userid FROM {question_attempt_steps} {$prefix}_qas JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id WHERE {$prefix}_qu.id IN ({$insql})"; if ($contextid) { $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid"; $params["{$prefix}_contextid"] = $contextid; } $userlist->add_from_sql('userid', $sql, $params); } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { global $CFG, $DB, $SITE; if (empty($contextlist)) { return; } // Use the Moodle XML Data format. // It is the only lossless format that we support. $format = "xml"; require_once($CFG->dirroot . "/question/format/{$format}/format.php"); // THe export system needs questions in a particular format. // The easiest way to fetch these is with get_questions_category() which takes the details of a question // category. // We fetch the root question category for each context and the get_questions_category function recurses to // After fetching them, we filter out any not created or modified by the requestor. $user = $contextlist->get_user(); $userid = $user->id; list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams); $classname = "qformat_{$format}"; foreach ($categories as $category) { $context = \context::instance_by_id($category->contextid); $questions = get_questions_category($category, true); $questions = array_filter($questions, function($question) use ($userid) { return ($question->createdby == $userid) || ($question->modifiedby == $userid); }, ARRAY_FILTER_USE_BOTH); if (empty($questions)) { continue; } $qformat = new $classname(); $qformat->setQuestions($questions); $qformat->setContexts([$context]); $qformat->setContexttofile(true); // We do not know which course this belongs to, and it's not actually used except in error, so use Site. $qformat->setCourse($SITE); $content = ''; if ($qformat->exportpreprocess()) { $content = $qformat->exportprocess(false); } $subcontext = [ get_string('questionbank', 'core_question'), ]; writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content); } } /** * Delete all data for all users in the specified context. * * @param \context $context The specific context to delete data for. * @throws \dml_exception */ public static function delete_data_for_all_users_in_context(\context $context) { global $DB; // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific // user. They are still exported in the list of a users data, but they are not removed. // The userid is instead anonymised. $sql = 'SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ?'; $questions = $DB->get_records_sql($sql, [$context->id]); foreach ($questions as $question) { $question->createdby = 0; $question->modifiedby = 0; $DB->update_record('question', $question); } } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { global $DB; // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific // user. They are still exported in the list of a users data, but they are not removed. // The userid is instead anonymised. list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $contextparams['createdby'] = $contextlist->get_user()->id; $questiondata = $DB->get_records_sql( "SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid {$contextsql} AND q.createdby = :createdby", $contextparams); foreach ($questiondata as $question) { $question->createdby = 0; $DB->update_record('question', $question); } list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $contextparams['modifiedby'] = $contextlist->get_user()->id; $questiondata = $DB->get_records_sql( "SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid {$contextsql} AND q.modifiedby = :modifiedby", $contextparams); foreach ($questiondata as $question) { $question->modifiedby = 0; $DB->update_record('question', $question); } } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { global $DB; // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific // user. They are still exported in the list of a users data, but they are not removed. // The userid is instead anonymised. $context = $userlist->get_context(); $userids = $userlist->get_userids(); list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); $params = ['contextid' => $context->id]; $questiondata = $DB->get_records_sql( "SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = :contextid AND q.createdby {$createdbysql}", $params + $createdbyparams); foreach ($questiondata as $question) { $question->createdby = 0; $DB->update_record('question', $question); } $questiondata = $DB->get_records_sql( "SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = :contextid AND q.modifiedby {$modifiedbysql}", $params + $modifiedbyparams); foreach ($questiondata as $question) { $question->modifiedby = 0; $DB->update_record('question', $question); } } } PK\ӏzz classes/local/bank/condition.phpnu[. namespace core_question\local\bank; use core\output\datafilter; /** * An abstract class for filtering/searching questions. * * @package core_question * @copyright 2013 Ray Morris * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class condition { /** @var int The default filter type */ const JOINTYPE_DEFAULT = datafilter::JOINTYPE_ANY; /** @var ?array Filter properties for this condition. */ public ?array $filter; /** @var string SQL fragment to add to the where clause. */ protected string $where = ''; /** @var array query param used in where. */ protected array $params = []; /** * Return title of the condition * * @return string title of the condition */ abstract public function get_title(); /** * Return the Javascript filter class to provide the UI for this condition. * * If left as null, this will use the default core/datafilter/filtertype class. Otherwise, override it to return * the full path to the Javascript module path for the class. * * @return ?string filter class */ public function get_filter_class() { return null; } /** * Extract the required filter from the provided question bank view and set the initial values. * * This will look for the filter matching {@see get_condition_key()} in the view's current filter parameter. * If the filter is not being initialised to display the question bank UI (for example, to resolve a list of questions matching * a set of filters), then the `$qbank` argument may be null, and any usage of it to set the initial filter state is skipped. * * @param ?view $qbank The question bank view the filter is being rendered for. This may only be used for setting the * initial state of the filter. */ public function __construct(?view $qbank = null) { if (is_null($qbank)) { return; } $this->filter = static::get_filter_from_list($qbank->get_pagevars('filter')); // Build where and params. [$this->where, $this->params] = $this->filter ? static::build_query_from_filter($this->filter) : ['', []]; } /** * Whether customisation is allowed. * * @return bool */ public function allow_custom() { return true; } /** * Whether multiple values are allowed . * * @return bool */ public function allow_multiple() { return true; } /** * Initial values of the condition * * @return array */ public function get_initial_values() { return []; } /** * Extra data specific to this condition. * * @return \stdClass */ public function get_filteroptions(): \stdClass { return (object)[]; } /** * Whether empty value is allowed * * @return bool */ public function allow_empty() { return true; } /** * Whether this filter is required - if so it cannot be removed from the list of filters. * * @return bool */ public function is_required(): bool { return false; } /** * Return this condition class * * @return string */ public function get_condition_class() { return get_class($this); } /** * Each condition will need a unique key to be identified and sequenced by the api. * Use a unique string for the condition identifier, use string directly, dont need to use language pack. * Using language pack might break the filter object for multilingual support. * * @return string */ abstract public static function get_condition_key(); /** * Return an SQL fragment to be ANDed into the WHERE clause to filter which questions are shown. * * @return string SQL fragment. Must use named parameters. */ public function where() { return $this->where; } /** * Return parameters to be bound to the above WHERE clause fragment. * @return array parameter name => value. */ public function params() { return $this->params; } /** * Display GUI for selecting criteria for this condition. Displayed when Show More is open. * * Compare display_options(), which displays always, whether Show More is open or not. * @return bool|string HTML form fragment * @deprecated since Moodle 4.0 MDL-72321 - please do not use this function any more. * @todo Final deprecation on Moodle 4.1 MDL-72572 */ public function display_options_adv() { debugging('Function display_options_adv() is deprecated, please use filtering objects', DEBUG_DEVELOPER); return false; } /** * Display GUI for selecting criteria for this condition. Displayed always, whether Show More is open or not. * * Compare display_options_adv(), which displays when Show More is open. * @return bool|string HTML form fragment * @deprecated since Moodle 4.0 MDL-72321 - please do not use this function any more. * @todo Final deprecation on Moodle 4.1 MDL-72572 */ public function display_options() { debugging('Function display_options() is deprecated, please use filtering objects', DEBUG_DEVELOPER); return false; } /** * Get the list of available joins for the filter. * * @return array */ public function get_join_list(): array { return [ datafilter::JOINTYPE_NONE, datafilter::JOINTYPE_ANY, datafilter::JOINTYPE_ALL, ]; } /** * Method to be overridden in condition classes to filter out anything invalid from the filterconditions array. * * This can be applied anywhere where the $filterconditions array exists, to let condition plugins remove elements * from the array, based on their own internal logic/validation. For example, this is used on the * /mod/quiz/editrandom.php page to filter out question categories which no longer exist, which previously * broke the editrandom page. * * @param array $filterconditions * @return array */ public function filter_invalid_values(array $filterconditions): array { return $filterconditions; } /** * Given an array of filters, pick the entry that matches the condition key and return it. * * @param array $filters Array of filters, keyed by condition. * @return ?array The filter that matches this condition */ public static function get_filter_from_list(array $filters): ?array { return $filters[static::get_condition_key()] ?? null; } /** * Return the SQL WHERE condition and parameters to be ANDed with other filter conditions. * * The $filter parameter recieves an array with a 'values' key, containing an array of the filter values selected, * and a 'jointype' key containing the selected join type. * * @param array $filter filter properties * @return array ['SQL where condition', ['param1' => 'value1', 'param2' => 'value2', ...]] */ abstract public static function build_query_from_filter(array $filter): array; } PK\p." " &classes/local/bank/version_options.phpnu[. namespace core_question\local\bank; /** * Helper methods for question version options. * * @package core_question * @copyright 2024 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Conn Warwicker */ class version_options { /** * Get the version options for display in dropdown menu * @param int $questionid * @return array * @throws \coding_exception */ public static function get_version_menu_options(int $questionid): array { $versions = self::get_version_options($questionid); $latestversion = reset($versions); $return = []; // Add the "always latest" option to the beginning. $return[0] = get_string('alwayslatest', 'question'); foreach ($versions as $version) { if ($version->version === $latestversion->version) { $value = get_string('versioninfolatestshort', 'question', $version->version); } else { $value = get_string('question_versionshort', 'question', $version->version); } $return[$version->version] = $value; } return $return; } /** * Get the available versions of a question where one of the version has the given question id. * * @param int $questionid id of a question. * @return stdClass[] other versions of this question. Each object has fields versionid, * version and questionid. Array is returned most recent version first. */ public static function get_version_options(int $questionid): array { global $DB; return $DB->get_records_sql(" SELECT allversions.id AS versionid, allversions.version, allversions.questionid FROM {question_versions} allversions WHERE allversions.questionbankentryid = ( SELECT givenversion.questionbankentryid FROM {question_versions} givenversion WHERE givenversion.questionid = ? ) AND allversions.status <> ? ORDER BY allversions.version DESC ", [$questionid, question_version_status::QUESTION_STATUS_DRAFT]); } } PK\ua 'classes/local/bank/edit_menu_column.phpnu[. /** * A question bank column which gathers together all the actions into a menu. * * @package core_question * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; use \core\plugininfo\qbank; /** * A question bank column which gathers together all the actions into a menu. * * This question bank column, if added to the question bank, will * replace all of the other columns which implement the * {@see menu_action_column_base} interface and replace them with a single * column containing an Edit menu. * * @copyright 2019 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_menu_column extends column_base { public function get_title() { return get_string('actions'); } public function get_name(): string { return 'editmenu'; } protected function display_content($question, $rowclasses): void { global $OUTPUT; $actions = $this->qbank->get_question_actions(); $menu = new \action_menu(); $menu->set_menu_trigger(get_string('edit')); $menu->set_boundary('window'); foreach ($actions as $action) { $action = $action->get_action_menu_link($question); if ($action) { $menu->add($action); } } $qtypeactions = \question_bank::get_qtype($question->qtype, false) ->get_extra_question_bank_actions($question); foreach ($qtypeactions as $action) { $menu->add($action); } echo $OUTPUT->render($menu); } public function get_required_fields(): array { return ['q.qtype']; } /** * Get menuable actions. * * @return menu_action_column_base Menuable actions. */ public function get_actions(): array { return $this->actions; } public function get_extra_classes(): array { return ['pe-3']; } } PK\@classes/local/bank/view.phpnu[. /** * Class to print a view of the question bank. * * @package core_question * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/editlib.php'); use coding_exception; use core\output\datafilter; use core_question\local\bank\condition; use core_question\local\statistics\statistics_bulk_loader; use core_question\output\question_bank_filter_ui; use core_question\local\bank\column_manager_base; use qbank_managecategories\category_condition; /** * This class prints a view of the question bank. * * including * + Some controls to allow users to to select what is displayed. * + A list of questions as a table. * + Further controls to do things with the questions. * * This class gives a basic view, and provides plenty of hooks where subclasses * can override parts of the display. * * The list of questions presented as a table is generated by creating a list of * core_question\bank\column objects, one for each 'column' to be displayed. These * manage * + outputting the contents of that column, given a $question object, but also * + generating the right fragments of SQL to ensure the necessary data is present, * and sorted in the right order. * + outputting table headers. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class view { /** * Maximum number of sorts allowed. */ const MAX_SORTS = 3; /** * @var \moodle_url base URL for the current page. Used as the * basis for making URLs for actions that reload the page. */ protected $baseurl; /** * @var \moodle_url used as a basis for URLs that edit a question. */ protected $editquestionurl; /** * @var \core_question\local\bank\question_edit_contexts */ public $contexts; /** * @var object|\cm_info|null if we are in a module context, the cm. */ public $cm; /** * @var object the course we are within. */ public $course; /** * @var column_base[] these are all the 'columns' that are * part of the display. Array keys are the class name. */ protected $requiredcolumns; /** * @var question_action_base[] these are all the actions that can be displayed in a question's action menu. * * Array keys are the class name. */ protected $questionactions; /** * @var column_base[] these are the 'columns' that are * actually displayed as a column, in order. Array keys are the class name. */ protected $visiblecolumns; /** * @var column_base[] these are the 'columns' that are * common to the question bank. */ protected $corequestionbankcolumns; /** * @var column_base[] these are the 'columns' that are * actually displayed as an additional row (e.g. question text), in order. * Array keys are the class name. */ protected $extrarows; /** * @var array list of column class names for which columns to sort on. */ protected $sort; /** * @var int page size to use (when we are not showing all questions). */ protected $pagesize = DEFAULT_QUESTIONS_PER_PAGE; /** * @var int|null id of the a question to highlight in the list (if present). */ protected $lastchangedid; /** * @var string SQL to count the number of questions matching the current * search conditions. */ protected $countsql; /** * @var string SQL to actually load the question data to display. */ protected $loadsql; /** * @var array params used by $countsql and $loadsql (which currently must be the same). */ protected $sqlparams; /** * @var ?array Stores all the average statistics that this question bank view needs. * * This field gets initialised in {@see display_question_list()}. It is a two dimensional * $this->loadedstatistics[$questionid][$fieldname] = $average value of that statistics for that question. * Column classes in qbank plugins can access these values using {@see get_aggregate_statistic()}. */ protected $loadedstatistics = null; /** * @var condition[] search conditions. */ protected $searchconditions = []; /** * @var string url of the new question page. */ public $returnurl; /** * @var bulk_action_base[] $bulkactions bulk actions for the api. */ public $bulkactions = []; /** * @var int|null Number of questions. */ protected $totalcount = null; /** * @var array Parameters for the page URL. */ protected $pagevars = []; /** * @var plugin_features_base[] $plugins Plugin feature objects for all enabled qbank plugins. */ protected $plugins = []; /** * @var string $component the component the api is used from. */ public $component = 'core_question'; /** * @var string $callback name of the callback for the api call via filter js. */ public $callback = 'question_data'; /** * @var array $extraparams extra parameters for the extended apis. */ public $extraparams = []; /** * @var column_manager_base $columnmanager The column manager, can be overridden by plugins. */ protected $columnmanager; /** * Constructor for view. * * @param \core_question\local\bank\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param object $course course settings * @param null $cm (optional) activity settings. * @param array $params the parameters required to initialize the api. * @param array $extraparams any extra parameters required by a particular view class. */ public function __construct($contexts, $pageurl, $course, $cm = null, $params = [], $extraparams = []) { $this->contexts = $contexts; $this->baseurl = $pageurl; $this->course = $course; $this->cm = $cm; $this->extraparams = $extraparams; // Add the default qperpage to extra params array so we can switch back and forth between it and "all". $this->extraparams['defaultqperpage'] = $this->pagesize; $this->extraparams['maxqperpage'] = MAXIMUM_QUESTIONS_PER_PAGE; if ($cm === null) { debugging('Passing $cm to the view constructor is now required.', DEBUG_DEVELOPER); } // Default filter condition. if (!isset($params['filter']) && isset($params['cat'])) { $params['filter'] = filter_condition_manager::get_default_filter($params['cat']); $params['jointype'] = datafilter::JOINTYPE_ALL; } if (!empty($params['filter'])) { $params['filter'] = filter_condition_manager::unpack_filteroptions_param($params['filter']); } if (isset($params['filter']['jointype'])) { $params['jointype'] = $params['filter']['jointype']; unset($params['filter']['jointype']); } // Create the url of the new question page to forward to. $this->returnurl = $pageurl->out_as_local_url(false); $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', ['returnurl' => $this->returnurl]); $this->editquestionurl->param('cmid', $this->cm->id); $this->lastchangedid = clean_param($pageurl->param('lastchanged'), PARAM_INT); $this->init_plugins(); $this->init_column_manager(); // Possibly the heading part can be removed. $this->set_pagevars($params); $this->init_columns($this->wanted_columns(), $this->heading_column()); $this->init_question_actions(); $this->init_sort(); $this->init_bulk_actions(); // Record that this question bank has been used. question_bank_helper::add_bank_context_to_recently_viewed($contexts->lowest()); } /** * Get an array of plugin features objects for all enabled qbank plugins. * * @return void */ protected function init_plugins(): void { $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); foreach ($plugins as $componentname => $pluginclass) { if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) { continue; } $this->plugins[$componentname] = new $pluginclass(); } // Sort plugin list by component name. ksort($this->plugins); } /** * Allow qbank plugins to override the column manager. * * If multiple qbank plugins define a column manager, this will pick the first one sorted alphabetically. * * @return void */ protected function init_column_manager(): void { $this->columnmanager = new column_manager_base(); foreach ($this->plugins as $plugin) { if ($columnmanager = $plugin->get_column_manager()) { $this->columnmanager = $columnmanager; break; } } } /** * Initialize bulk actions. */ protected function init_bulk_actions(): void { foreach ($this->plugins as $componentname => $plugin) { $bulkactions = $plugin->get_bulk_actions($this); if (!is_array($bulkactions)) { debugging("The method {$componentname}::get_bulk_actions() must return an " . "array of bulk actions instead of a single bulk action. " . "Please update your implementation of get_bulk_actions() to return an array. " . "Check out the qbank_bulkmove plugin for a working example.", DEBUG_DEVELOPER); $bulkactions = [$bulkactions]; } foreach ($bulkactions as $bulkactionobject) { $this->bulkactions[$bulkactionobject->get_key()] = $bulkactionobject; } } } /** * @deprecated Since Moodle 4.3 */ #[\core\attribute\deprecated( 'create a qbank plugin and implement a filter object', since: '4.3', mdl: 'MDL-72321', final: true )] protected function init_search_conditions(): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Initialise list of menu actions for enabled question bank plugins. * * Menu action objects are stored in $this->menuactions, keyed by class name. * * @return void */ protected function init_question_actions(): void { $this->questionactions = []; foreach ($this->plugins as $plugin) { $menuactions = $plugin->get_question_actions($this); foreach ($menuactions as $menuaction) { $this->questionactions[$menuaction::class] = $menuaction; if ($menuaction->get_menu_position() === question_action_base::MENU_POSITION_NOT_SET) { debugging('Question bank actions must define the get_menu_position method. ' . $menuaction::class . ' does not.', DEBUG_DEVELOPER); } } } // Sort according to each action's desired position. // Note, we are relying on the sort to be stable for // equal values of get_menu_position. uasort( $this->questionactions, function (question_action_base $a, question_action_base $b) { return $a->get_menu_position() <=> $b->get_menu_position(); }, ); } /** * Get class for each question bank columns. * * @return array */ protected function get_question_bank_plugins(): array { $questionbankclasscolumns = []; $newpluginclasscolumns = []; $corequestionbankcolumns = [ 'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column', 'core_question\local\bank\edit_menu_column' . column_base::ID_SEPARATOR . 'edit_menu_column', ]; foreach ($corequestionbankcolumns as $columnid) { [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2); if (class_exists($columnclass)) { $questionbankclasscolumns[$columnid] = $columnclass::from_column_name($this, $columnname); } } foreach ($this->plugins as $plugin) { $plugincolumnobjects = $plugin->get_question_columns($this); foreach ($plugincolumnobjects as $columnobject) { $columnid = $columnobject->get_column_id(); foreach ($corequestionbankcolumns as $corequestionbankcolumn) { // Check if it has custom preference selector to view/hide. if ($columnobject->has_preference()) { if (!$columnobject->get_preference()) { continue; } } if ($corequestionbankcolumn === $columnid) { $questionbankclasscolumns[$columnid] = $columnobject; } else { // Any community plugin for column/action. $newpluginclasscolumns[$columnid] = $columnobject; } } } } // New plugins added at the end of the array, will change in sorting feature. foreach ($newpluginclasscolumns as $key => $newpluginclasscolumn) { $questionbankclasscolumns[$key] = $newpluginclasscolumn; } $questionbankclasscolumns = $this->columnmanager->get_sorted_columns($questionbankclasscolumns); $questionbankclasscolumns = $this->columnmanager->set_columns_visibility($questionbankclasscolumns); // Mitigate the error in case of any regression. foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) { if (!is_object($questionbankclasscolumn) || !$questionbankclasscolumn->isvisible) { unset($questionbankclasscolumns[$shortname]); } } return $questionbankclasscolumns; } /** * Loads all the available columns. * * @return array */ protected function wanted_columns(): array { $this->requiredcolumns = []; $questionbankcolumns = $this->get_question_bank_plugins(); foreach ($questionbankcolumns as $classobject) { if (empty($classobject) || !($classobject instanceof \core_question\local\bank\column_base)) { continue; } $this->requiredcolumns[$classobject->get_column_name()] = $classobject; } return $this->requiredcolumns; } /** * Check a column object from its name and get the object for sort. * * @param string $columnname */ protected function get_column_type($columnname) { if (empty($this->requiredcolumns[$columnname])) { $this->requiredcolumns[$columnname] = new $columnname($this); } } /** * Specify the column heading * * @return string Column name for the heading */ protected function heading_column(): string { return 'qbank_viewquestionname\viewquestionname_column_helper'; } /** * Initializing table columns * * @param array $wanted Collection of column names * @param string $heading The name of column that is set as heading */ protected function init_columns($wanted, $heading = ''): void { // Now split columns into real columns and rows. $this->visiblecolumns = []; $this->extrarows = []; foreach ($wanted as $column) { if ($column->is_extra_row()) { $this->extrarows[$column->get_column_name()] = $column; } else { // Only add columns which are visible. if ($column->isvisible) { $this->visiblecolumns[$column->get_column_name()] = $column; } } } if (array_key_exists($heading, $this->requiredcolumns)) { $this->requiredcolumns[$heading]->set_as_heading(); } } /** * Checks if the column included in the output. * * @param string $colname a column internal name. * @return bool is this column included in the output? */ public function has_column($colname): bool { return isset($this->visiblecolumns[$colname]); } /** * Get the count of the columns. * * @return int The number of columns in the table. */ public function get_column_count(): int { return count($this->visiblecolumns); } /** * Get course id. * @return mixed */ public function get_courseid() { return $this->course->id; } /** * Initialise sorting. */ protected function init_sort(): void { $this->sort = []; $sorts = optional_param_array('sortdata', [], PARAM_INT); if (empty($sorts)) { $sorts = $this->get_pagevars('sortdata'); } if (empty($sorts)) { $sorts = $this->default_sort(); } $sorts = array_slice($sorts, 0, self::MAX_SORTS); foreach ($sorts as $sortname => $sortorder) { // Deal with subsorts. [$colname] = $this->parse_subsort($sortname); $this->get_column_type($colname); } $this->sort = $sorts; } /** * Deal with a sort name of the form columnname, or colname_subsort by * breaking it up, validating the bits that are present, and returning them. * If there is no subsort, then $subsort is returned as ''. * * @param string $sort the sort parameter to process. * @return array [$colname, $subsort]. */ protected function parse_subsort($sort): array { // Do the parsing. if (strpos($sort, '-') !== false) { [$colname, $subsort] = explode('-', $sort, 2); } else { $colname = $sort; $subsort = ''; } $colname = str_replace('__', '\\', $colname); // Validate the column name. $this->get_column_type($colname); $column = $this->requiredcolumns[$colname]; if (!isset($column) || !$column->is_sortable()) { $this->baseurl->remove_params('sortdata'); throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $colname); } // Validate the subsort, if present. if ($subsort) { $subsorts = $column->is_sortable(); if (!is_array($subsorts) || !isset($subsorts[$subsort])) { throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $sort); } } return [$colname, $subsort]; } /** * Sort to parameters. * * @param array $sorts * @return array */ protected function sort_to_params($sorts): array { $params = []; foreach ($sorts as $sortname => $sortorder) { $params['sortdata[' . $sortname . ']'] = $sortorder; } return $params; } /** * Get the default sort columns for this view of the question bank. * * The array keys in the return value need be the standard keys used * to represent sorts. That is [plugin_frankenstyle]__[class_name] and then * and optional bit like '-name' if the column supports muliple ways of sorting. * * The array values should be one of the constants SORT_ASC or SORT_DESC for the default order. * * Therefore, the default implementation below is a good example. * * @return int[] sort key => SORT_ASC or SORT_DESC. */ protected function default_sort(): array { $defaultsort = []; if (class_exists('\\qbank_viewquestiontype\\question_type_column')) { $defaultsort['qbank_viewquestiontype__question_type_column'] = SORT_ASC; } if (class_exists('\\qbank_viewquestionname\\question_name_idnumber_tags_column')) { $defaultsort['qbank_viewquestionname__question_name_idnumber_tags_column-name'] = SORT_ASC; } return $defaultsort; } /** * Gets the primary sort order according to the default sort. * * @param string $sortname a column or column_subsort name. * @return int the current sort order for this column -1, 0, 1 */ public function get_primary_sort_order($sortname): int { $order = reset($this->sort); $primarysort = key($this->sort); if ($sortname == $primarysort) { return $order; } return 0; } /** * Get a URL to redisplay the page with a new sort for the question bank. * * @param string $sortname the column, or column_subsort to sort on. * @param bool $newsortreverse whether to sort in reverse order. * @return string The new URL. */ public function new_sort_url($sortname, $newsortreverse): string { // Tricky code to add the new sort at the start, removing it from where it was before, if it was present. $newsort = array_reverse($this->sort); if (isset($newsort[$sortname])) { unset($newsort[$sortname]); } $newsort[$sortname] = $newsortreverse ? SORT_DESC : SORT_ASC; $newsort = array_reverse($newsort); if (count($newsort) > self::MAX_SORTS) { $newsort = array_slice($newsort, 0, self::MAX_SORTS, true); } return $this->baseurl->out(true, $this->sort_to_params($newsort)); } /** * Return an array 'table_alias' => 'JOIN clause' to bring in any data that * the core view requires. * * @return string[] 'table_alias' => 'JOIN clause' */ protected function get_required_joins(): array { return [ 'qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id', 'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid', 'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid', ]; } /** * Return an array of fields for any data that the core view requires. * * Use table alias 'q' for the question table, or one of the ones from get_required_joins. * Every field requested must specify a table prefix. * * @return string[] fields required. */ protected function get_required_fields(): array { return [ 'q.id', 'q.qtype', 'q.createdby', 'qc.id as categoryid', 'qc.contextid', 'qv.status', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid', ]; } /** * Gather query requirements from view component objects. * * This will take the required fields and joins for this view, and combine them with those for all active view components. * Fields will be de-duplicated in multiple components require the same field. * Joins will be de-duplicated if the alias and join clause match exactly. * * @throws \coding_exception If two components attempt to use the same alias for different joins. * @param view_component[] $viewcomponents List of component objects included in the current view * @return array [$fields, $joins] SQL fields and joins to add to the query. */ protected function get_component_requirements(array $viewcomponents): array { $fields = $this->get_required_fields(); $joins = $this->get_required_joins(); if (!empty($viewcomponents)) { foreach ($viewcomponents as $viewcomponent) { $extrajoins = $viewcomponent->get_extra_joins(); foreach ($extrajoins as $prefix => $join) { if (isset($joins[$prefix]) && $joins[$prefix] != $join) { throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]); } $joins[$prefix] = $join; } $fields = array_merge($fields, $viewcomponent->get_required_fields()); } } return [array_unique($fields), $joins]; } /** * Create the SQL query to retrieve the indicated questions, based on * \core_question\bank\search\condition filters. */ protected function build_query(): void { // Get the required tables and fields. [$fields, $joins] = $this->get_component_requirements(array_merge($this->requiredcolumns, $this->questionactions)); // Build the order by clause. $sorts = []; foreach ($this->sort as $sortname => $sortorder) { [$colname, $subsort] = $this->parse_subsort($sortname); $sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort); } $this->sqlparams = []; $conditions = []; $showhiddenquestion = true; // Build the where clause. foreach ($this->searchconditions as $searchcondition) { // This is nasty hack to check if the filter is using "Show hidden question" option. if (array_key_exists('hidden_condition', $searchcondition->params())) { $showhiddenquestion = false; } if ($searchcondition->where()) { $conditions[] = '((' . $searchcondition->where() .'))'; } if ($searchcondition->params()) { $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params()); } } $extracondition = ''; if (!$showhiddenquestion) { // If Show hidden question option is off, then we need get the latest version that is not hidden. $extracondition = ' AND v.status <> :hiddenstatus'; $this->sqlparams = array_merge($this->sqlparams, ['hiddenstatus' => question_version_status::QUESTION_STATUS_HIDDEN]); } $latestversion = "qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id $extracondition)"; // Get higher level filter condition. $jointype = isset($this->pagevars['jointype']) ? (int)$this->pagevars['jointype'] : condition::JOINTYPE_DEFAULT; $nonecondition = ($jointype === datafilter::JOINTYPE_NONE) ? ' NOT ' : ''; $separator = ($jointype === datafilter::JOINTYPE_ALL) ? ' AND ' : ' OR '; // Build the SQL. $sql = ' FROM {question} q ' . implode(' ', $joins); $sql .= ' WHERE q.parent = 0 AND ' . $latestversion; if (!empty($conditions)) { $sql .= ' AND ' . $nonecondition . ' ( '; $sql .= implode($separator, $conditions); $sql .= ' ) '; } $this->countsql = 'SELECT count(1)' . $sql; $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts); } /** * Get the number of questions. * * @return int */ public function get_question_count(): int { global $DB; if (is_null($this->totalcount)) { $this->totalcount = $DB->count_records_sql($this->countsql, $this->sqlparams); } return $this->totalcount; } /** * Load the questions we need to display. * * @return \moodle_recordset questionid => data about each question. */ protected function load_page_questions(): \moodle_recordset { global $DB; // Load the questions based on the page we are on. $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, (int)$this->pagevars['qpage'] * (int)$this->pagevars['qperpage'], (int)$this->pagevars['qperpage']); if (!$questions->valid()) { $questions->close(); // No questions on this page. Reset to the nearest page that contains questions. $this->pagevars['qpage'] = max(0, ceil($this->totalcount / (int)$this->pagevars['qperpage']) - 1); $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, (int)$this->pagevars['qpage'] * (int)$this->pagevars['qperpage'], (int)$this->pagevars['qperpage']); } return $questions; } /** * Returns the base url. * * @return \moodle_url */ public function base_url(): \moodle_url { return $this->baseurl; } /** * Get the URL for editing a question as a moodle url. * * @param int $questionid the question id. * @return \moodle_url the URL, HTML-escaped. */ public function edit_question_moodle_url($questionid) { return new \moodle_url($this->editquestionurl, ['id' => $questionid]); } /** * Get the URL for editing a question as a HTML-escaped string. * * @param int $questionid the question id. * @return string the URL, HTML-escaped. */ public function edit_question_url($questionid) { return $this->edit_question_moodle_url($questionid)->out(); } /** * Get the URL for duplicating a question as a moodle url. * * @param int $questionid the question id. * @return \moodle_url the URL. */ public function copy_question_moodle_url($questionid) { return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]); } /** * Get the URL for duplicating a given question. * @param int $questionid the question id. * @return string the URL, HTML-escaped. */ public function copy_question_url($questionid) { return $this->copy_question_moodle_url($questionid)->out(); } /** * Get the context we are displaying the question bank for. * @return \context context object. */ public function get_most_specific_context(): \context { return $this->contexts->lowest(); } /** * Get fields from the pagevars array. * * If a field is specified, that particlar pagevars field will be returned. Otherwise the entire array will be returned. * * If a field is specified but it does not exist, null will be returned. * * @param ?string $field * @return mixed */ public function get_pagevars(?string $field = null): mixed { if (is_null($field)) { return $this->pagevars; } else { return $this->pagevars[$field] ?? null; } } /** * Set the pagevars property with the provided array. * * @param array $pagevars */ public function set_pagevars(array $pagevars): void { $this->pagevars = $pagevars; } /** * Shows the question bank interface. */ public function display(): void { $editcontexts = $this->contexts->having_one_edit_tab_cap('questions'); echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter', [ 'data-component' => 'core_question', 'data-callback' => 'display_question_bank', 'data-contextid' => $editcontexts[array_key_last($editcontexts)]->id, ]); // Show the filters and search options. $this->wanted_filters(); // Continues with list of questions. $this->display_question_list(); echo \html_writer::end_div(); } /** * The filters for the question bank. */ public function wanted_filters(): void { global $OUTPUT; [, $contextid] = explode(',', $this->pagevars['cat']); $catcontext = \context::instance_by_id($contextid); // Category selection form. $this->display_question_bank_header(); // Add search conditions. $this->add_standard_search_conditions(); // Render the question bank filters. $additionalparams = [ 'perpage' => $this->pagevars['qperpage'], ]; $filter = new question_bank_filter_ui($catcontext, $this->searchconditions, $additionalparams, $this->component, $this->callback, static::class, 'qbank-table', $this->cm?->id, $this->pagevars, $this->extraparams); echo $OUTPUT->render($filter); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated( 'question/bank/managecategories/classes/category_confition.php', since: '4.3', mdl: 'MDL-72321', final: true )] protected function print_choose_category_message(): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated( 'question/bank/managecategories/classes/category_confition.php', since: '4.3', mdl: 'MDL-72321', final: true )] protected function get_current_category($categoryandcontext) { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('filtering objects', since: '4.3', mdl: 'MDL-72321', final: true)] protected function display_options_form($showquestiontext): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('filtering objects', since: '4.3', mdl: 'MDL-72321', final: true)] protected function display_advanced_search_form($advancedsearch): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('filtering objects', since: '4.3', mdl: 'MDL-72321', final: true)] protected function display_showtext_checkbox($showquestiontext): void { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Display the header element for the question bank. */ protected function display_question_bank_header(): void { global $OUTPUT; echo $OUTPUT->heading(get_string('questionbank', 'question'), 2); } /** * Does the current view allow adding new questions? * * @return bool True if the view supports adding new questions. */ public function allow_add_questions(): bool { return true; } /** * Output the question bank controls for each plugin. * * Controls will be output in the order defined by the array keys returned from * {@see plugin_features_base::get_question_bank_controls}. If more than one plugin defines a control in the same position, * they will placed after one another based on the alphabetical order of the plugins. * * @param \core\context $context The current context, for permissions checks. * @param int $categoryid The current question category. */ protected function get_plugin_controls(\core\context $context, int $categoryid): string { global $OUTPUT; $orderedcontrols = []; foreach ($this->plugins as $plugin) { $plugincontrols = $plugin->get_question_bank_controls($this, $context, $categoryid); foreach ($plugincontrols as $position => $plugincontrol) { if (!array_key_exists($position, $orderedcontrols)) { $orderedcontrols[$position] = []; } $orderedcontrols[$position][] = $plugincontrol; } } ksort($orderedcontrols); $output = ''; foreach ($orderedcontrols as $controls) { foreach ($controls as $control) { $output .= $OUTPUT->render($control); } } return $OUTPUT->render_from_template('core_question/question_bank_controls', ['controls' => $output]); } /** * Prints the table of questions in a category with interactions */ public function display_question_list(): void { // This function can be moderately slow with large question counts and may time out. // We probably do not want to raise it to unlimited, so randomly picking 5 minutes. // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc). \core_php_time_limit::raise(300); raise_memory_limit(MEMORY_EXTRA); [$categoryid, $contextid] = category_condition::validate_category_param($this->pagevars['cat']); $catcontext = \context::instance_by_id($contextid); // Update the question in the list with correct category context when we have selected category filter. if (isset($this->pagevars['filter']['category']['values'])) { $categoryid = $this->pagevars['filter']['category']['values'][0]; foreach ($this->contexts->all() as $context) { if ((int) $context->instanceid === (int) $categoryid) { $catcontext = $context; break; } } } echo \html_writer::start_tag( 'div', [ 'id' => 'questionscontainer', 'data-component' => $this->component, 'data-callback' => $this->callback, 'data-contextid' => $this->get_most_specific_context()->id, ] ); echo $this->get_plugin_controls($catcontext, $categoryid); $questions = $this->load_questions(); // This html will be refactored in the bulk actions implementation. echo \html_writer::start_tag('form', ['action' => $this->baseurl, 'method' => 'post', 'id' => 'questionsubmit']); echo \html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset', 'style' => "display: block;"]); echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'addonpage']); echo \html_writer::input_hidden_params($this->baseurl); $filtercondition = json_encode($this->get_pagevars()); // Embeded filterconditon into the div. echo \html_writer::start_tag('div', ['class' => 'categoryquestionscontainer', 'data-filtercondition' => $filtercondition]); if ($this->totalcount > 0) { // Bulk load any required statistics. $this->load_required_statistics($questions); // Bulk load any extra data that any column requires. foreach ($this->requiredcolumns as $column) { $column->load_additional_data($questions); } $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']); } echo \html_writer::end_tag('div'); $this->display_bottom_controls($catcontext); echo \html_writer::end_tag('fieldset'); echo \html_writer::end_tag('form'); echo \html_writer::end_tag('div'); } /** * Work out the list of all the required statistics fields for this question bank view. * * This gathers all the required fields from all columns, so they can all be loaded at once. * * @return string[] the names of all the required fields for this question bank view. */ protected function determine_required_statistics(): array { $requiredfields = []; foreach ($this->requiredcolumns as $column) { $requiredfields = array_merge($requiredfields, $column->get_required_statistics_fields()); } return array_unique($requiredfields); } /** * Load the aggregate statistics that all the columns require. * * @param \stdClass[] $questions the questions that will be displayed indexed by question id. */ protected function load_required_statistics(array $questions): void { $requiredstatistics = $this->determine_required_statistics(); $this->loadedstatistics = statistics_bulk_loader::load_aggregate_statistics( array_keys($questions), $requiredstatistics); } /** * Get the aggregated value of a particular statistic for a particular question. * * You can only get values for the questions on the current page of the question bank view, * and only if you declared the need for this statistic in the get_required_statistics_fields() * method of your question bank column. * * @param int $questionid the id of a question * @param string $fieldname the name of a statistics field, e.g. 'facility'. * @return float|null the average (across all users) of this statistic for this question. * Null if the value is not available right now. */ public function get_aggregate_statistic(int $questionid, string $fieldname): ?float { if (!array_key_exists($questionid, $this->loadedstatistics)) { throw new \coding_exception('Question ' . $questionid . ' is not on the current page of ' . 'this question bank view, so its statistics are not available.'); } // Must be array_key_exists, not isset, because we care about null values. if (!array_key_exists($fieldname, $this->loadedstatistics[$questionid])) { throw new \coding_exception('Statistics field ' . $fieldname . ' was not requested by any ' . 'question bank column in this view, so it is not available.'); } return $this->loadedstatistics[$questionid][$fieldname]; } /** * Display the top pagination bar. * * @param object $pagination * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function display_top_pagnation($pagination): void { debugging( 'Function display_top_pagnation() is deprecated, please use display_questions() for ajax based pagination.', DEBUG_DEVELOPER ); global $PAGE; $displaydata = [ 'pagination' => $pagination ]; echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata); } /** * Display bottom pagination bar. * * @param string $pagination * @param int $totalnumber * @param int $perpage * @param \moodle_url $pageurl * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function display_bottom_pagination($pagination, $totalnumber, $perpage, $pageurl): void { debugging( 'Function display_bottom_pagination() is deprecated, please use display_questions() for ajax based pagination.', DEBUG_DEVELOPER ); global $PAGE; $displaydata = array ( 'extraclasses' => 'pagingbottom', 'pagination' => $pagination, 'biggertotal' => true, ); if ($totalnumber > $this->pagesize) { $displaydata['showall'] = true; if ($perpage == $this->pagesize) { $url = new \moodle_url($pageurl, array_merge($pageurl->params(), ['qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE])); if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) { $displaydata['totalnumber'] = MAXIMUM_QUESTIONS_PER_PAGE; } else { $displaydata['biggertotal'] = false; $displaydata['totalnumber'] = $totalnumber; } } else { $url = new \moodle_url($pageurl, array_merge($pageurl->params(), ['qperpage' => $this->pagesize])); $displaydata['totalnumber'] = $this->pagesize; } $displaydata['showallurl'] = $url; } echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata); } /** * Display the controls at the bottom of the list of questions. * * @param \context $catcontext The context of the category being displayed. */ protected function display_bottom_controls(\context $catcontext): void { $caneditall = has_capability('moodle/question:editall', $catcontext); $canuseall = has_capability('moodle/question:useall', $catcontext); $canmoveall = has_capability('moodle/question:moveall', $catcontext); if ($caneditall || $canmoveall || $canuseall) { global $PAGE; $bulkactiondatas = []; $params = $this->base_url()->params(); $returnurl = new \moodle_url($this->base_url(), ['filter' => json_encode($this->pagevars['filter'])]); $params['returnurl'] = $returnurl; foreach ($this->bulkactions as $key => $action) { // Check capabilities. $capcount = 0; foreach ($action->get_bulk_action_capabilities() as $capability) { if (has_capability($capability, $catcontext)) { $capcount ++; } } // At least one cap need to be there. if ($capcount === 0) { unset($this->bulkactions[$key]); continue; } $actiondata = new \stdClass(); $actiondata->actionname = $action->get_bulk_action_title(); $actiondata->actionkey = $key; $actiondata->actionurl = new \moodle_url($action->get_bulk_action_url(), $params); $bulkactiondata[] = $actiondata; $bulkactiondatas ['bulkactionitems'] = $bulkactiondata; } // We dont need to show this section if none of the plugins are enabled. if (!empty($bulkactiondatas)) { echo $PAGE->get_renderer('core_question', 'bank')->render_bulk_actions_ui($bulkactiondatas); } } } /** * Give each bulk action a chance to load its own javascript module. * @return void */ public function init_bulk_actions_js(): void { foreach ($this->bulkactions as $action) { $action->initialise_javascript(); } } /** * Display the questions. * * @param array $questions */ public function display_questions($questions, $page = 0, $perpage = DEFAULT_QUESTIONS_PER_PAGE): void { global $OUTPUT; if (!isset($this->pagevars['filter']['category'])) { // We must have a category filter selected. echo $OUTPUT->render_from_template('qbank_managecategories/choose_category', []); return; } // Pagination. $pageingurl = new \moodle_url($this->base_url()); // TODO MDL-82312: it really should not be necessary to set filter here, and not like this. // This should be handled in baseurl, but it isn't so we do this so Moodle basically works for now. $pageingurl->param('filter', json_encode($this->pagevars['filter'])); $pagingbar = new \paging_bar($this->totalcount, $page, $perpage, $pageingurl); $pagingbar->pagevar = 'qpage'; echo $OUTPUT->render($pagingbar); // Table of questions. echo \html_writer::start_tag('div', ['class' => 'question_table', 'id' => 'question_table']); $this->print_table($questions); echo \html_writer::end_tag('div'); echo $OUTPUT->render($pagingbar); } /** * Load the questions according to the search conditions. * * @return array */ public function load_questions() { $this->build_query(); $this->get_question_count(); $questionsrs = $this->load_page_questions(); $questions = []; foreach ($questionsrs as $question) { if (!empty($question->id)) { $questions[$question->id] = $question; } } $questionsrs->close(); foreach ($this->requiredcolumns as $name => $column) { $column->load_additional_data($questions); } return $questions; } /** * Prints the actual table with question. * * @param array $questions */ protected function print_table($questions): void { // Start of the table. echo \html_writer::start_tag('table', [ 'id' => 'categoryquestions', 'class' => 'question-bank-table table table-hover table-striped generaltable', 'data-defaultsort' => json_encode($this->sort), ]); // Prints the table header. echo \html_writer::start_tag('thead'); echo \html_writer::start_tag('tr', ['class' => 'qbank-column-list']); $this->print_table_headers(); echo \html_writer::end_tag('tr'); echo \html_writer::end_tag('thead'); // Prints the table row or content. echo \html_writer::start_tag('tbody'); $rowcount = 0; foreach ($questions as $question) { $this->print_table_row($question, $rowcount); $rowcount += 1; } echo \html_writer::end_tag('tbody'); // End of the table. echo \html_writer::end_tag('table'); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('print_table()', since: '4.3', mdl: 'MDL-72321', final: true)] protected function start_table() { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * @deprecated since Moodle 4.3 MDL-72321 */ #[\core\attribute\deprecated('print_table()', since: '4.3', mdl: 'MDL-72321', final: true)] protected function end_table() { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); } /** * Print table headers from child classes. */ protected function print_table_headers(): void { $columnactions = $this->columnmanager->get_column_actions($this); foreach ($this->visiblecolumns as $column) { $width = $this->columnmanager->get_column_width($column); $column->display_header($columnactions, $width); } } /** * Gets the classes for the row. * * @param \stdClass $question * @param int $rowcount * @return array */ protected function get_row_classes($question, $rowcount): array { $classes = []; if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) { $classes[] = 'dimmed_text'; } if ($question->id == $this->lastchangedid) { $classes[] = 'highlight text-dark'; } $classes[] = 'r' . ($rowcount % 2); return $classes; } /** * Prints the table row from child classes. * * @param \stdClass $question * @param int $rowcount */ public function print_table_row($question, $rowcount): void { $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount)); $attributes = []; // If the question type is invalid we highlight it red. if (!\question_bank::is_qtype_usable($question->qtype)) { $rowclasses .= ' table-danger'; } if ($rowclasses) { $attributes['class'] = $rowclasses; // Add firstrow class to highlighted row if it does not already have it. if (str_contains($attributes['class'], 'highlight') && !str_contains($attributes['class'], 'firstrow')) { $attributes['class'] .= ' firstrow'; // If question_text is not visible, add class onlyhighlightrow. if (!isset($this->extrarows['question_text_row']) || !($this->extrarows['question_text_row']->isvisible)) { $attributes['class'] .= ' onlyhighlightrow'; } } } echo \html_writer::start_tag('tr', $attributes); foreach ($this->visiblecolumns as $column) { $column->display($question, $rowclasses); } echo \html_writer::end_tag('tr'); foreach ($this->extrarows as $row) { if (str_contains($attributes['class'], 'highlight')) { // Add extrarow class to highlighted row. $rowclasses .= ' extrarow' . ' ' . $this->extrarows[0]; } $row->display($question, $rowclasses); } } /** * Add another search control to this view. * @param condition $searchcondition the condition to add. * @param string|null $fieldname */ public function add_searchcondition(condition $searchcondition, ?string $fieldname = null): void { if (is_null($fieldname)) { $this->searchconditions[] = $searchcondition; } else { $this->searchconditions[$fieldname] = $searchcondition; } } /** * Add standard search conditions. * Params must be set into this object before calling this function. */ public function add_standard_search_conditions(): void { foreach ($this->plugins as $componentname => $plugin) { if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) { $pluginentrypointobject = new $plugin(); $pluginobjects = $pluginentrypointobject->get_question_filters($this); foreach ($pluginobjects as $pluginobject) { $this->add_searchcondition($pluginobject, $pluginobject->get_condition_key()); } } } } /** * Gets visible columns. * @return array Visible columns. */ public function get_visiblecolumns(): array { return $this->visiblecolumns; } /** * Is this view showing separate versions of a question? * * @return bool */ public function is_listing_specific_versions(): bool { return false; } /** * Return array of menu actions. * * @return question_action_base[] */ public function get_question_actions(): array { return $this->questionactions; } /** * Display the questions table for the fragment/ajax. * * @return string HTML for the question table */ public function display_questions_table(): string { $this->add_standard_search_conditions(); $questions = $this->load_questions(); $questionhtml = ''; if ($this->get_question_count() > 0) { $this->load_required_statistics($questions); ob_start(); $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']); $questionhtml = ob_get_clean(); } return $questionhtml; } } PK\ a.;;+classes/local/bank/plugin_features_base.phpnu[. /** * Base class class for qbank plugins. * * Every qbank plugin must extent this class. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; use core\context; use qbank_columnsortorder\local\qbank\column_action_move; use qbank_columnsortorder\local\qbank\column_action_remove; use qbank_columnsortorder\local\qbank\column_action_resize; /** * Class plugin_features_base is the base class for qbank plugins. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_features_base { /** * This method will return the array of objects to be rendered as a part of question bank columns. * * @param view $qbank * @return array */ public function get_question_columns(view $qbank): ?array { return []; } /** * This method will return the array of objects to be rendered as a part of question bank actions. * * @param view $qbank * @return question_action_base[] */ public function get_question_actions(view $qbank): array { return []; } /** * This method will return the object for the navigation node. * * @return null|navigation_node_base */ public function get_navigation_node(): ?navigation_node_base { return null; } /** * This method will return the array objects for the bulk actions ui. * * @param view $qbank * @return bulk_action_base[] */ public function get_bulk_actions(view $qbank): array { return []; } /** * This method will return a column manager object, if this plugin provides one. * * @return ?column_manager_base */ public function get_column_manager(): ?column_manager_base { return null; } /** * This method will return an array of renderable objects, for adding additional controls to the question bank screen. * * The array returned can include a numeric index for each object, to indicate the position in which it should be displayed * relative to other controls. If two plugins return controls with the same position, they will be displayed after one another, * based on the alphabetical order of the plugin component names. * * @param view $qbank The question bank view. * @param context $context The current context, for permission checks. * @param int $categoryid The current question category ID. * @return \renderable[] */ public function get_question_bank_controls(view $qbank, context $context, int $categoryid): array { return []; } /** * Return search conditions for the plugin. * * This is used by question bank view classes to get the list of available filters to display in the filter UI. * * This is also used to get the list of available filters for other purposes, for example resolving the list of questions * selected by a set of filters, in which case the `$qbank` argument may be `null` * {@see filter_condition_manager::get_condition_classes()}. * * @param view|null $qbank The current question bank view the filters are being rendered for, for example the main question * bank page, or the random question view. * @return condition[] */ public function get_question_filters(?view $qbank = null): array { return []; } } PK\ܨ1zz+classes/local/bank/question_bank_helper.phpnu[. namespace core_question\local\bank; use cm_info; use context; use context_course; use core\context_helper; use core\task\manager; use moodle_url; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/questionlib.php'); require_once($CFG->dirroot . '/course/modlib.php'); /** * Helper class for qbank sharing. * * @package core_question * @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 question_bank_helper { /** @var string the type of qbank module that users create */ public const TYPE_STANDARD = 'standard'; /** * The type of shared bank module that the system creates. * These are created in course restores when no target context can be found, * and also for when a question category cannot be deleted safely due to questions being in use. * * @var string */ public const TYPE_SYSTEM = 'system'; /** @var string The type of shared bank module that the system creates for previews. Not used for any other purpose. */ public const TYPE_PREVIEW = 'preview'; /** @var array Shared bank types */ public const SHARED_TYPES = [self::TYPE_STANDARD, self::TYPE_SYSTEM, self::TYPE_PREVIEW]; /** * User preferences record key to store recently viewed question banks. */ protected const RECENTLY_VIEWED = 'recently_viewed_open_banks'; /** * Category delimiter used by the SQL to group concatenate question category data i.e. * category_id<->category_name<->context_id. */ private const CATEGORY_DELIMITER = '<->'; /** * Category separator used by the SQL for group concatenation of those category triplets * from above. */ private const CATEGORY_SEPARATOR = '<,>'; /** * Maximum length for the question bank name database field. */ public const BANK_NAME_MAX_LENGTH = 255; /** * Modules that share questions via FEATURE_PUBLISHES_QUESTIONS. * * @return array */ public static function get_activity_types_with_shareable_questions(): array { static $sharedmods; if (!empty($sharedmods)) { return $sharedmods; } $manager = \core_plugin_manager::instance(); $plugins = $manager->get_enabled_plugins('mod'); $sharedmods = array_filter( array_keys($plugins), static fn ($plugin) => plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) && question_module_uses_questions($plugin) ); return array_values($sharedmods); } /** * Get module types that do not share questions. They will have FEATURE_USES_QUESTIONS set to false or won't have it defined. * * @return array */ public static function get_activity_types_with_private_questions(): array { static $privatemods; if (!empty($privatemods)) { return $privatemods; } $manager = \core_plugin_manager::instance(); $plugins = $manager->get_enabled_plugins('mod'); $privatemods = array_filter( array_keys($plugins), static fn ($plugin) => !plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) && question_module_uses_questions($plugin) ); return array_values($privatemods); } /** * Get records for activity modules that do publish questions, and optionally get their question categories too. * * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses. * @param array $notincourseids array of course ids where you do not want instances included. * @param array $havingcap current user must have at least one of these capabilities on each bank context. * @param bool $getcategories optionally return the categories belonging to these banks. * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with * parameters that will get banks across multiple contexts. * @param string $search Optional term to search question bank instances by name * @param int $limit The number of results to return (default 0 = no limit) * @return stdClass[] */ public static function get_activity_instances_with_shareable_questions( array $incourseids = [], array $notincourseids = [], array $havingcap = [], bool $getcategories = false, int $currentbankid = 0, ?context $filtercontext = null, string $search = '', int $limit = 0, ): array { return self::get_bank_instances(true, $incourseids, $notincourseids, $getcategories, $currentbankid, $havingcap, $filtercontext, $search, $limit, ); } /** * Get records for activity modules that don't publish questions, and optionally get their question categories too. * * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses. * @param array $notincourseids array of course ids where you do not want instances included. * @param array $havingcap current user must have at least one of these capabilities on each bank context. * @param bool $getcategories optionally return the categories belonging to these banks. * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with * parameters that will get banks across multiple contexts. * @return stdClass[] */ public static function get_activity_instances_with_private_questions( array $incourseids = [], array $notincourseids = [], array $havingcap = [], bool $getcategories = false, int $currentbankid = 0, ?context $filtercontext = null, ): array { return self::get_bank_instances(false, $incourseids, $notincourseids, $getcategories, $currentbankid, $havingcap, $filtercontext, ); } /** * Private method to build the SQL and get records from the DB. Called from public API methods * {@see self::get_activity_instances_with_shareable_questions()} * {@see self::get_activity_instances_with_private_questions()} * * @param bool $isshared true if you want instances that publish questions false if you want instances that don't * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses. * @param array $notincourseids array of course ids where you do not want instances included. * @param bool $getcategories optionally return the categories belonging to these banks. * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. * @param array $havingcap current user must have at least one of these capabilities on each bank context. * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with * parameters that will get banks across multiple contexts. * @param string $search Optional term to search question bank instances by name * @param int $limit The number of results to return (default 0 = no limit) * @return stdClass[] */ private static function get_bank_instances( bool $isshared, array $incourseids = [], array $notincourseids = [], bool $getcategories = false, int $currentbankid = 0, array $havingcap = [], ?context $filtercontext = null, string $search = '', int $limit = 0, ): array { global $DB; $params = []; if ($getcategories || !empty($havingcap)) { $contextselect = ', ' . context_helper::get_preload_record_columns_sql('c'); $contextsql = ' JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = ' . CONTEXT_MODULE . ' '; $contextgroupby = ', ' . implode(', ', array_keys(context_helper::get_preload_record_columns('c'))); } else { $contextselect = ''; $contextsql = ''; $contextgroupby = ''; } // Build the SELECT portion of the SQL and include question category joins as required. if ($getcategories) { $concat = $DB->sql_concat('qc.id', "'" . self::CATEGORY_DELIMITER . "'", 'qc.name', "'" . self::CATEGORY_DELIMITER . "'", 'qc.contextid' ); $groupconcat = $DB->sql_group_concat($concat, self::CATEGORY_SEPARATOR); $select = "SELECT cm.id, cm.course, {$groupconcat} AS cats"; $catsql = ' JOIN {question_categories} qc ON qc.contextid = c.id AND qc.parent <> 0'; } else { $select = 'SELECT cm.id, cm.course'; $catsql = ''; } if ($isshared) { $plugins = self::get_activity_types_with_shareable_questions(); } else { $plugins = self::get_activity_types_with_private_questions(); } if (empty($plugins)) { return []; } // Build the left joins for all modules of the type requested i.e. those that do or do not share questions. $pluginjoins = []; $pluginfields = []; foreach ($plugins as $key => $plugin) { $join = "LEFT JOIN {{$plugin}} p{$key} ON p{$key}.id = cm.instance AND m.name = '{$plugin}'"; if ($plugin === self::get_default_question_bank_activity_name()) { $join .= " AND p{$key}.type <> '" . self::TYPE_PREVIEW . "'"; } $pluginjoins[] = $join; $pluginfields[] = "p{$key}.name"; } $pluginjoinsql = implode(' ', $pluginjoins); // Build the SQL to filter by module name and exclude deleted course modules. [$modulenamesql, $modulenameparams] = $DB->get_in_or_equal($plugins, SQL_PARAMS_NAMED, 'modname'); $wheremodulesql = "AND m.name $modulenamesql AND cm.deletioninprogress = 0"; $params = array_merge($params, $modulenameparams); // Build the search condition using COALESCE. $searchsql = ''; if (!empty($search) && !empty($pluginfields)) { $params['search'] = "%{$search}%"; if (count($pluginfields) === 1) { $searchfield = $pluginfields[0]; } else { $searchfield = 'COALESCE(' . implode(', ', $pluginfields) . ')'; } $searchsql = "AND " . $DB->sql_like($searchfield, ':search', false); } // Build the SQL to filter out any requested course ids. if (!empty($notincourseids)) { [$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_NAMED, 'param', false); $notincoursesql = "AND cm.course {$notincoursesql}"; $params = array_merge($params, $notincourseparams); } else { $notincoursesql = ''; } // Build the SQL to include ONLY records belonging to the requested courses. if (!empty($incourseids)) { [$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids, SQL_PARAMS_NAMED); $incoursesql = " AND cm.course {$incoursesql}"; $params = array_merge($params, $incourseparams); } else { $incoursesql = ''; } // Optionally order the results by the requested bank id. if (!empty($currentbankid)) { $orderbysql = " ORDER BY CASE WHEN cm.id = :currentbankid THEN 0 ELSE 1 END ASC, cm.id DESC "; $params['currentbankid'] = $currentbankid; } else { $orderbysql = ''; } $sql = "{$select} {$contextselect} FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module {$pluginjoinsql} {$contextsql} {$catsql} WHERE 1=1 {$wheremodulesql} {$notincoursesql} {$incoursesql} {$searchsql} GROUP BY cm.id, cm.course {$contextgroupby} {$orderbysql}"; $limitforsql = $limit !== 0 && !empty($havingcap) ? 0 : $limit; $rs = $DB->get_recordset_sql($sql, $params, limitnum: $limitforsql); $banks = []; foreach ($rs as $cm) { // If capabilities have been supplied as a method argument then ensure the viewing user has at least one of those // capabilities on the module itself. if (!empty($havingcap)) { // We can preload because we made sure that in case of capabilities being passed we have the context joined in the // SQL. context_helper::preload_from_record($cm); $context = \context_module::instance($cm->id); if (!(new question_edit_contexts($context))->have_one_cap($havingcap)) { continue; } } // Populate the raw record. $banks[] = self::get_formatted_bank($cm, $currentbankid, filtercontext: $filtercontext); if (!empty($limit) && count($banks) === $limit) { break; } } $rs->close(); return $banks; } /** * Get a list of recently viewed question banks that implement FEATURE_PUBLISHES_QUESTIONS. * If any of the stored contexts don't exist anymore then update the user preference record accordingly. * * @param int $userid of the user to get recently viewed banks for. * @param int $notincourseid if supplied don't return any in this course id * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with * parameters that will get banks across multiple contexts. * @return cm_info[] */ public static function get_recently_used_open_banks( int $userid, int $notincourseid = 0, ?context $filtercontext = null, array $havingcap = [], ): array { $prefs = get_user_preferences(self::RECENTLY_VIEWED, null, $userid); $contextids = !empty($prefs) ? explode(',', $prefs) : []; if (empty($contextids)) { return $contextids; } $invalidcontexts = []; $banks = []; foreach ($contextids as $contextid) { if (!$context = context::instance_by_id($contextid, IGNORE_MISSING)) { $invalidcontexts[] = $context; continue; } if ($context->contextlevel !== CONTEXT_MODULE) { throw new \moodle_exception('Invalid question bank contextlevel: ' . $context->contextlevel); } [, $cm] = get_module_from_cmid($context->instanceid); if (!empty($notincourseid) && $notincourseid == $cm->course) { continue; } if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) { continue; } $record = self::get_formatted_bank($cm, filtercontext: $filtercontext); $banks[] = $record; } if (!empty($invalidcontexts)) { $tostore = array_diff($contextids, $invalidcontexts); $tostore = implode(',', $tostore); set_user_preference(self::RECENTLY_VIEWED, $tostore, $userid); } return $banks; } /** * Mark a user as having viewed a question bank in the user_preferences table with key {@see self::RECENTLY_VIEWED} * * @param context $bankcontext add this bank context to the viewing user's list of recently viewed. * @return void */ public static function add_bank_context_to_recently_viewed(context $bankcontext): void { [, $cm] = get_module_from_cmid($bankcontext->instanceid); if (!plugin_supports('mod', $cm->modname, FEATURE_PUBLISHES_QUESTIONS)) { return; } $userprefs = get_user_preferences(self::RECENTLY_VIEWED); $recentlyviewed = !empty($userprefs) ? explode(',', $userprefs) : []; $recentlyviewed = array_combine($recentlyviewed, $recentlyviewed); $tostore = []; $tostore[] = $bankcontext->id; if (!empty($recentlyviewed[$bankcontext->id])) { unset($recentlyviewed[$bankcontext->id]); } $tostore = array_merge($tostore, array_values($recentlyviewed)); $tostore = array_slice($tostore, 0, 5); set_user_preference(self::RECENTLY_VIEWED, implode(',', $tostore)); } /** * Populate the raw record with data for use in rendering. * * @param stdClass $cm raw course_modules record to populate data from. * @param int $currentbankid set an 'enabled' flag on the instance that matched this id. * Used in qbank_bulkmove/bulk_move.mustache * @param ?context $filtercontext Optional context in which to apply filters. * * @return stdClass */ private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass { $cminfo = cm_info::create($cm); $concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : []; $categories = array_map(static function($concatedcategory) use ($cminfo, $currentbankid) { $values = explode(self::CATEGORY_DELIMITER, $concatedcategory); $cat = new stdClass(); $cat->id = $values[0]; $cat->name = $values[1]; $cat->contextid = $values[2]; $cat->enabled = $cminfo->id == $currentbankid ? 'enabled' : 'disabled'; return $cat; }, $concatedcats); $bank = new stdClass(); $filteroptions = ['escape' => false]; if (!is_null($filtercontext)) { $filteroptions['context'] = $filtercontext; } $bank->name = $cminfo->get_formatted_name($filteroptions); $bank->modid = $cminfo->id; $bank->contextid = $cminfo->context->id; if (!isset($filteroptions['context'])) { $filteroptions['context'] = context_course::instance($cminfo->get_course()->id); } $bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}"; $bank->cminfo = $cminfo; $bank->questioncategories = $categories; return $bank; } /** * Get the system type qbank instance for this course, optionally create it if it does not yet exist. * {@see self::TYPE_SYSTEM} * * @param stdClass $course the course to get the default system type bank for. * @param bool $createifnotexists create a default bank if it does not exist. * @return cm_info|null */ public static function get_default_open_instance_system_type(stdClass $course, bool $createifnotexists = false): ?cm_info { $modinfo = get_fast_modinfo($course); $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name()); $systembank = null; if ($systembankids = self::get_qbank_ids_of_type_in_course($course, self::TYPE_SYSTEM)) { // We should only ever have 1 of these. $systembankid = reset($systembankids); // Filter the course modinfo qbanks by the systembankid. $systembanks = array_filter($qbanks, static fn($bank) => $bank->id === $systembankid); $systembank = !empty($systembanks) ? reset($systembanks) : null; } if (!$systembank && $createifnotexists) { $systembank = self::create_default_open_instance( $course, self::get_bank_name_string('systembank', 'question'), self::TYPE_SYSTEM, ); } return $systembank; } /** * Get the bank that is used for preview purposes only, optionally create it if it does not yet exist. * {@see \qbank_columnsortorder\column_manager::get_questionbank()} * * @param bool $createifnotexists create a default bank if it does not exist. * @return cm_info|null */ public static function get_preview_open_instance_type(bool $createifnotexists = false): ?cm_info { $site = get_site(); $modinfo = get_fast_modinfo($site); $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name()); $previewbank = null; if ($previewbankids = self::get_qbank_ids_of_type_in_course($site, self::TYPE_PREVIEW)) { // We should only ever have 1 of these. $previewbankid = reset($previewbankids); // Filter the course modinfo qbanks by the previewbankid. $previewbanks = array_filter($qbanks, static fn($bank) => $bank->id === $previewbankid); $previewbank = !empty($previewbanks) ? reset($previewbanks) : null; } if (!$previewbank && $createifnotexists) { $previewbank = self::create_default_open_instance( $site, self::get_bank_name_string('previewbank', 'question'), self::TYPE_PREVIEW ); } return $previewbank; } /** * Get course module ids from qbank instances on a course that are of the sub-type provided. * * @param stdClass $course the course to search * @param string $subtype the subtype of the qbank module {@see self::SHARED_TYPES} * @return int[] */ private static function get_qbank_ids_of_type_in_course(stdClass $course, string $subtype): array { global $DB; if (!in_array($subtype, self::SHARED_TYPES)) { throw new \moodle_exception('Invalid question bank type: ' . $subtype); } $modinfo = get_fast_modinfo($course); $defaultyactivityname = self::get_default_question_bank_activity_name(); $qbanks = $modinfo->get_instances_of($defaultyactivityname); $whereclause = "AND m.name = '" . $defaultyactivityname . "'"; if (!empty($qbanks)) { $sql = "SELECT cm.id FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module JOIN {{$defaultyactivityname}} q ON q.id = cm.instance WHERE cm.course = :course AND q.type = :type " . $whereclause; return $DB->get_fieldset_sql($sql, ['type' => $subtype, 'course' => $course->id]); } return []; } /** * Create a bank on the course from default options. * * @param stdClass $course the course that the new module is being created in * @param string $bankname name of the new module * @param string $type {@see self::TYPES} * @return cm_info */ public static function create_default_open_instance( stdClass $course, string $bankname, string $type = self::TYPE_STANDARD ): cm_info { global $DB; if (!in_array($type, self::SHARED_TYPES)) { throw new \RuntimeException('invalid type'); } // Preview bank must be created at site course. if ($type === self::TYPE_PREVIEW) { if ($qbank = self::get_preview_open_instance_type()) { return $qbank; } $course = get_site(); } // We can only have one of these types per course. if ($type === self::TYPE_SYSTEM && $qbank = self::get_default_open_instance_system_type($course)) { return $qbank; } $module = $DB->get_record('modules', ['name' => self::get_default_question_bank_activity_name()], '*', MUST_EXIST); $context = context_course::instance($course->id); // STANDARD type needs capability checks. if ($type === self::TYPE_STANDARD) { require_capability('moodle/course:manageactivities', $context); if (!course_allowed_module($course, $module->name)) { throw new \moodle_exception('moduledisable'); } } if (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH) { throw new \coding_exception( 'The provided bankname is too long for the database field.', 'Use question_bank_helper::get_bank_name_string to get a suitably truncated name.', ); } if ($bankname === '') { throw new \coding_exception( 'The provided bankname is empty. You must provide a name for the question bank.', ); } $data = new stdClass(); $data->section = 0; $data->visible = 0; $data->course = $course->id; $data->module = $module->id; $data->modulename = $module->name; $data->groupmode = $course->groupmode; $data->groupingid = $course->defaultgroupingid; $data->id = ''; $data->instance = ''; $data->coursemodule = ''; $data->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED; $data->visibleoncoursepage = 0; $data->name = $bankname; $data->type = in_array($type, self::SHARED_TYPES) ? $type : self::TYPE_STANDARD; $data->showdescription = $type === self::TYPE_STANDARD ? 0 : 1; // Don't create the default category if this is being created by the system as part of a migration or restore, // existing categories will be migrated to the new context. $data->skipdefaultcategory = $type === self::TYPE_SYSTEM; $mod = add_moduleinfo($data, $course); // Have to set this manually as the system because this bank type is not intended to be created directly by a user. if ($type === self::TYPE_SYSTEM) { $DB->set_field($module->name, 'intro', get_string('systembankdescription', 'question'), ['id' => $mod->instance]); $DB->set_field($module->name, 'introformat', FORMAT_HTML, ['id' => $mod->instance]); } return get_fast_modinfo($course)->get_cm($mod->coursemodule); } /** * Get the url that shows the banks list of a course. * * @param int $courseid of the course to get the url for. * @param bool $createdefault Pass true if you want the URL to create a default qbank instance when referred. * @return moodle_url */ public static function get_url_for_qbank_list(int $courseid, bool $createdefault = false): moodle_url { $url = new moodle_url('/question/banks.php', ['courseid' => $courseid]); if ($createdefault) { $url->param('createdefault', true); } return $url; } /** * This task should only ever be called once, on install/upgrade. But we may need to warn the user on some pages * that some banks may not have been transferred yet if it failed or hasn't yet completed. * * @return bool */ public static function has_bank_migration_task_completed_successfully(): bool { $defaultbank = self::get_default_question_bank_activity_name(); $task = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_question_categories"); $subtasks = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_questions"); return empty($task) && empty($subtasks); } /** * Get the activity plugin name that will be the type used for default bank creation and management. * * @return string */ public static function get_default_question_bank_activity_name(): string { global $CFG; return $CFG->corequestion_defaultqbankmod ?? 'qbank'; } /** * Get the requested language string, with parameters truncated to ensure the result fits in the database. * * Since we may be generating a question bank name based on an existing course or category name, we need to ensure * that the resulting string isn't longer than the maximum module name. * * @param string $identifier The string identifier * @param string $component The string component * @param mixed|null $params The string parameters (a single string, array or object as accepted by get_string) * @return string The string truncated to a length that will fit in the database. */ public static function get_bank_name_string(string $identifier, string $component, mixed $params = null): string { if (is_object($params)) { $shortparams = (array) $params; } else { $shortparams = $params; } $bankname = get_string($identifier, $component, $shortparams); if (!is_null($shortparams)) { $trimlength = self::BANK_NAME_MAX_LENGTH - 4; while (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH && $trimlength > 0) { // Gradually shorten the string parameters until the resulting string is short enough. if (is_array($shortparams)) { $shortparams = array_map(fn($param) => shorten_text(trim($param), $trimlength), $shortparams); } else { $shortparams = shorten_text(trim($shortparams), $trimlength); } $bankname = get_string($identifier, $component, $shortparams); $trimlength -= 10; } } // As a failsafe, limit the length of the final string in case the lang string is too long. return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH); } } PK\̀RR.classes/local/bank/question_version_status.phpnu[. namespace core_question\local\bank; /** * Class question_version_status contains the statuses for a question. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_version_status { /** * Const if the question is ready to use. */ const QUESTION_STATUS_READY = 'ready'; /** * Const if the question is hidden. */ const QUESTION_STATUS_HIDDEN = 'hidden'; /** * const if the question is in draft. */ const QUESTION_STATUS_DRAFT = 'draft'; } PK\V_,$SS%classes/local/bank/view_component.phpnu[. namespace core_question\local\bank; /** * Abstract class to define functionality shared by all pluggable components used in the question bank view. * * @package core_question * @copyright 2023 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 */ abstract class view_component { /** @var int value we return from get_menu_position here. Subclasses should override this. */ const MENU_POSITION_NOT_SET = 6666; /** @var view Question bank view. */ protected $qbank; /** * Constructor. * @param view $qbank the question bank view we are helping to render. */ public function __construct(view $qbank) { $this->qbank = $qbank; $this->init(); } /** * A chance for subclasses to initialise themselves, for example to load lang strings, * without having to override the constructor. */ protected function init(): void { } /** * Return an integer to indicate the desired position in the menu for this link, smaller at the top. * * The standard menu items in Moodle core return these numbers: * 100 preview_action * 200 edit_action * 250 copy_action * 300 tags_action * 500 history_action * 600 export_xml_action * 2000 delete_action * (So, if you want your action at a particular place in the order, there should be space.) * * If two actions get the same order number, then the tie-break on the sort * is plugin name, then the order returned by get_question_actions for that plugin. * * @return int desired position. Smallest at the top. */ public function get_menu_position(): int { // We return a big number by default, which is after all the standard core links, // so they go first. This should be overridden by all plugins, and not overriding will // generate a debugging warning from {@see \core_question\local\bank\view::init_question_actions()}. return self::MENU_POSITION_NOT_SET; } /** * Return an array 'table_alias' => 'JOIN clause' to bring in any data that * this feature requires. * * The return values for all the features will be checked. It is OK if two * features join in the same table with the same alias and identical JOIN clauses. * If two features try to use the same alias with different joins, you get an error. * Tables included by default are question (alias q) and those defined in {@see view::get_required_joins()} * * It is importnat that your join simply adds additional data (or NULLs) to the * existing rows of the query. It must not cause additional rows. * * @return string[] 'table_alias' => 'JOIN clause' */ public function get_extra_joins(): array { return []; } /** * Use table alias 'q' for the question table, or one of the * ones from get_extra_joins. Every field requested must specify a table prefix. * * @return string[] fields required. */ public function get_required_fields(): array { return []; } } PK\Sѓclasses/local/bank/helper.phpnu[. /** * Helper class for question bank and its plugins. * * All the functions which has a potential to be used by different features or * plugins, should go here. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * Class helper * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Check the status of a plugin and throw exception if not enabled and called manually. * * Any action plugin having a php script, should call this function for a safer enable/disable implementation. * * @param string $pluginname * @return void */ public static function require_plugin_enabled(string $pluginname): void { if (!\core\plugininfo\qbank::is_plugin_enabled($pluginname)) { throw new \moodle_exception('The following plugin is either disabled or missing from disk: ' . $pluginname); } } } PK\ l l *classes/local/bank/column_manager_base.phpnu[. namespace core_question\local\bank; /** * Default column manager class * * This class defines stub methods that can be overridden by a plugin defining its own column manager. * * @package core_question * @copyright 2023 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 column_manager_base { /** * Sort the list of columns * * Sort the provided list of columns into the order implemented in this column manager. * * @param array $unsortedcolumns Unordered array of columns * @return array Columns in the desired order. */ public function get_sorted_columns(array $unsortedcolumns): array { return $unsortedcolumns; } /** * Given an array of columns, set the isvisible attribute. * * This base class leave all columns visible. * * @param column_base[] $columns * @return array */ public function set_columns_visibility(array $columns): array { return $columns; } /** * Return a list of actions to display in an action menu for each column. * * @param view $qbank Question bank view. * @return column_action_base[] A list of column actions. */ public function get_column_actions(view $qbank): array { return []; } /** * Given a column, return a value for its width CSS property. * * @param column_base $column * @return string CSS width property value. */ public function get_column_width(column_base $column): string { return $column->get_default_width() . 'px'; } } PK\"O, , 3classes/local/bank/context_to_string_translator.phpnu[. namespace core_question\local\bank; /** * Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files. * * @package core_question * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class context_to_string_translator { /** * @var array used to translate between contextids and strings for this context. */ protected $contexttostringarray = []; /** * context_to_string_translator constructor. * * @param \context[] $contexts */ public function __construct($contexts) { $this->generate_context_to_string_array($contexts); } /** * Context to string. * * @param int $contextid * @return mixed */ public function context_to_string($contextid) { return $this->contexttostringarray[$contextid]; } /** * String to context. * * @param string $contextname * @return false|int|string */ public function string_to_context($contextname) { return array_search($contextname, $this->contexttostringarray); } /** * Generate context to array. * * @param \context[] $contexts */ protected function generate_context_to_string_array($contexts) { if (!$this->contexttostringarray) { $catno = 1; /** @var \context $context */ foreach ($contexts as $context) { switch ($context->contextlevel) { case CONTEXT_MODULE : $contextstring = 'module'; break; case CONTEXT_COURSE : $contextstring = 'course'; break; case CONTEXT_COURSECAT : $contextstring = "cat$catno"; $catno++; break; case CONTEXT_SYSTEM : $contextstring = 'system'; break; default: throw new \coding_exception('Unexpected context level ' . \context_helper::get_level_name($context->contextlevel) . ' for context ' . $context->id . ' in generate_context_to_string_array. ' . 'Questions can never exist in this type of context.'); } $this->contexttostringarray[$context->id] = $contextstring; } } } } PK\zGQ Q &classes/local/bank/checkbox_column.phpnu[. /** * A column with a checkbox for each question with name q{questionid}. * * @package core_question * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; use core\output\checkbox_toggleall; /** * A column with a checkbox for each question with name q{questionid}. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class checkbox_column extends column_base { public function get_name(): string { return 'checkbox'; } public function get_title() { global $OUTPUT; $mastercheckbox = new checkbox_toggleall('qbank', true, [ 'id' => 'qbheadercheckbox', 'name' => 'qbheadercheckbox', 'value' => '1', 'label' => get_string('selectall'), 'labelclasses' => 'accesshide', ]); return $OUTPUT->render($mastercheckbox); } public function get_title_tip() { return get_string('selectquestionsforbulk', 'question'); } public function display_header(array $columnactions = [], string $width = ''): void { global $PAGE; $renderer = $PAGE->get_renderer('core_question', 'bank'); $data = []; $data['sortable'] = false; $data['extraclasses'] = $this->get_classes(); $name = get_class($this); $data['sorttip'] = true; $data['tiptitle'] = $this->get_title(); $data['tip'] = $this->get_title_tip(); $data['colname'] = $this->get_column_name(); $data['columnid'] = $this->get_column_id(); $data['name'] = get_string('selectall'); $data['class'] = $name; $data['width'] = $width; echo $renderer->render_column_header($data); } protected function display_content($question, $rowclasses): void { global $OUTPUT; $checkbox = new checkbox_toggleall('qbank', false, [ 'id' => "checkq{$question->id}", 'name' => "q{$question->id}", 'value' => '1', 'label' => get_string('select'), 'labelclasses' => 'accesshide', ]); echo $OUTPUT->render($checkbox); } public function get_required_fields(): array { return ['q.id']; } public function get_default_width(): int { return 30; } } PK\P-classes/local/bank/question_edit_contexts.phpnu[. namespace core_question\local\bank; /** * Tracks all the contexts related to the one we are currently editing questions and provides helper methods to check permissions. * * @package core_question * @copyright 2007 Jamie Pratt me@jamiep.org * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_edit_contexts { /** * @var \string[][] array of the capabilities. */ public static $caps = [ 'editq' => [ 'moodle/question:add', 'moodle/question:editmine', 'moodle/question:editall', 'moodle/question:viewmine', 'moodle/question:viewall', 'moodle/question:usemine', 'moodle/question:useall', 'moodle/question:movemine', 'moodle/question:moveall'], 'questions' => [ 'moodle/question:add', 'moodle/question:editmine', 'moodle/question:editall', 'moodle/question:viewmine', 'moodle/question:viewall', 'moodle/question:movemine', 'moodle/question:moveall'], 'categories' => [ 'moodle/question:managecategory'], 'import' => [ 'moodle/question:add'], 'export' => [ 'moodle/question:viewall', 'moodle/question:viewmine']]; /** * @var array of contexts. */ protected $allcontexts; /** * Constructor * @param \context $thiscontext the current context. */ public function __construct(\context $thiscontext) { $this->allcontexts = [$thiscontext]; } /** * Get all the contexts. * * @return \context[] all parent contexts */ public function all() { return $this->allcontexts; } /** * Get the lowest context. * * @return \context lowest context which must be either the module or course context */ public function lowest() { return $this->allcontexts[0]; } /** * Get the contexts having cap. * * @param string $cap capability * @return \context[] parent contexts having capability, zero based index */ public function having_cap($cap) { $contextswithcap = []; foreach ($this->allcontexts as $context) { if (has_capability($cap, $context)) { $contextswithcap[] = $context; } } return $contextswithcap; } /** * Get the contexts having at least one cap. * * @param array $caps capabilities * @return \context[] parent contexts having at least one of $caps, zero based index */ public function having_one_cap($caps) { $contextswithacap = []; foreach ($this->allcontexts as $context) { foreach ($caps as $cap) { if (has_capability($cap, $context)) { $contextswithacap[] = $context; break; // Done with caps loop. } } } return $contextswithacap; } /** * Context having at least one cap. * * @param string $tabname edit tab name * @return \context[] parent contexts having at least one of $caps, zero based index */ public function having_one_edit_tab_cap($tabname) { return $this->having_one_cap(self::$caps[$tabname]); } /** * Contexts for adding question and also using it. * * @return \context[] those contexts where a user can add a question and then use it. */ public function having_add_and_use() { $contextswithcap = []; foreach ($this->allcontexts as $context) { if (!has_capability('moodle/question:add', $context)) { continue; } if (!has_any_capability(['moodle/question:useall', 'moodle/question:usemine'], $context)) { continue; } $contextswithcap[] = $context; } return $contextswithcap; } /** * Has at least one parent context got the cap $cap? * * @param string $cap capability * @return boolean */ public function have_cap($cap) { return (count($this->having_cap($cap))); } /** * Has at least one parent context got one of the caps $caps? * * @param array $caps capability * @return boolean */ public function have_one_cap($caps) { foreach ($caps as $cap) { if ($this->have_cap($cap)) { return true; } } return false; } /** * Has at least one parent context got one of the caps for actions on $tabname * * @param string $tabname edit tab name * @return boolean */ public function have_one_edit_tab_cap($tabname) { return $this->have_one_cap(self::$caps[$tabname]); } /** * Throw error if at least one parent context hasn't got the cap $cap * * @param string $cap capability */ public function require_cap($cap) { if (!$this->have_cap($cap)) { throw new \moodle_exception('nopermissions', '', '', $cap); } } /** * Throw error if at least one parent context hasn't got one of the caps $caps * * @param array $caps capabilities */ public function require_one_cap($caps) { if (!$this->have_one_cap($caps)) { $capsstring = join(', ', $caps); throw new \moodle_exception('nopermissions', '', '', $capsstring); } } /** * Throw error if at least one parent context hasn't got one of the caps $caps * * @param string $tabname edit tab name */ public function require_one_edit_tab_cap($tabname) { if (!$this->have_one_edit_tab_cap($tabname)) { throw new \moodle_exception('nopermissions', '', '', 'access question edit tab '.$tabname); } } } PK\Љy?y?"classes/local/bank/column_base.phpnu[. /** * Base class for representing a column. * * @package core_question * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * Base class for representing a column. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class column_base extends view_component { /** * @var string A separator for joining column attributes together into a unique ID string. */ const ID_SEPARATOR = '-'; /** * @var view $qbank the question bank view we are helping to render. */ protected $qbank; /** @var bool determine whether the column is td or th. */ protected $isheading = false; /** @var bool determine whether the column is visible */ public $isvisible = true; /** * Return an instance of this column, based on the column name. * * In the case of the base class, we don't actually use the column name since the class represents one specific column. * However, sub-classes may use the column name as an additional constructor to the parameter. * * @param view $view Question bank view * @param string $columnname The column name for this instance, as returned by {@see get_column_name()} * @param bool $ingoremissing Whether to ignore if the class does not exist. * @return column_base|null An instance of this class. */ public static function from_column_name(view $view, string $columnname, bool $ingoremissing = false): ?column_base { return new static($view); } /** * Set the column as heading */ public function set_as_heading(): void { $this->isheading = true; } /** * Check if the column is an extra row of not. */ public function is_extra_row(): bool { return false; } /** * Check if the row has an extra preference to view/hide. */ public function has_preference(): bool { return false; } /** * Get if the preference key of the row. */ public function get_preference_key(): string { return ''; } /** * Get if the preference of the row. */ public function get_preference(): bool { return false; } /** * Output the column header cell. * * @param column_action_base[] $columnactions A list of column actions to include in the header. * @param string $width A CSS width property value. */ public function display_header(array $columnactions = [], string $width = ''): void { global $PAGE; $renderer = $PAGE->get_renderer('core_question', 'bank'); $data = []; $data['sortable'] = true; $data['extraclasses'] = $this->get_classes(); $sortable = $this->is_sortable(); $name = str_replace('\\', '__', get_class($this)); $title = $this->get_title(); $tip = $this->get_title_tip(); $links = []; if (is_array($sortable)) { if ($title) { $data['title'] = $title; } foreach ($sortable as $subsort => $details) { $links[] = $this->make_sort_link($name . '-' . $subsort, $details['title'], isset($details['tip']) ? $details['tip'] : '', !empty($details['reverse'])); } $data['sortlinks'] = implode(' / ', $links); } else if ($sortable) { $data['sortlinks'] = $this->make_sort_link($name, $title, $tip); } else { $data['sortable'] = false; $data['tiptitle'] = $title; if ($tip) { $data['sorttip'] = true; $data['tip'] = $tip; } } $help = $this->help_icon(); if ($help) { $data['help'] = $help->export_for_template($renderer); } $data['colname'] = $this->get_column_name(); $data['columnid'] = $this->get_column_id(); $data['name'] = $title; $data['class'] = $name; $data['width'] = $width; if (!empty($columnactions)) { $actions = array_map(fn($columnaction) => $columnaction->get_action_menu_link($this), $columnactions); $actionmenu = new \action_menu($actions); $data['actionmenu'] = $actionmenu->export_for_template($renderer); } echo $renderer->render_column_header($data); } /** * Title for this column. Not used if is_sortable returns an array. */ abstract public function get_title(); /** * Use this when get_title() returns * something very short, and you want a longer version as a tool tip. * * @return string a fuller version of the name. */ public function get_title_tip() { return ''; } /** * If you return a help icon here, it is shown in the column header after the title. * * @return \help_icon|null help icon to show, if required. */ public function help_icon(): ?\help_icon { return null; } /** * Get a link that changes the sort order, and indicates the current sort state. * * @param string $sortname the column to sort on. * @param string $title the link text. * @param string $tip the link tool-tip text. If empty, defaults to title. * @param bool $defaultreverse whether the default sort order for this column is descending, rather than ascending. * @return string */ protected function make_sort_link($sortname, $title, $tip, $defaultreverse = false): string { global $PAGE; $sortdata = []; $currentsort = $this->qbank->get_primary_sort_order($sortname); $newsortreverse = $defaultreverse; if ($currentsort) { $newsortreverse = $currentsort == SORT_ASC; } if (!$tip) { $tip = $title; } if ($newsortreverse) { $tip = get_string('sortbyxreverse', '', $tip); } else { $tip = get_string('sortbyx', '', $tip); } $link = $title; if ($currentsort) { $link .= $this->get_sort_icon($currentsort == SORT_DESC); } $sortdata['sorturl'] = $this->qbank->new_sort_url($sortname, $newsortreverse); $sortdata['sortname'] = $sortname; $sortdata['sortcontent'] = $link; $sortdata['sorttip'] = $tip; $sortdata['sortorder'] = $newsortreverse ? SORT_DESC : SORT_ASC; $renderer = $PAGE->get_renderer('core_question', 'bank'); return $renderer->render_column_sort($sortdata); } /** * Get an icon representing the current sort state. * * @param bool $reverse sort is descending, not ascending. * @return string HTML image tag. */ protected function get_sort_icon($reverse): string { global $OUTPUT; if ($reverse) { return $OUTPUT->pix_icon('t/sort_desc', get_string('desc')); } else { return $OUTPUT->pix_icon('t/sort_asc', get_string('asc')); } } /** * Output this column. * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ public function display($question, $rowclasses): void { $this->display_start($question, $rowclasses); $this->display_content($question, $rowclasses); $this->display_end($question, $rowclasses); } /** * Output the opening column tag. If it is set as heading, it will use tag instead of * * @param \stdClass $question * @param string $rowclasses */ protected function display_start($question, $rowclasses): void { $tag = 'td'; $attr = [ 'class' => $this->get_classes(), 'data-columnid' => $this->get_column_id(), ]; if ($this->isheading) { $tag = 'th'; $attr['scope'] = 'row'; } echo \html_writer::start_tag($tag, $attr); } /** * The CSS classes to apply to every cell in this column. * * @return string */ protected function get_classes(): string { $classes = $this->get_extra_classes(); $classes[] = $this->get_name(); return implode(' ', $classes); } /** * Get the internal name for this column. Used as a CSS class name, * and to store information about the current sort. Must match PARAM_ALPHA. * * @return string column name. */ abstract public function get_name(); /** * Get the name of this column. This must be unique. * When using the inherited class to make many columns from one parent, * ensure each instance returns a unique value. * * @return string The unique name; */ public function get_column_name() { return (new \ReflectionClass($this))->getShortName(); } /** * Return a unique ID for this column object. * * This is constructed using the class name and get_column_name(), which must be unique. * * The combination of these attributes allows the object to be reconstructed, by splitting the ID into its constituent * parts then calling {@see from_column_name()}, like this: * [$class, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2); * $column = $class::from_column_name($qbank, $columnname); * Including 2 as the $limit parameter for explode() is a good idea for safely, in case a plugin defines a column with the * ID_SEPARATOR in the column name. * * @return string The column ID. */ final public function get_column_id(): string { return implode(self::ID_SEPARATOR, [static::class, $this->get_column_name()]); } /** * Any extra class names you would like applied to every cell in this column. * * @return array */ public function get_extra_classes(): array { return []; } /** * Return the default column width in pixels. * * @return int */ public function get_default_width(): int { return 120; } /** * Output the contents of this column. * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ abstract protected function display_content($question, $rowclasses); /** * Output the closing column tag * * @param object $question * @param string $rowclasses */ protected function display_end($question, $rowclasses): void { $tag = 'td'; if ($this->isheading) { $tag = 'th'; } echo \html_writer::end_tag($tag); } public function get_extra_joins(): array { return []; } public function get_required_fields(): array { return []; } /** * If this column requires any aggregated statistics, it should declare that here. * * This is those statistics can be efficiently loaded in bulk. * * The statistics are all loaded just before load_additional_data is called on each column. * The values are then available from $this->qbank->get_aggregate_statistic(...); * * @return string[] the names of the required statistics fields. E.g. ['facility']. */ public function get_required_statistics_fields(): array { return []; } /** * If this column needs extra data (e.g. tags) then load that here. * * The extra data should be added to the question object in the array. * Probably a good idea to check that another column has not already * loaded the data you want. * * @param \stdClass[] $questions the questions that will be displayed, indexed by question id. */ public function load_additional_data(array $questions) { } /** * Load the tags for each question. * * Helper that can be used from {@see load_additional_data()}; * * @param array $questions */ public function load_question_tags(array $questions): void { $firstquestion = reset($questions); if (isset($firstquestion->tags)) { // Looks like tags are already loaded, so don't do it again. return; } // Load the tags. $tagdata = \core_tag_tag::get_items_tags('core_question', 'question', array_keys($questions)); // Add them to the question objects. foreach ($tagdata as $questionid => $tags) { $questions[$questionid]->tags = $tags; } } /** * Can this column be sorted on? You can return either: * + false for no (the default), * + a field name, if sorting this column corresponds to sorting on that datbase field. * + an array of subnames to sort on as follows * return [ * 'firstname' => ['field' => 'uc.firstname', 'title' => get_string('firstname')], * 'lastname' => ['field' => 'uc.lastname', 'title' => get_string('lastname')], * ]; * As well as field, and field, you can also add 'revers' => 1 if you want the default sort * order to be DESC. * @return mixed as above. */ public function is_sortable() { return false; } /** * Helper method for building sort clauses. * @param bool $reverse whether the normal direction should be reversed. * @return string 'ASC' or 'DESC' */ protected function sortorder($reverse): string { if ($reverse) { return ' DESC'; } else { return ' ASC'; } } /** * Sorts the expressions. * * @param bool $reverse Whether to sort in the reverse of the default sort order. * @param string $subsort if is_sortable returns an array of subnames, then this will be * one of those. Otherwise will be empty. * @return string some SQL to go in the order by clause. */ public function sort_expression($reverse, $subsort): string { $sortable = $this->is_sortable(); if (is_array($sortable)) { if (array_key_exists($subsort, $sortable)) { return $sortable[$subsort]['field'] . $this->sortorder($reverse); } else { throw new \coding_exception('Unexpected $subsort type: ' . $subsort); } } else if ($sortable) { return $sortable . $this->sortorder($reverse); } else { throw new \coding_exception('sort_expression called on a non-sortable column.'); } } /** * Output the column with an example value. * * By default, this will call $this->display() using whatever dummy data is passed in. Columns can override this * to provide example output without requiring valid data. * * @param \stdClass $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ public function display_preview(\stdClass $question, string $rowclasses): void { $this->display($question, $rowclasses); } } PK\>Opp)classes/local/bank/column_action_base.phpnu[. namespace core_question\local\bank; /** * Base class to implement actions that can be performed on any column. * * A plugin should define subclasses of this for each action it provides, and return an instance of each from * plugin_feature::get_column_actions(). The action returned from {@see get_action_menu_link()} will be displayed in each column * header. * * @package core_question * @copyright 2023 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 */ abstract class column_action_base extends view_component { /** * A chance for subclasses to initialise themselves, for example to load lang strings, * without having to override the constructor. */ protected function init(): void { } /** * Return the action menu link for this action on the supplied column. * * @param column_base $column The column we are providing the action for. * @return ?\action_menu_link The action to display in the column header. */ abstract public function get_action_menu_link(column_base $column): ?\action_menu_link; } PK\nDD+classes/local/bank/navigation_node_base.phpnu[. /** * Base class class for navigation node. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * Class navigation_node_base is the base class for navigation node. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class navigation_node_base { /** * Title for this node. */ abstract public function get_navigation_title(); /** * Key for this node. */ abstract public function get_navigation_key(); /** * URL for this node */ abstract public function get_navigation_url(); /** * Tab capabilities. * * If it has capabilities to be checked, it will return the array of capabilities. * * @return null|array */ public function get_navigation_capabilities(): ?array { return null; } } PK\-8dbdb-classes/local/bank/random_question_loader.phpnu[. /** * A class for efficiently finds questions at random from the question bank. * * @package core_question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * This class efficiently finds questions at random from the question bank. * * You can ask for questions at random one at a time. Each time you ask, you * pass a category id, and whether to pick from that category and all subcategories * or just that category. * * The number of teams each question has been used is tracked, and we will always * return a question from among those elegible that has been used the fewest times. * So, if there are questions that have not been used yet in the category asked for, * one of those will be returned. However, within one instantiation of this class, * we will never return a given question more than once, and we will never return * questions passed into the constructor as $usedquestions. * * @copyright 2015 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class random_question_loader { /** @var \qubaid_condition which usages to consider previous attempts from. */ protected $qubaids; /** * @var array Array of question types to include in random questions. */ protected $includedqtypes = []; /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */ protected $availablequestionscache = []; /** * @var array questionid => num recent uses. Questions that have been used, * but that is not yet recorded in the DB. */ protected $recentlyusedquestions; /** * Constructor. * * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question. * @param array $usedquestions questionid => number of times used count. If we should allow for * further existing uses of a question in addition to the ones in $qubaids. */ public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) { $this->qubaids = $qubaids; $this->recentlyusedquestions = $usedquestions; // Load the possible question types we can select from. foreach (\question_bank::get_all_qtypes() as $qtype) { if ($qtype->is_usable_by_random()) { $this->includedqtypes[] = $qtype->name(); } } } /** * Pick a random question based on filter conditions * * @param array $filters filter array * @return int|null */ public function get_next_filtered_question_id(array $filters): ?int { $this->ensure_filtered_questions_loaded($filters); $key = $this->get_filtered_questions_key($filters); if (empty($this->availablequestionscache[$key])) { return null; } reset($this->availablequestionscache[$key]); $lowestcount = key($this->availablequestionscache[$key]); reset($this->availablequestionscache[$key][$lowestcount]); $questionid = key($this->availablequestionscache[$key][$lowestcount]); $this->use_question($questionid); return $questionid; } /** * Pick a question at random from the given category, from among those with the fewest uses. * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. * * It is up the the caller to verify that the cateogry exists. An unknown category * behaves like an empty one. * * @param int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) * in order to be eligible for being picked. * @return int|null the id of the question picked, or null if there aren't any. * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int { debugging( 'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.', DEBUG_DEVELOPER ); $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); if (empty($this->availablequestionscache[$categorykey])) { return null; } reset($this->availablequestionscache[$categorykey]); $lowestcount = key($this->availablequestionscache[$categorykey]); reset($this->availablequestionscache[$categorykey][$lowestcount]); $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); $this->use_question($questionid); return $questionid; } /** * Key for filtered questions. * This function replace get_category_key * * @param array $filters filter array * @return String */ protected function get_filtered_questions_key(array $filters): String { return sha1(json_encode($filters)); } /** * Get the key into {@see $availablequestionscache} for this combination of options. * * @param int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids an array of tag ids. * @return string the cache key. * * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string { debugging( 'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.', DEBUG_DEVELOPER ); if ($includesubcategories) { $key = $categoryid . '|1'; } else { $key = $categoryid . '|0'; } if (!empty($tagids)) { $key .= '|' . implode('|', $tagids); } return $key; } /** * Populate {@see $availablequestionscache} according to filter conditions. * * @param array $filters filter array * @return void */ protected function ensure_filtered_questions_loaded(array $filters) { global $DB; // Check if this is already done. $key = $this->get_filtered_questions_key($filters); if (isset($this->availablequestionscache[$key])) { // Data is already in the cache, nothing to do. return; } // Build filter conditions. $params = []; $filterconditions = []; foreach (filter_condition_manager::get_condition_classes() as $conditionclass) { $filter = $conditionclass::get_filter_from_list($filters); if (is_null($filter)) { continue; } [$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter); if (!empty($filterwhere)) { $filterconditions[] = '(' . $filterwhere . ')'; } if (!empty($filterparams)) { $params = array_merge($params, $filterparams); } } $filtercondition = $filterconditions ? 'AND ' . implode(' AND ', $filterconditions) : ''; // Prepare qtype check. [$qtypecondition, $qtypeparams] = $DB->get_in_or_equal($this->includedqtypes, SQL_PARAMS_NAMED, 'includedqtype'); if ($qtypecondition) { $qtypecondition = 'AND q.qtype ' . $qtypecondition; } $questionidsandcounts = $DB->get_records_sql_menu(" SELECT q.id, ( SELECT COUNT(1) FROM {$this->qubaids->from_question_attempts('qa')} WHERE qa.questionid = q.id AND {$this->qubaids->where()} ) AS previous_attempts FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.parent = :noparent $qtypecondition $filtercondition AND qv.version = ( SELECT MAX(version) FROM {question_versions} WHERE questionbankentryid = qbe.id AND status = :ready ) ORDER BY previous_attempts ", array_merge( $params, $this->qubaids->from_where_params(), ['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY], $qtypeparams, )); if (!$questionidsandcounts) { // No questions in this category. $this->availablequestionscache[$key] = []; return; } // Put all the questions with each value of $prevusecount in separate arrays. $idsbyusecount = []; foreach ($questionidsandcounts as $questionid => $prevusecount) { if (isset($this->recentlyusedquestions[$questionid])) { // Recently used questions are never returned. continue; } $idsbyusecount[$prevusecount][] = $questionid; } // Now put that data into our cache. For each count, we need to shuffle // questionids, and make those the keys of an array. $this->availablequestionscache[$key] = []; foreach ($idsbyusecount as $prevusecount => $questionids) { shuffle($questionids); $this->availablequestionscache[$key][$prevusecount] = array_combine( $questionids, array_fill(0, count($questionids), 1)); } ksort($this->availablequestionscache[$key]); } /** * Populate {@see $availablequestionscache} for this combination of options. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void { debugging( 'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' . 'ensure_filtered_questions_loaded.', DEBUG_DEVELOPER ); global $DB; $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); if (isset($this->availablequestionscache[$categorykey])) { // Data is already in the cache, nothing to do. return; } // Load the available questions from the question bank. if ($includesubcategories) { $categoryids = question_categorylist($categoryid); } else { $categoryids = [$categoryid]; } list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, SQL_PARAMS_NAMED, 'excludedqtype', false); $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); if (!$questionidsandcounts) { // No questions in this category. $this->availablequestionscache[$categorykey] = []; return; } // Put all the questions with each value of $prevusecount in separate arrays. $idsbyusecount = []; foreach ($questionidsandcounts as $questionid => $prevusecount) { if (isset($this->recentlyusedquestions[$questionid])) { // Recently used questions are never returned. continue; } $idsbyusecount[$prevusecount][] = $questionid; } // Now put that data into our cache. For each count, we need to shuffle // questionids, and make those the keys of an array. $this->availablequestionscache[$categorykey] = []; foreach ($idsbyusecount as $prevusecount => $questionids) { shuffle($questionids); $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( $questionids, array_fill(0, count($questionids), 1)); } ksort($this->availablequestionscache[$categorykey]); } /** * Update the internal data structures to indicate that a given question has * been used one more time. * * @param int $questionid the question that is being used. */ protected function use_question($questionid): void { if (isset($this->recentlyusedquestions[$questionid])) { $this->recentlyusedquestions[$questionid] += 1; } else { $this->recentlyusedquestions[$questionid] = 1; } foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { foreach ($questionsforcategory as $numuses => $questionids) { if (!isset($questionids[$questionid])) { continue; } unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); if (empty($this->availablequestionscache[$categorykey][$numuses])) { unset($this->availablequestionscache[$categorykey][$numuses]); } } } } /** * Get filtered questions. * * @param array $filters filter array * @return array list of filtered questions */ protected function get_filtered_question_ids(array $filters): array { $this->ensure_filtered_questions_loaded($filters); $key = $this->get_filtered_questions_key($filters); $cachedvalues = $this->availablequestionscache[$key]; $questionids = []; foreach ($cachedvalues as $usecount => $ids) { $questionids = array_merge($questionids, array_keys($ids)); } return $questionids; } /** * Get the list of available question ids for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @return int[] The list of question ids * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array { debugging( 'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.', DEBUG_DEVELOPER ); $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); $cachedvalues = $this->availablequestionscache[$categorykey]; $questionids = []; foreach ($cachedvalues as $usecount => $ids) { $questionids = array_merge($questionids, array_keys($ids)); } return $questionids; } /** * Check whether a given question is available in a given category. If so, mark it used. * If an optional list of tag ids are provided, then the question must be tagged with * ALL of the provided tags to be considered as available. * * @param array $filters filter array * @param int $questionid the question that is being used. * @return bool whether the question is available in the requested category. */ public function is_filtered_question_available(array $filters, int $questionid): bool { $this->ensure_filtered_questions_loaded($filters); $categorykey = $this->get_filtered_questions_key($filters); foreach ($this->availablequestionscache[$categorykey] as $questionids) { if (isset($questionids[$questionid])) { $this->use_question($questionid); return true; } } return false; } /** * Check whether a given question is available in a given category. If so, mark it used. * If an optional list of tag ids are provided, then the question must be tagged with * ALL of the provided tags to be considered as available. * * @param int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param int $questionid the question that is being used. * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. * @return bool whether the question is available in the requested category. * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool { debugging( 'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.', DEBUG_DEVELOPER ); $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); foreach ($this->availablequestionscache[$categorykey] as $questionids) { if (isset($questionids[$questionid])) { $this->use_question($questionid); return true; } } return false; } /** * Get the list of available questions for the given criteria. * * @param array $filters filter array * @param int $limit Maximum number of results to return. * @param int $offset Number of items to skip from the begging of the result set. * @param string[] $fields The fields to return for each question. * @return \stdClass[] The list of question records */ public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) { global $DB; $questionids = $this->get_filtered_question_ids($filters); if (empty($questionids)) { return []; } if (empty($fields)) { // Return all fields. $fieldsstring = '*'; } else { $fieldsstring = implode(',', $fields); } // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). $hasquestions = false; if (!empty($questionids)) { $hasquestions = true; } if ($hasquestions) { [$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); $condition = 'WHERE q.id ' . $condition; $sql = "SELECT {$fieldsstring} FROM (SELECT q.*, qbe.questioncategoryid as category FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid {$condition}) q ORDER BY q.id"; return $DB->get_records_sql($sql, $param, $offset, $limit); } else { return []; } } /** * Get the list of available questions for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @param int $limit Maximum number of results to return. * @param int $offset Number of items to skip from the begging of the result set. * @param string[] $fields The fields to return for each question. * @return \stdClass[] The list of question records * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) { debugging( 'Function get_questions() is deprecated, please use get_filtered_questions() instead.', DEBUG_DEVELOPER ); global $DB; $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); if (empty($questionids)) { return []; } if (empty($fields)) { // Return all fields. $fieldsstring = '*'; } else { $fieldsstring = implode(',', $fields); } // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). $hasquestions = false; if (!empty($questionids)) { $hasquestions = true; } if ($hasquestions) { list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); $condition = 'WHERE q.id ' . $condition; $sql = "SELECT {$fieldsstring} FROM (SELECT q.*, qbe.questioncategoryid as category FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid {$condition}) q ORDER BY q.id"; return $DB->get_records_sql($sql, $param, $offset, $limit); } else { return []; } } /** * Count number of filtered questions * * @param array $filters filter array * @return int number of question */ public function count_filtered_questions(array $filters): int { $questionids = $this->get_filtered_question_ids($filters); return count($questionids); } /** * Count the number of available questions for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @return int The number of questions matching the criteria. * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function count_questions($categoryid, $includesubcategories, $tagids = []): int { debugging( 'Function count_questions() is deprecated, please use count_filtered_questions() instead.', DEBUG_DEVELOPER ); $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); return count($questionids); } } PK\H +classes/local/bank/question_action_base.phpnu[. /** * Base class to make it easier to implement actions that are menuable_actions. * * @package core_question * @copyright 2019 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * Base class to make it easier to implement actions that are menuable_actions. * * Use this class if your action is simple (defined by just a URL, label and icon). * If your action is not simple enough to fit into the pattern that this * class implements, then you will have to implement the menuable_action * interface yourself. * * @copyright 2019 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_action_base extends view_component { /** * Get the information required to display this action either as a menu item or a separate action column. * * For most actions, it should be sufficient to override just this method. {@see get_action_menu_link()} is the public interface * and handles building a renderable menu link object from this data. * * If this action cannot apply to this question (e.g. because the user does not have * permission, then return [null, null, null]. * * @param \stdClass $question the row from the $question table, augmented with extra information. * @return array with three elements. * $url - the URL to perform the action. * $icon - the icon for this action. E.g. 't/delete'. * $label - text label to display in the UI (either in the menu, or as a tool-tip on the icon) */ protected function get_url_icon_and_label(\stdClass $question): array { return [null, null, null]; } /** * Return the action menu link for this action on the supplied question. * * For most actions, you will just need to override {@see get_url_icon_and_label()}. You only need to override * this method if you need to pass additional attributes to {@see action_menu_link_secondary}, or use a different class to * render the link. * * @param \stdClass $question * @return \action_menu_link|null */ public function get_action_menu_link(\stdClass $question): ?\action_menu_link { [$url, $icon, $label] = $this->get_url_icon_and_label($question); if (!$url) { return null; } return new \action_menu_link_secondary($url, new \pix_icon($icon, ''), $label); } } PK\L~7 7 'classes/local/bank/bulk_action_base.phpnu[. namespace core_question\local\bank; /** * Class bulk_action_base is the base class for bulk actions ui. * * Every plugin wants to implement a bulk action, should extend this class, add appropriate values to the methods * and finally pass this object via plugin_feature class. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class bulk_action_base extends view_component { /** * Title of the bulk action. * Every bulk action will have a string to show in the list. * * @return string */ abstract public function get_bulk_action_title(): string; /** * A unique key for the bulk action, this will be used in the api to identify the action data. * Every bulk must have a unique key to perform the action as a part of the form post in the base view. * When questions are selected, it will post according to the key its selected from the dropdown. * * @return string */ abstract function get_key(): string; /** * URL of the bulk action redirect page. * Bulk action can be performed by redirecting to a page and doing the appropriate selection * and finally doing the action. The url will be url of the page where users will be redirected to * select what to do with the selected questions. * * @return \moodle_url */ abstract public function get_bulk_action_url(): \moodle_url; /** * Get the capabilities for the bulk action. * The bulk actions might have some capabilities to action them as a user. * This method helps to get those caps which will be used by the base view before actioning the bulk action. * For ex: ['moodle/question:moveall', 'moodle/question:add'] * At least one of the cap need to be true for the user to use this action. * * @return array|null */ public function get_bulk_action_capabilities(): ?array { return null; } /** * Override if you want to load your own javascript. * * @return void */ public function initialise_javascript(): void { } /** * @deprecated since Moodle 4.0 */ public function get_bulk_action_key() { throw new \coding_exception(__FUNCTION__ . '() has been removed.'); } } PK\|/classes/local/bank/filter_condition_manager.phpnu[. /** * Functions for managing and manipulating question filter conditions * * @package core_question * @copyright 2023 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 */ namespace core_question\local\bank; use core\output\datafilter; use qbank_deletequestion\hidden_condition; use qbank_managecategories\category_condition; /** * Static methods for parsing and formatting data related to filter conditions. */ class filter_condition_manager { /** * Extract parameters from args list. * * @param array $args * @return array the param and extra param */ public static function extract_parameters_from_fragment_args(array $args): array { $params = []; if (array_key_exists('filter', $args)) { $params['filter'] = json_decode($args['filter'], true); } if (array_key_exists('cmid', $args)) { $params['cmid'] = $args['cmid']; } if (array_key_exists('courseid', $args)) { $params['courseid'] = $args['courseid']; } $params['jointype'] = $args['jointype'] ?? condition::JOINTYPE_DEFAULT; $params['qpage'] = $args['qpage'] ?? 0; $params['qperpage'] = $args['qperpage'] ?? 100; $params['sortdata'] = json_decode($args['sortdata'] ?? '', true); $extraparams = json_decode($args['extraparams'] ?? '', true); return [$params, $extraparams]; } /** * List of condition classes * * @return condition[] condition classes: [condition_key] = class */ public static function get_condition_classes(): array { $classes = []; $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); foreach ($plugins as $componentname => $plugin) { if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) { $pluginentrypointobject = new $plugin(); $conditions = $pluginentrypointobject->get_question_filters(); foreach ($conditions as $condition) { $classes[$condition->get_condition_key()] = $condition->get_condition_class(); } } } return $classes; } /** * Given a JSON-encoded "filter" URL param, create or replace the category filter with the provided category. * * @param string $filterparam The json-encoded filter param from the URL, containing the list of filters. * @param int $newcategoryid The new ID to set for the "category" filter condition's value. * @return string JSON-encoded filter param with the new category. */ public static function update_filter_param_to_category(string $filterparam, int $newcategoryid): string { $returnfilters = json_decode($filterparam, true); if (!$returnfilters) { $returnfilters = [ 'category' => [ 'name' => 'category', 'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT, ] ]; } $returnfilters['category']['values'] = [$newcategoryid]; return json_encode($returnfilters); } /** * Unpack filteroptions passed in a request's filter param if required. * * Filteroptions are passed via AJAX as an array of {name:, value:} pairs for compatibility with external functions. * * @param array $filters List of filters, each optionally including an array of filteroptions. * @return array The input array, with filteroptions unpacked from [{name:, value:}, ...] to [name => value, ...]. */ public static function unpack_filteroptions_param(array $filters): array { foreach ($filters as $name => $filter) { if (!empty($filter['filteroptions']) && isset(reset($filter['filteroptions'])['name'])) { $unpacked = []; foreach ($filter['filteroptions'] as $filteroption) { $unpacked[$filteroption['name']] = $filteroption['value']; } $filters[$name]['filteroptions'] = $unpacked; } } return $filters; } /** * Provide a category-context string to get a default filter array for the category. * * @param string $catstring in format '1,2' or 'categoryid,contextid' * @return array */ public static function get_default_filter(string $catstring): array { $filter = []; [$validcatid, $contextid] = category_condition::validate_category_param($catstring); if (!is_null($validcatid)) { $category = category_condition::get_category_record($validcatid, $contextid); $filter['category'] = [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$category->id], 'filteroptions' => ['includesubcategories' => get_user_preferences('qbank_managecategories_includesubcategories_filter_default', false)], ]; } $filter['hidden'] = [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [0], ]; return $filter; } /** * Filter out invalid values from the filterconditions array, * * @param array $filterconditions * @return array * @throws \dml_exception */ public static function filter_invalid_values(array $filterconditions): array { $classes = self::get_condition_classes(); foreach ($classes as $class) { $condition = new $class(); $filterconditions = $condition->filter_invalid_values($filterconditions); } return $filterconditions; } } PK\S-classes/local/bank/row_base.phpnu[. /** * Base class for 'columns' that are actually displayed as a row following the main question row. * * @package core_question * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\local\bank; /** * Base class for 'columns' that are actually displayed as a row following the main question row. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class row_base extends column_base { /** * Check if the column is an extra row of not. */ public function is_extra_row(): bool { return true; } /** * Output the opening column tag. If it is set as heading, it will use tag instead of * * @param \stdClass $question * @param string $rowclasses */ protected function display_start($question, $rowclasses): void { if ($rowclasses) { echo \html_writer::start_tag('tr', ['class' => $rowclasses]); } else { echo \html_writer::start_tag('tr'); } echo \html_writer::start_tag('td', ['colspan' => $this->qbank->get_column_count(), 'class' => $this->get_name()]); } /** * Output the closing column tag * * @param object $question * @param string $rowclasses */ protected function display_end($question, $rowclasses): void { echo \html_writer::end_tag('td'); echo \html_writer::end_tag('tr'); } } PK\J3classes/local/statistics/statistics_bulk_loader.phpnu[. namespace core_question\local\statistics; use core_question\local\bank\column_base; use core_question\statistics\questions\all_calculated_for_qubaid_condition; use core_component; /** * Helper to efficiently load all the statistics for a set of questions. * * If you are implementing a question bank column, do not use this method directly. * Instead, override the {@see column_base::get_required_statistics_fields()} method * in your column class, and the question bank view will take care of it for you. * * @package core_question * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class statistics_bulk_loader { /** * Load and aggregate the requested statistics for all the places where the given questions are used. * * The returned array will contain a values for each questionid and field, which will be null if the value is not available. * * @param int[] $questionids array of question ids. * @param string[] $requiredstatistics array of the fields required, e.g. ['facility', 'discriminationindex']. * @return float[][] if a value is not available, it will be set to null. */ public static function load_aggregate_statistics(array $questionids, array $requiredstatistics): array { // Prevent unnecessary statistics calculations. if (empty($requiredstatistics)) { $aggregates = []; foreach ($questionids as $questionid) { $aggregates[$questionid] = []; } return $aggregates; } $places = self::get_all_places_where_questions_were_attempted($questionids); // Set up blank two-dimensional arrays to store the running totals. Indexed by questionid and field name. $zerovaluesforonequestion = array_combine($requiredstatistics, array_fill(0, count($requiredstatistics), 0)); $counts = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion)); $sums = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion)); // Load the data for each place, and add to the running totals. foreach ($places as $place) { $statistics = self::load_statistics_for_place($place->component, \context::instance_by_id($place->contextid)); if ($statistics === null) { continue; } foreach ($questionids as $questionid) { foreach ($requiredstatistics as $item) { $value = self::extract_item_value($statistics, $questionid, $item); if ($value === null) { continue; } $counts[$questionid][$item] += 1; $sums[$questionid][$item] += $value; } } } // Compute the averages from the final totals. $aggregates = []; foreach ($questionids as $questionid) { $aggregates[$questionid] = []; foreach ($requiredstatistics as $item) { if ($counts[$questionid][$item] > 0) { $aggregates[$questionid][$item] = $sums[$questionid][$item] / $counts[$questionid][$item]; } else { $aggregates[$questionid][$item] = null; } } } return $aggregates; } /** * For a list of questions find all the places, defined by (component, contextid), where there are attempts. * * @param int[] $questionids array of question ids that we are interested in. * @return \stdClass[] list of objects with fields ->component and ->contextid. */ protected static function get_all_places_where_questions_were_attempted(array $questionids): array { global $DB; [$questionidcondition, $params] = $DB->get_in_or_equal($questionids); // The MIN(qu.id) is just to ensure that the rows have a unique key. $places = $DB->get_records_sql(" SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid FROM {question_usages} qu JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id JOIN {context} ctx ON ctx.id = qu.contextid WHERE qatt.questionid $questionidcondition GROUP BY qu.component, qu.contextid ORDER BY qu.contextid ASC ", $params); // Strip out the unwanted ids. $places = array_values($places); foreach ($places as $place) { unset($place->somethingunique); } return $places; } /** * Load the question statistics for all the attempts belonging to a particular component in a particular context. * * @param string $component frankenstyle component name, e.g. 'mod_quiz'. * @param \context $context the context to load the statistics for. * @return all_calculated_for_qubaid_condition|null question statistics. */ protected static function load_statistics_for_place( string $component, \context $context ): ?all_calculated_for_qubaid_condition { // This check is basically if (component_exists). if (empty(core_component::get_component_directory($component))) { return null; } if (!component_callback_exists($component, 'calculate_question_stats')) { return null; } return component_callback($component, 'calculate_question_stats', [$context]); } /** * Extract the value for one question and one type of statistic from a set of statistics. * * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. * @param int $questionid a question id. * @param string $item one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. * @return float|null the required value. */ protected static function extract_item_value(all_calculated_for_qubaid_condition $statistics, int $questionid, string $item): ?float { // Look in main questions. foreach ($statistics->questionstats as $stats) { if ($stats->questionid == $questionid && isset($stats->$item)) { return $stats->$item; } } // If not found, look in sub questions. foreach ($statistics->subquestionstats as $stats) { if ($stats->questionid == $questionid && isset($stats->$item)) { return $stats->$item; } } return null; } } PK\Ś+classes/admin/manage_qbank_plugins_page.phpnu[. /** * Manage question banks page. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\admin; /** * Class manage_qbank_plugins_page. * * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manage_qbank_plugins_page extends \admin_setting { /** * Class admin_page_manageqbanks constructor. */ public function __construct() { $this->nosave = true; parent::__construct('manageqbanks', new \lang_string('manageqbanks', 'admin'), '', ''); } public function get_setting(): bool { return true; } public function get_defaultsetting(): bool { return true; } public function write_setting($data): string { // Do not write any setting. return ''; } public function is_related($query): bool { if (parent::is_related($query)) { return true; } $types = \core_plugin_manager::instance()->get_plugins_of_type('qbank'); foreach ($types as $type) { if (strpos($type->component, $query) !== false || strpos(\core_text::strtolower($type->displayname), $query) !== false) { return true; } } return false; } public function output_html($data, $query = ''): string { global $CFG, $OUTPUT; $return = ''; $pluginmanager = \core_plugin_manager::instance(); $types = $pluginmanager->get_plugins_of_type('qbank'); if (empty($types)) { return get_string('noquestionbanks', 'question'); } $txt = get_strings(['settings', 'name', 'enable', 'disable', 'default']); $txt->uninstall = get_string('uninstallplugin', 'core_admin'); $table = new \html_table(); $table->head = [$txt->name, $txt->enable, $txt->settings, $txt->uninstall]; $table->align = ['left', 'center', 'center', 'center', 'center']; $table->attributes['class'] = 'manageqbanktable generaltable admintable table table-hover'; $table->data = []; $totalenabled = 0; $count = 0; foreach ($types as $type) { if ($type->is_enabled() && $type->is_installed_and_upgraded()) { $totalenabled++; } } foreach ($types as $type) { $url = new \moodle_url('/admin/qbankplugins.php', ['sesskey' => sesskey(), 'name' => $type->name]); $class = ''; if ($pluginmanager->get_plugin_info('qbank_'.$type->name)->get_status() === \core_plugin_manager::PLUGIN_STATUS_MISSING) { $strtypename = $type->displayname.' ('.get_string('missingfromdisk').')'; } else { $strtypename = $type->displayname; } if ($type->is_enabled()) { $hideshow = \html_writer::link($url->out(false, ['action' => 'disable']), $OUTPUT->pix_icon('t/hide', $txt->disable, 'moodle', ['class' => 'iconsmall'])); } else { $class = 'dimmed_text'; $hideshow = \html_writer::link($url->out(false, ['action' => 'enable']), $OUTPUT->pix_icon('t/show', $txt->enable, 'moodle', ['class' => 'iconsmall'])); } $settings = ''; if ($type->get_settings_url()) { $settings = \html_writer::link($type->get_settings_url(), $txt->settings); } $uninstall = ''; if ($uninstallurl = \core_plugin_manager::instance()->get_uninstall_url( 'qbank_'.$type->name, 'manage')) { $uninstall = \html_writer::link($uninstallurl, $txt->uninstall); } $row = new \html_table_row([$strtypename, $hideshow, $settings, $uninstall]); if ($class) { $row->attributes['class'] = $class; } $table->data[] = $row; $count++; } // Sort table data. usort($table->data, function($a, $b) { $aid = $a->cells[0]->text; $bid = $b->cells[0]->text; if ($aid == $bid) { return 0; } return $aid < $bid ? -1 : 1; }); $return .= \html_writer::table($table); return highlight($query, $return); } } PK\ ׭uu/classes/engine/variants/least_used_strategy.phpnu[. /** * A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet. * * @package core_question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\engine\variants; defined('MOODLE_INTERNAL') || die(); /** * A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet. * * If all variants have been used at least once in the set of usages under * consideration, then then it picks one of the least often used. * * Within one particular use of this class, each seed will always select the * same variant. This is so that shared datasets work in calculated questions, * and similar features in question types like varnumeric and STACK. * * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class least_used_strategy implements \question_variant_selection_strategy { /** @var array seed => variant number => number of uses. */ protected $variantsusecounts = array(); /** @var array seed => variant number. */ protected $selectedvariant = array(); /** * Constructor. * @param \question_usage_by_activity $quba the question usage we will be picking variants for. * @param \qubaid_condition $qubaids ids of the usages to consider when counting previous uses of each variant. */ public function __construct(\question_usage_by_activity $quba, \qubaid_condition $qubaids) { $questionidtoseed = array(); foreach ($quba->get_attempt_iterator() as $qa) { $question = $qa->get_question(); if ($question->get_num_variants() > 1) { $questionidtoseed[$question->id] = $question->get_variants_selection_seed(); } } if (empty($questionidtoseed)) { return; } $this->variantsusecounts = array_fill_keys($questionidtoseed, array()); $variantsused = \question_engine::load_used_variants(array_keys($questionidtoseed), $qubaids); foreach ($variantsused as $questionid => $usagecounts) { $seed = $questionidtoseed[$questionid]; foreach ($usagecounts as $variant => $count) { if (isset($this->variantsusecounts[$seed][$variant])) { $this->variantsusecounts[$seed][$variant] += $count; } else { $this->variantsusecounts[$seed][$variant] = $count; } } } } public function choose_variant($maxvariants, $seed) { if ($maxvariants == 1) { return 1; } if (isset($this->selectedvariant[$seed])) { return $this->selectedvariant[$seed]; } // Catch a possible programming error, and make the problem clear. if (!isset($this->variantsusecounts[$seed])) { debugging('Variant requested for unknown seed ' . $seed . '. ' . 'You must add all questions to the usage before creating the least_used_strategy. ' . 'Continuing, but the variant choses may not actually be least used.', DEBUG_DEVELOPER); $this->variantsusecounts[$seed] = array(); } if ($maxvariants > 2 * count($this->variantsusecounts[$seed])) { // Many many more variants exist than have been used so far. // It will be quicker to just pick until we miss a collision. do { $variant = rand(1, $maxvariants); } while (isset($this->variantsusecounts[$seed][$variant])); } else { // We need to work harder to find a least-used one. $leastusedvariants = array(); for ($variant = 1; $variant <= $maxvariants; ++$variant) { if (!isset($this->variantsusecounts[$seed][$variant])) { $leastusedvariants[$variant] = 1; } } if (empty($leastusedvariants)) { // All variants used at least once, try again. $leastuses = min($this->variantsusecounts[$seed]); foreach ($this->variantsusecounts[$seed] as $variant => $uses) { if ($uses == $leastuses) { $leastusedvariants[$variant] = 1; } } } $variant = array_rand($leastusedvariants); } $this->selectedvariant[$seed] = $variant; if (isset($variantsusecounts[$seed][$variant])) { $variantsusecounts[$seed][$variant] += 1; } else { $variantsusecounts[$seed][$variant] = 1; } return $variant; } } PK\%eN  -classes/output/question_version_selection.phpnu[. namespace core_question\output; use renderer_base; use templatable; use renderable; use question_bank; require_once($CFG->dirroot . '/question/engine/bank.php'); /** * A UI widget to select other versions of a particular question. * * It will help plugins to enable version selection in locations like modal, page etc. * * @package core_question * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_version_selection implements templatable, renderable { /** @var string */ private $uniqueidentifier; /** @var int */ private $currentselectedquestionid = null; /** * Constructor. * * @param string $uniqueidentifier unique identifier for the api usage. * @param int $currentlyselectedquestionid selected question id in dropdown. */ protected function __construct(string $uniqueidentifier, int $currentlyselectedquestionid) { $this->uniqueidentifier = $uniqueidentifier; $this->currentselectedquestionid = $currentlyselectedquestionid; } /** * Set the selected question id for the currently selected question. * * @param string $uniqueidentifier unique identifier for the api usage. * @param int $currentlyselectedquestionid selected question id in dropdown. * @return self an instance of this UI widget for the given question. */ public static function make_for_question(string $uniqueidentifier, int $currentlyselectedquestionid): self { return new self($uniqueidentifier, $currentlyselectedquestionid); } /** * Export the data for version selection mustache. * * @param renderer_base $output renderer of the output * @return array */ public function export_for_template(renderer_base $output): array { $displaydata = []; $versionsoptions = question_bank::get_all_versions_of_question($this->currentselectedquestionid); foreach ($versionsoptions as $versionsoption) { $versionsoption->selected = false; $a = new \stdClass(); $a->version = $versionsoption->version; $versionsoption->name = get_string('version_selection', 'core_question', $a); if ($versionsoption->questionid == $this->currentselectedquestionid) { $versionsoption->selected = true; } $displaydata[] = $versionsoption; } return [ 'options' => $displaydata, 'uniqueidentifier' => $this->uniqueidentifier, ]; } } PK\f:  $classes/output/qbank_action_menu.phpnu[. namespace core_question\output; use core_question\local\bank\navigation_node_base; use core_question\local\bank\plugin_features_base; use moodle_url; use renderer_base; use templatable; use renderable; use url_select; /** * Rendered HTML elements for tertiary nav for Question bank. * * Provides a menu of links for question bank tertiary navigation, based on get_navigation_node() implemented by each plugin. * Optionally includes and additional action button to display alongside the menu. * * @package core_question * @copyright 2021 Sujith Haridasan * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbank_action_menu implements templatable, renderable { /** @var moodle_url */ private $currenturl; /** @var ?moodle_url $actionurl URL for additional action button */ protected ?moodle_url $actionurl = null; /** @var ?string $actionlabel Label for additional action button */ protected ?string $actionlabel = null; /** * qbank_actionbar constructor. * * @param moodle_url $currenturl The current URL. */ public function __construct(moodle_url $currenturl) { $this->currenturl = $currenturl; } /** * Set the properties of an additional action button specific to the current page. * * @param moodle_url $url * @param string $label * @return void */ public function set_action_button(moodle_url $url, string $label): void { $this->actionurl = $url; $this->actionlabel = $label; } /** * Provides the data for the template. * * @param renderer_base $output renderer_base object. * @return array data for the template */ public function export_for_template(renderer_base $output): array { $questionslink = new moodle_url('/question/edit.php', $this->currenturl->params()); $menu = [ $questionslink->out(false) => get_string('questions', 'question'), ]; $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); foreach ($plugins as $componentname => $pluginfeaturesclass) { if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) { continue; } /** @var plugin_features_base $pluginfeatures */ $pluginfeatures = new $pluginfeaturesclass(); $navigationnode = $pluginfeatures->get_navigation_node(); if (is_null($navigationnode)) { continue; } /** @var moodle_url $url */ $url = $navigationnode->get_navigation_url(); $url->params($this->currenturl->params()); $menu[$url->out(false)] = $navigationnode->get_navigation_title(); } $actionbutton = null; if ($this->actionurl) { $actionbutton = [ 'url' => $this->actionurl->out(false), 'label' => $this->actionlabel, ]; } $urlselect = new url_select($menu, $this->currenturl->out(false), null, 'questionbankaction'); $urlselect->set_label(get_string('questionbanknavigation', 'question'), ['class' => 'accesshide']); return [ 'questionbankselect' => $urlselect->export_for_template($output), 'actionbutton' => $actionbutton ]; } } PK\  classes/output/add_bank_list.phpnu[. namespace core_question\output; use action_link; use core_question\local\bank\question_bank_helper; use renderer_base; use stdClass; /** * Create a list of 'Add another question bank' links for plugins that support FEATURE_PUBLISHES_QUESTIONS. * * @package core_question * @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 add_bank_list implements \renderable, \templatable { /** * Instantiate the output class. * * @param stdClass $course the course currently being viewed. * @param array $bankplugins {@see question_bank_helper::get_activity_types_with_shareable_questions()} */ public function __construct( /** @var stdClass $course the viewing course */ protected readonly stdClass $course, /** @var array $bankplugins shareable bank type plugins */ protected readonly array $bankplugins ) { } /** * Dynamically create an 'add' link for each module type that supports FEATURE_PUBLISHES_QUESTIONS. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output): array { $addbanks = []; foreach ($this->bankplugins as $plugin) { if (!plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS)) { continue; } $link = new action_link( new \moodle_url('/course/modedit.php', [ 'add' => $plugin, 'course' => $this->course->id, 'section' => 0, 'return' => 0, 'sr' => 0, 'beforemod' => 0, ]), get_string('addanotherbank', $plugin), null, null, new \pix_icon('t/add', get_string('addanotherbank', $plugin)) ); $addbanks[] = $link->export_for_template($output); } return $addbanks; } } PK\) 'classes/output/switch_question_bank.phpnu[. /** * core_question output class. * * @package core_question * @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 */ namespace core_question\output; use cm_info; use core_question\local\bank\question_bank_helper; use renderer_base; /** * Get the switch question bank rendered content. Displays lists of shared banks the viewing user has access to. */ class switch_question_bank implements \renderable, \templatable { /** * Instantiate the output class. * * @param int $quizcmid quiz course module id. * @param int $courseid of the current course. * @param int $userid of the user viewing the page. */ public function __construct( /** @var int quiz course module id */ private readonly int $quizcmid, /** @var int id of the current course */ private readonly int $courseid, /** @var int id of the user viewing the page */ private readonly int $userid ) { } /** * Create a list of question banks the user has access to for the template. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output) { [, $cm] = get_module_from_cmid($this->quizcmid); $cminfo = cm_info::create($cm); $capabilities = ['moodle/question:useall', 'moodle/question:usemine']; $coursesharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( incourseids: [$this->courseid], havingcap: $capabilities, filtercontext: $cminfo->context, ); $recentlyviewedbanks = question_bank_helper::get_recently_used_open_banks($this->userid, havingcap: $capabilities); return [ 'quizname' => $cminfo->get_formatted_name(), 'quizcmid' => $this->quizcmid, 'quizcontextid' => $cminfo->context->id, 'hascoursesharedbanks' => !empty($coursesharedbanks), 'coursesharedbanks' => $coursesharedbanks, 'hasrecentlyviewedbanks' => !empty($recentlyviewedbanks), 'recentlyviewedbanks' => $recentlyviewedbanks, ]; } } PK\^]*classes/output/question_bank_filter_ui.phpnu[. namespace core_question\output; use core\output\datafilter; use renderer_base; use stdClass; /** * Class for rendering filters on the base view. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Tomo Tsuyuki * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_bank_filter_ui extends datafilter { /** @var int $perpage number of records per page. */ protected $perpage = 0; /** @var array Parameters for the page URL. */ protected $pagevars; /** @var array $searchconditions Searchconditions for the filter. */ /** @var array $additionalparams Conditino objects for the current filter. */ /** @var string $component Component for calling the fragment callback. */ /** @var string $callback Fragment callback. */ /** @var string $view View class name. */ /** @var int|null $cmid if in an activity, the cmid. */ /** @var array $extraparams Additional parameters used by view classes. */ /** * Create a new datafilter * * @param \context $context The context of the course being displayed * @param array $searchconditions Array of condition objects for the current filters * @param array $additionalparams Additional display parameters * @param string $component the component for the fragment * @param string $callback the callback for the fragment * @param string $view the view class * @param ?string $tableregionid The ID of the region to update with the fragment * @param ?int $cmid if in an activity, the cmid. * @param array $pagevars current filter parameters * @param array $extraparams additional parameters required for a particular view class. */ public function __construct( \context $context, protected array $searchconditions, protected array $additionalparams, protected string $component, protected string $callback, protected string $view, ?string $tableregionid = null, protected ?int $cmid = null, array $pagevars = [], protected array $extraparams = [] ) { parent::__construct($context, $tableregionid); if (array_key_exists('sortData', $pagevars)) { foreach ($pagevars['sortData'] as $sortname => $sortorder) { unset($pagevars['sortData'][$sortname]); $pagevars['sortData'][str_replace('\\', '\\\\', $sortname)] = $sortorder; } } $this->pagevars = $pagevars; } /** * Get data for all filter types. * * @return array */ protected function get_filtertypes(): array { $filtertypes = []; foreach ($this->searchconditions as $searchcondition) { $filtertypes[] = $this->get_filter_object( $searchcondition->get_condition_key(), $searchcondition->get_title(), $searchcondition->allow_custom(), $searchcondition->allow_multiple(), $searchcondition->get_filter_class(), $searchcondition->get_initial_values(), $searchcondition->allow_empty(), $searchcondition->get_filteroptions(), $searchcondition->is_required(), $searchcondition->get_join_list(), ); } return $filtertypes; } /** * Export the renderer data in a mustache template friendly format. * * @param renderer_base $output Unused. * @return stdClass Data in a format compatible with a mustache template. */ public function export_for_template(renderer_base $output): stdClass { $defaultcategory = question_get_default_category($this->context->id, true); if ($this->context->contextlevel != CONTEXT_MODULE) { debugging("Context level: {$this->context->contextlevel} is now deprecated, please use CONTEXT_MODULE"); } $courseid = $this->context->get_course_context()->instanceid; return (object) [ 'tableregionid' => $this->tableregionid, 'courseid' => $courseid, 'filtertypes' => $this->get_filtertypes(), 'selected' => 'category', 'rownumber' => 1, 'categoryid' => $defaultcategory->id, 'perpage' => $this->additionalparams['perpage'] ?? 0, 'contextid' => $this->context->id, 'quizcmid' => $this->extraparams['quizcmid'] ?? $this->cmid, 'component' => $this->component, 'callback' => $this->callback, 'view' => $this->view, 'cmid' => $this->cmid ?? 0, 'pagevars' => json_encode($this->pagevars), 'extraparams' => json_encode($this->extraparams), 'showallbutton' => true, ]; } } PK\9@@-classes/output/question_category_selector.phpnu[. namespace core_question\output; use core\context; use core\output\renderable; use core\output\renderer_base; use core\output\templatable; use core_question\local\bank\question_version_status; /** * A select menu of question categories. * * * @package core_question * @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 question_category_selector implements renderable, templatable { /** * Constructor. * * @param array $contexts * @param bool $top * @param string $currentcat * @param string $selected * @param int $nochildrenof * @param bool $autocomplete */ public function __construct( /** @var array The module contexts for the question banks to show category options for. */ protected array $contexts = [], /** @var bool If true, include top categories for each context in the options. */ protected bool $top = false, /** @var string The current category, to exclude from the list. */ protected $currentcat = 0, /** @var string The value of the initially selected option. */ protected string $selected = "", /** @var int If this matches the ID of a category in the list, don't include its children. */ protected int $nochildrenof = -1, /** @var bool If true, return options as a flattened array suitable for a list of autocomplete suggestions. */ protected bool $autocomplete = false, ) { } /** * Get all the category objects, including a count of the number of questions in that category, * for all the categories in the lists $contexts. * * @param string $contexts comma separated list of contextids * @param string $sortorder used as the ORDER BY clause in the select statement. * @param bool $top Whether to return the top categories or not. * @param int $showallversions 1 to show all versions not only the latest. * @return array of category objects. * @throws \dml_exception */ public function get_categories_for_contexts( string $contexts, string $sortorder = 'parent, sortorder, name ASC', bool $top = false, int $showallversions = 0, ): array { global $DB; $contextids = explode(',', $contexts); foreach ($contextids as $contextid) { $context = context::instance_by_id($contextid); if ($context->contextlevel === CONTEXT_MODULE) { $validcontexts[] = $contextid; } } if (empty($validcontexts)) { return []; } [$insql, $inparams] = $DB->get_in_or_equal($validcontexts); $topwhere = $top ? '' : 'AND c.parent <> 0'; $statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " . " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )"; $substatuscondition = "AND v.status <> '" . question_version_status::QUESTION_STATUS_HIDDEN . "' "; $sql = "SELECT c.*, (SELECT COUNT(1) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.parent = '0' $statuscondition AND c.id = qbe.questioncategoryid AND ({$showallversions} = 1 OR (qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id $substatuscondition) ) ) ) AS questioncount FROM {question_categories} c WHERE c.contextid {$insql} {$topwhere} ORDER BY {$sortorder}"; return $DB->get_records_sql($sql, $inparams); } /** * Output an array of question categories. * * @param array $contexts The list of contexts. * @param bool $top Whether to return the top categories or not. * @param int $currentcat The current category, to exclude from the list. * @param bool $popupform Return each question bank's group in an additional nested array. * @param int $nochildrenof Don't include children of this category * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. * @return array * @throws \coding_exception|\dml_exception */ public function question_category_options( array $contexts, bool $top = false, int $currentcat = 0, bool $popupform = false, int $nochildrenof = -1, bool $escapecontextnames = true, ): array { global $CFG; $pcontexts = []; foreach ($contexts as $context) { if ($context->contextlevel !== CONTEXT_MODULE) { continue; } $pcontexts[] = $context->id; } $contextslist = join(', ', $pcontexts); $categories = $this->get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); if ($top) { $categories = $this->question_fix_top_names($categories); } $categories = $this->question_add_context_in_key($categories); $categories = $this->add_indented_names($categories, $nochildrenof); // Sort cats out into different contexts. $categoriesarray = []; foreach ($pcontexts as $contextid) { $context = context::instance_by_id($contextid); $contextstring = $context->get_context_name(true, true, $escapecontextnames); foreach ($categories as $category) { if ($category->contextid == $contextid) { $cid = $category->id; if ("{$currentcat},{$contextid}" != $cid || $currentcat == 0) { $a = new \stdClass(); $a->name = format_string( $category->indentedname, true, ['context' => $context] ); if ($category->idnumber !== null && $category->idnumber !== '') { $a->idnumber = s($category->idnumber); } if (!empty($category->questioncount)) { $a->questioncount = $category->questioncount; } if (isset($a->idnumber) && isset($a->questioncount)) { $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a); } else if (isset($a->idnumber)) { $formattedname = get_string('categorynamewithidnumber', 'question', $a); } else if (isset($a->questioncount)) { $formattedname = get_string('categorynamewithcount', 'question', $a); } else { $formattedname = $a->name; } $categoriesarray[$contextstring][$cid] = $formattedname; } } } } if ($popupform) { $popupcats = []; foreach ($categoriesarray as $contextstring => $optgroup) { $group = []; foreach ($optgroup as $key => $value) { $key = str_replace($CFG->wwwroot, '', $key); $group[$key] = $value; } $popupcats[] = [$contextstring => $group]; } return $popupcats; } else { return $categoriesarray; } } /** * Add context in categories key. * * @param array $categories The list of categories, keyed by ID. * @return array The list with the context id added to each key, id, and parent attribute. */ public function question_add_context_in_key(array $categories): array { $newcatarray = []; foreach ($categories as $id => $category) { $category->parent = "$category->parent,$category->contextid"; $category->id = "$category->id,$category->contextid"; $newcatarray["$id,$category->contextid"] = $category; } return $newcatarray; } /** * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. * * @param array $categories An array of question categories. * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not. * @return array The same question category list given to the function, with the top category names being translated. * @throws \coding_exception */ public function question_fix_top_names(array $categories, bool $escape = true): array { foreach ($categories as $id => $category) { if ($category->parent == 0) { $context = context::instance_by_id($category->contextid); $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape)); } } return $categories; } /** * Format categories into an indented list reflecting the tree structure. * * @param array $categories An array of category objects, keyed by ID. * @param int $nochildrenof If the category with this ID is in the list, don't include its children. * @return array The formatted list of categories. */ public function add_indented_names(array $categories, int $nochildrenof = -1): array { // Add an array to each category to hold the child category ids. This array // will be removed again by flatten_category_tree(). It should not be used // outside these two functions. foreach (array_keys($categories) as $id) { $categories[$id]->childids = []; } // Build the tree structure, and record which categories are top-level. // We have to be careful, because the categories array may include published // categories from other courses, but not their parents. $toplevelcategoryids = []; foreach (array_keys($categories) as $id) { if ( !empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories) ) { $categories[$categories[$id]->parent]->childids[] = $id; } else { $toplevelcategoryids[] = $id; } } // Flatten the tree to and add the indents. $newcategories = []; foreach ($toplevelcategoryids as $id) { $newcategories = $newcategories + $this->flatten_category_tree( $categories, $id, 0, $nochildrenof, ); } return $newcategories; } /** * Only for the use of add_indented_names(). * * Recursively adds an indentedname field to each category, starting with the category * with id $id, and dealing with that category and all its children, and * return a new array, with those categories in the right order. * * @param array $categories an array of categories which has had childids * fields added by flatten_category_tree(). Passed by reference for * performance only. It is not modfied. * @param int $id the category to start the indenting process from. * @param int $depth the indent depth. Used in recursive calls. * @param int $nochildrenof If the category with this ID is in the list, don't recur to its children. * @return array a new array of categories, in the right order for the tree. */ public function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array { // Indent the name of this category. $newcategories = []; $newcategories[$id] = $categories[$id]; $newcategories[$id]->indentedname = str_repeat('   ', $depth) . $categories[$id]->name; // Recursively indent the children. foreach ($categories[$id]->childids as $childid) { [$childcategory, ] = explode(',', $childid); if ($childcategory != $nochildrenof) { $newcategories = $newcategories + self::flatten_category_tree( $categories, $childid, $depth + 1, $nochildrenof, ); } } // Remove the childids array that were temporarily added. unset($newcategories[$id]->childids); return $newcategories; } /** * Context for the question_category_selector.mustache template. * * @param renderer_base $output * @return array[] [ * 'banks' => a 2-D array of question banks and categories, for a plain select list with optgroups. * 'categories' => A flat list of categories, with bank names and disabled entries, for enhancing with an Autocomplete. * ] */ public function export_for_template(renderer_base $output): array { $categoriesarray = $this->question_category_options( $this->contexts, $this->top, $this->currentcat, false, $this->nochildrenof, false, ); $bankoptgroups = []; $categoryoptions = []; if ($this->autocomplete) { foreach ($categoriesarray as $bankname => $categories) { $categoryoptions[] = [ 'label' => $bankname, 'value' => 0, 'disabled' => true, ]; foreach ($categories as $idcontext => $category) { $categoryoptions[] = [ 'label' => $category, 'value' => $idcontext, 'selected' => $this->selected == $idcontext, ]; } } if (empty($selected) && isset($categoryoptions[1])) { // Default to selecting the first category option. $categoryoptions[1]['selected'] = 1; } } else { foreach ($categoriesarray as $bankname => $categories) { $bankoptgroups[] = [ 'bankname' => $bankname, 'categories' => array_map( fn($idcontext, $category) => [ 'idcontext' => $idcontext, 'category' => $category, 'selected' => $this->selected == $idcontext, ], array_keys($categories), $categories, ), ]; } if (empty($selected) && isset($bankoptgroups[0]['categories'][0])) { // Default to selecting the first category option. $bankoptgroups[0]['categories'][0]['selected'] = 1; } } return [ 'banks' => $bankoptgroups, 'categories' => $categoryoptions, ]; } } PK\T %gg%classes/output/question_bank_list.phpnu[. namespace core_question\output; use action_link; use renderer_base; use core_courseformat\output\local\content\cm\controlmenu; use core_question\local\bank\question_bank_helper; /** * Create a list of question bank type links to manage their respective instances. * * @package core_question * @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 question_bank_list implements \renderable, \templatable { /** * Instantiate the output class. * * @param array $bankinstances {@see question_bank_helper::get_activity_instances_with_shareable_questions()} */ public function __construct( /** @var array $bankinstances */ protected readonly array $bankinstances ) { } /** * Create a list of question banks for the template. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output): array { $banks = []; foreach ($this->bankinstances as $instance) { if (plugin_supports('mod', $instance->cminfo->modname, FEATURE_PUBLISHES_QUESTIONS)) { $returnurl = question_bank_helper::get_url_for_qbank_list($instance->cminfo->course); $format = course_get_format($instance->cminfo->course); $controlmenu = new controlmenu($format, $instance->cminfo->get_section_info(), $instance->cminfo); $controlmenu->set_baseurl($returnurl); $actions = $controlmenu->get_cm_control_items(); $actionmenu = new \action_menu(); $actionmenu->set_kebab_trigger(get_string('edit')); $actionmenu->add_secondary_action($actions['update']); $actionmenu->add_secondary_action($actions['assign']); $actionmenu->add_secondary_action($actions['delete']); $managebankexport = $actionmenu->export_for_template($output); } else { $managebankexport = null; } $managequestions = new action_link( new \moodle_url("/mod/{$instance->cminfo->modname}/view.php", [ 'id' => $instance->cminfo->id, ]), $instance->name, ); $banks[] = [ 'purpose' => plugin_supports('mod', $instance->cminfo->modname, FEATURE_MOD_PURPOSE), 'iconurl' => $instance->cminfo->get_icon_url(), 'modname' => $instance->name, 'description' => $instance->cminfo->get_formatted_content(), 'managequestions' => $managequestions->export_for_template($output), 'managebank' => $managebankexport, ]; } usort($banks, static fn($a, $b) => $a['modname'] <=> $b['modname']); return $banks; } } PK\classes/output/view_banks.phpnu[. namespace core_question\output; use context_course; use core_question\local\bank\question_bank_helper; use renderer_base; use single_button; use stdClass; /** * Create the management view of shared and non-shared banks. * * @package core_question * @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 view_banks implements \templatable, \renderable { /** * Create a new view_banks instance. * * @param array $sharedbanks {@see question_bank_helper::get_activity_instances_with_shareable_questions()} * @param array $privatebanks {@see question_bank_helper::get_activity_instances_with_private_questions()} * @param stdClass $course the viewing course. */ public function __construct( /** @var array Banks that can be shared */ protected readonly array $sharedbanks, /** @var array Banks that cannot be shared */ protected readonly array $privatebanks, /** @var stdClass current course object */ protected readonly stdClass $course, ) { } /** * Create a list of shared and non-shared banks for the template. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output) { $sharedbanksrenderable = new question_bank_list($this->sharedbanks); $sharedbankscontext = $sharedbanksrenderable->export_for_template($output); $privatebanksrenderable = new question_bank_list($this->privatebanks); $privatebankscontext = $privatebanksrenderable->export_for_template($output); $defaultbankname = question_bank_helper::get_default_question_bank_activity_name(); $customqbankmods = array_filter(question_bank_helper::get_activity_types_with_shareable_questions(), static fn($plugin) => $plugin !== $defaultbankname ); $addqbanklink = new single_button( new \moodle_url('/course/modedit.php', [ 'add' => $defaultbankname, 'course' => $this->course->id, 'section' => 0, 'return' => 0, 'sr' => 0, 'beforemod' => 0, ]), get_string('add', 'core'), 'post', single_button::BUTTON_PRIMARY ); $addcustombanksrenderable = new add_bank_list($this->course, $customqbankmods); $createdefaultrenderable = new single_button( question_bank_helper::get_url_for_qbank_list($this->course->id, true), get_string('createdefault', 'question') ); $cancreate = has_capability('moodle/course:manageactivities', context_course::instance($this->course->id)); return [ 'addqbank' => $cancreate ? $addqbanklink->export_for_template($output) : false, 'hassharedbanks' => !empty($sharedbankscontext), 'sharedbanks' => $sharedbankscontext, 'hasprivatebanks' => !empty($privatebankscontext), 'privatebanks' => $privatebankscontext, 'addcustombanks' => $cancreate ? $addcustombanksrenderable->export_for_template($output) : false, 'createdefault' => $cancreate ? $createdefaultrenderable->export_for_template($output) : false, ]; } } PK\Qt(classes/output/question_version_info.phpnu[. namespace core_question\output; use renderer_base; /** * Track and display question version information. * * This class handles rendering the question version information (the current version of the question, the total number of versions, * and if the current version is the latest). It also tracks loaded question definitions that don't yet have the latest version * loaded, and handles loading the latest version of all pending questions. * * @package core_question * @copyright 2023 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 question_version_info implements \renderable, \templatable { /** * @var array List of definitions that don't know whether they are the latest version yet. */ public static array $pendingdefinitions = []; /** * @var int $version The current version number. */ public int $version; /** * @var ?int $latestversion The latest version number of this question. */ public ?int $latestversion; /** * @var bool $shortversion Are we displaying an abbreviation for "version" rather than the full word? */ protected bool $shortversion; /** * Store the current and latest versions of the question, and whether we want to abbreviate the output string. * * @param \question_definition $question * @param bool $shortversion */ public function __construct(\question_definition $question, bool $shortversion = false) { $this->version = $question->version; $this->latestversion = $question->latestversion; $this->shortversion = $shortversion; } /** * Find and set the latest version of all pending question_definition objects. * * This will update all pending objects in one go, saving us having to do a query for each question. * * @return void */ public static function populate_latest_versions(): void { global $DB; $pendingentryids = array_map(fn($definition) => $definition->questionbankentryid, self::$pendingdefinitions); [$insql, $params] = $DB->get_in_or_equal($pendingentryids); $sql = "SELECT questionbankentryid, MAX(version) AS latestversion FROM {question_versions} WHERE questionbankentryid $insql GROUP BY questionbankentryid"; $latestversions = $DB->get_records_sql_menu($sql, $params); array_walk(self::$pendingdefinitions, function($definition) use ($latestversions) { if (!isset($latestversions[$definition->questionbankentryid])) { return; } $definition->set_latest_version($latestversions[$definition->questionbankentryid]); unset(self::$pendingdefinitions[$definition->id]); }); } /** * Return the question version info as a string, including the version number and whether this is the latest version. * * @param renderer_base $output * @return array * @throws \coding_exception */ public function export_for_template(renderer_base $output): array { if (is_null($this->latestversion)) { return []; } $identifier = 'versioninfo'; if ($this->version === $this->latestversion) { $identifier .= 'latest'; } if ($this->shortversion) { $identifier = 'short' . $identifier; } return [ 'versioninfo' => get_string($identifier, 'question', $this) ]; } } PK\A+.==classes/category_manager.phpnu[. namespace core_question; use stdClass; use core\exception\moodle_exception; use core\context; /** * Category manager class, used for CRUD operations on question categories and related utility methods. * * @copyright 2024 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @package core_question */ class category_manager { /** * Cached checks for managecategories permissions in each context. * * @var array $managedcontexts; */ protected array $managedcontexts = []; /** * Deletes an existing question category. * * @param int $categoryid id of category to delete. */ public function delete_category(int $categoryid): void { global $DB; $this->require_can_delete_category($categoryid); $category = $DB->get_record('question_categories', ['id' => $categoryid]); $transaction = $DB->start_delegated_transaction(); // Send the children categories to live with their grandparent. $DB->set_field('question_categories', 'parent', $category->parent, ['parent' => $category->id]); // Finally delete the category itself. $DB->delete_records('question_categories', ['id' => $category->id]); // Log the deletion of this category. $event = \core\event\question_category_deleted::create_from_question_category_instance($category); $event->add_record_snapshot('question_categories', $category); $event->trigger(); $transaction->allow_commit(); } /** * Move questions and then delete the category. * * @param int $oldcat id of the old category. * @param int $newcat id of the new category. */ public function move_questions_and_delete_category(int $oldcat, int $newcat): void { global $DB; $transaction = $DB->start_delegated_transaction(); $this->require_can_delete_category($oldcat); $this->move_questions($oldcat, $newcat); $this->delete_category($oldcat); $transaction->allow_commit(); } /** * Checks whether the category is a "Top" category (with no parent). * * @param int $categoryid a category id. * @return bool * @throws \dml_exception */ public function is_top_category(int $categoryid): bool { global $DB; return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]); } /** * Checks whether this is the only child of a top category in a context. * * @param int $categoryid a category id. * @return bool * @throws \dml_exception */ public function is_only_child_of_top_category_in_context(int $categoryid): bool { global $DB; return 1 == $DB->count_records_sql(" SELECT count(siblingcategory.id) FROM {question_categories} thiscategory JOIN {question_categories} parentcategory ON thiscategory.parent = parentcategory.id JOIN {question_categories} siblingcategory ON siblingcategory.parent = thiscategory.parent WHERE thiscategory.id = ? AND parentcategory.parent = 0", [$categoryid]); } /** * Ensures that this user is allowed to delete this category. * * @param int $todelete a category id. * @throws \required_capability_exception * @throws \dml_exception|moodle_exception */ public function require_can_delete_category(int $todelete): void { global $DB; if ($this->is_top_category($todelete)) { throw new moodle_exception('cannotdeletetopcat', 'question'); } else if ($this->is_only_child_of_top_category_in_context($todelete)) { throw new moodle_exception('cannotdeletecate', 'question'); } else { $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete], MUST_EXIST); $this->require_manage_category(context::instance_by_id($contextid)); } } /** * Move questions to another category. * * @param int $oldcat id of the old category. * @param int $newcat id of the new category. * @throws \dml_exception */ public function move_questions(int $oldcat, int $newcat): void { $questionids = $this->get_real_question_ids_in_category($oldcat); question_move_questions_to_category($questionids, $newcat); } /** * Check the user can manage categories in the given context. * * This caches a successful check in $this->managedcontexts in case we check the same context multiple times. * * @param context $context * @return void * @throws \required_capability_exception */ public function require_manage_category(context $context): void { if (!array_key_exists($context->id, $this->managedcontexts)) { require_capability('moodle/question:managecategory', $context); $this->managedcontexts[$context->id] = true; } } /** * Check there is no question category with the given ID number in the given context. * * @param ?string $idnumber The ID number to look for. * @param int $contextid The context to check the categories in. * @param ?int $excludecategoryid If set, exclude this category from the check (e.g. if this is the one being edited). * @return bool * @throws \dml_exception */ public function idnumber_is_unique_in_context(?string $idnumber, int $contextid, ?int $excludecategoryid = null): bool { global $DB; if (empty($idnumber)) { return true; } $where = 'idnumber = ? AND contextid = ?'; $params = [$idnumber, $contextid]; if ($excludecategoryid) { $where .= ' AND id != ?'; $params[] = $excludecategoryid; } return !$DB->record_exists_select('question_categories', $where, $params); } /** * Create a new category. * * Data is expected to come from question_category_edit_form. * * By default redirects on success, unless $return is true. * * @param string $newparent 'categoryid,contextid' of the parent category. * @param string $newcategory the name. * @param string $newinfo the description. * @param string $newinfoformat description format. One of the FORMAT_ constants. * @param ?string $idnumber the idnumber. '' is converted to null. * @return int New category id. */ public function add_category( string $newparent, string $newcategory, string $newinfo, string $newinfoformat = FORMAT_HTML, ?string $idnumber = null, ): int { global $DB; if (empty($newcategory)) { throw new moodle_exception('categorynamecantbeblank', 'question'); } [$parentid, $contextid] = explode(',', $newparent); // ...moodle_form makes sure select element output is legal no need for further cleaning. $this->require_manage_category(context::instance_by_id($contextid)); if ($parentid) { if (!($DB->get_field('question_categories', 'contextid', ['id' => $parentid]) == $contextid)) { throw new moodle_exception( 'cannotinsertquestioncatecontext', 'question', '', ['cat' => $newcategory, 'ctx' => $contextid], ); } } if (!$this->idnumber_is_unique_in_context($idnumber, $contextid)) { throw new moodle_exception('idnumbertaken', 'error'); } if ((string)$idnumber === '') { $idnumber = null; } $transaction = $DB->start_delegated_transaction(); $cat = new stdClass(); $cat->parent = $parentid; $cat->contextid = $contextid; $cat->name = $newcategory; $cat->info = $newinfo; $cat->infoformat = $newinfoformat; $cat->sortorder = $this->get_max_sortorder($parentid) + 1; $cat->stamp = make_unique_id_code(); $cat->idnumber = $idnumber; $categoryid = $DB->insert_record("question_categories", $cat); // Log the creation of this category. $category = new stdClass(); $category->id = $categoryid; $category->contextid = $contextid; $event = \core\event\question_category_created::create_from_question_category_instance($category); $event->trigger(); $transaction->allow_commit(); return $categoryid; } /** * Updates an existing category with given params. * * Warning! parameter order and meaning confusingly different from add_category in some ways! * * @param int $updateid id of the category to update. * @param string $newparent 'categoryid,contextid' of the parent category to set. * @param string $newname category name. * @param string $newinfo category description. * @param string $newinfoformat description format. One of the FORMAT_ constants. * @param ?string $idnumber the idnumber. '' is converted to null. * @param ?int $sortorder The updated sortorder. Not updated if null. */ public function update_category( int $updateid, string $newparent, string $newname, string $newinfo, string $newinfoformat = FORMAT_HTML, ?string $idnumber = null, ?int $sortorder = null, ): void { global $DB; if (empty($newname)) { throw new moodle_exception('categorynamecantbeblank', 'question'); } // Get the record we are updating. $oldcat = $DB->get_record('question_categories', ['id' => $updateid]); $lastcategoryinthiscontext = $this->is_only_child_of_top_category_in_context($updateid); if (!empty($newparent) && !$lastcategoryinthiscontext) { [$parentid, $tocontextid] = explode(',', $newparent); } else { $parentid = $oldcat->parent; $tocontextid = $oldcat->contextid; } // Check permissions. $fromcontext = context::instance_by_id($oldcat->contextid); $this->require_manage_category($fromcontext); // If moving to another context, check permissions some more, and confirm contextid,stamp uniqueness. $newstamprequired = false; if ($oldcat->contextid != $tocontextid) { $tocontext = context::instance_by_id($tocontextid); $this->require_manage_category($tocontext); // Confirm stamp uniqueness in the new context. If the stamp already exists, generate a new one. if ($DB->record_exists('question_categories', ['contextid' => $tocontextid, 'stamp' => $oldcat->stamp])) { $newstamprequired = true; } } if (!$this->idnumber_is_unique_in_context($idnumber, $tocontextid, $updateid)) { throw new moodle_exception('idnumbertaken', 'error'); } if ((string)$idnumber === '') { $idnumber = null; } $transaction = $DB->start_delegated_transaction(); // Update the category record. $cat = new stdClass(); $cat->id = $updateid; $cat->name = $newname; $cat->info = $newinfo; $cat->infoformat = $newinfoformat; $cat->parent = $parentid; $cat->contextid = $tocontextid; $cat->idnumber = $idnumber; if ($newstamprequired) { $cat->stamp = make_unique_id_code(); } if ($sortorder) { $cat->sortorder = $sortorder; } $DB->update_record('question_categories', $cat); // Update the set_reference records when moving a category to a different context. move_question_set_references($cat->id, $cat->id, $oldcat->contextid, $tocontextid); // Log the update of this category. $event = \core\event\question_category_updated::create_from_question_category_instance($cat); $event->trigger(); if ($oldcat->contextid != $tocontextid) { // Moving to a new context. Must move files belonging to questions. question_move_category_to_context($cat->id, $oldcat->contextid, $tocontextid); } $transaction->allow_commit(); } /** * Returns ids of the question in the given question category. * * This method only returns the real question. It does not include * subquestions of question types like multianswer. * * @param int $categoryid id of the category. * @return int[] array of question ids. */ public function get_real_question_ids_in_category(int $categoryid): array { global $DB; $sql = "SELECT q.id 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 = :categoryid AND (q.parent = 0 OR q.parent = q.id)"; $questionids = $DB->get_records_sql($sql, ['categoryid' => $categoryid]); return array_keys($questionids); } /** * Get current max sort in a given parent * * @param int $parentid The ID of the parent category. * @return int current max sort order */ public function get_max_sortorder(int $parentid): int { global $DB; $sql = "SELECT MAX(sortorder) FROM {question_categories} WHERE parent = :parent"; $lastmax = $DB->get_field_sql($sql, ['parent' => $parentid]); return $lastmax ?? 0; } /** * Upgrade step to find question categories with the wrong parent category. * * This will find question categories that have a parent in a different context, and set the parent to the top category * of the current context. GROUP BY is to ensure we only get one result for each category, just in case we somehow end up * with multiple top-level categories in a context. * * This could occur before the fix for MDL-86300, where a course restore left question categories that were the child of a top * category with the original top category as the parent, rather than the new top category. * * This only needs to one once on upgrade, so is deprecated for removal in 6.0. */ public static function fix_restored_category_parents(): void { global $DB; $categoriestofix = $DB->get_records_sql(" SELECT qc.id as id, MIN(qc3.id) AS parent FROM {question_categories} qc JOIN {question_categories} qc2 ON qc2.id = qc.parent AND qc.contextid != qc2.contextid JOIN {question_categories} qc3 ON qc3.contextid = qc.contextid AND qc3.parent = 0 GROUP BY qc.id "); foreach ($categoriestofix as $categorytofix) { $DB->update_record('question_categories', $categorytofix, true); } } } PK\m<classes/statistics/questions/calculated_question_summary.phpnu[. /** * Question statistics calculations class. Used in the quiz statistics report. * * @package core_question * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); /** * Class calculated_question_summary * * This class is used to indicate the statistics for a random question slot should * be rendered with a link to a summary of the displayed questions. * * It's used in the limited view of the statistics calculation in lieu of adding * the stats for each subquestion individually. * * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated_question_summary extends calculated { /** * @var int only set immediately before display in the table. The order of display in the table. */ public $subqdisplayorder; /** * @var calculated[] The instances storing the calculated stats of the questions that are being summarised. */ protected $subqstats; /** * calculated_question_summary constructor. * * @param \stdClass $question * @param int $slot * @param calculated[] $subqstats The instances of the calculated stats of the questions that are being summarised. */ public function __construct($question, $slot, $subqstats) { parent::__construct($question, $slot); $this->subqstats = $subqstats; $this->subquestions = implode(',', array_column($subqstats, 'questionid')); } /** * This is a summary stat so never breakdown by variant. * * @return bool */ public function break_down_by_variant() { return false; } /** * Returns the minimum and maximum values of the given attribute in the summarised calculated stats. * * @param string $attribute The attribute that we are looking for its extremums. * @return array An array of [min,max] */ public function get_min_max_of($attribute) { $getmethod = 'get_min_max_of_' . $attribute; if (method_exists($this, $getmethod)) { return $this->$getmethod(); } else { $min = $max = null; $set = false; // We cannot simply use min or max functions because, in theory, some attributes might be non-scalar. foreach (array_column($this->subqstats, $attribute) as $value) { if (is_scalar($value) || is_null($value)) { if (!$set) { // It is not good enough to check if (!isset($min)), // because $min might have been set to null in an earlier iteration. $min = $value; $max = $value; $set = true; } $min = $this->min($min, $value); $max = $this->max($max, $value); } } return [$min, $max]; } } /** * Returns the minimum and maximum values of the standard deviation in the summarised calculated stats. * @return array An array of [min,max] */ protected function get_min_max_of_sd() { $min = $max = null; $set = false; foreach ($this->subqstats as $subqstat) { if (isset($subqstat->sd) && $subqstat->maxmark > \question_utils::MARK_TOLERANCE) { $value = $subqstat->sd / $subqstat->maxmark; } else { $value = null; } if (!$set) { // It is not good enough to check if (!isset($min)), // because $min might have been set to null in an earlier iteration. $min = $value; $max = $value; $set = true; } $min = $this->min($min, $value); $max = $this->max($max, $value); } return [$min, $max]; } /** * Find higher value. * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero, * zero is higher than null. * * @param float|null $value1 * @param float|null $value2 * @return float|null */ protected function max(?float $value1 = null, ?float $value2 = null) { $temp1 = $value1 ?: 0; $temp2 = $value2 ?: 0; $tempmax = max($temp1, $temp2); if (!$tempmax && $value1 !== 0 && $value2 !== 0) { $max = null; } else { $max = $tempmax; } return $max; } /** * Find lower value. * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero, * zero is lower than null. * * @param float|null $value1 * @param float|null $value2 * @return mixed|null */ protected function min(?float $value1 = null, ?float $value2 = null) { $temp1 = $value1 ?: 0; $temp2 = $value2 ?: 0; $tempmin = min($temp1, $temp2); if (!$tempmin && $value1 !== 0 && $value2 !== 0) { $min = null; } else { $min = $tempmin; } return $min; } } PK\}OODclasses/statistics/questions/all_calculated_for_qubaid_condition.phpnu[. /** * A collection of all the question statistics calculated for an activity instance ie. the stats calculated for slots and * sub-questions and variants of those questions. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; use question_bank; /** * A collection of all the question statistics calculated for an activity instance. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class all_calculated_for_qubaid_condition { /** * @var object[] */ public $subquestions = []; /** * Holds slot (position) stats and stats for variants of questions in slots. * * @var calculated[] */ public $questionstats = array(); /** * Holds sub-question stats and stats for variants of subqs. * * @var calculated_for_subquestion[] */ public $subquestionstats = array(); /** * Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats. * * @param object $step * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null. */ public function initialise_for_subq($step, $variant = null) { $newsubqstat = new calculated_for_subquestion($step, $variant); if ($variant === null) { $this->subquestionstats[$step->questionid] = $newsubqstat; } else { $this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat; } } /** * Set up a calculated instance ready to store a slot question's stats. * * @param int $slot * @param object $question * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null. */ public function initialise_for_slot($slot, $question, $variant = null) { $newqstat = new calculated($question, $slot, $variant); if ($variant === null) { $this->questionstats[$slot] = $newqstat; } else { $this->questionstats[$slot]->variantstats[$variant] = $newqstat; } } /** * Do we have stats for a particular quesitonid (and optionally variant)? * * @param int $questionid The id of the sub question. * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats. * @return bool whether those stats exist (yet). */ public function has_subq($questionid, $variant = null) { if ($variant === null) { return isset($this->subquestionstats[$questionid]); } else { return isset($this->subquestionstats[$questionid]->variantstats[$variant]); } } /** * Reference for a item stats instance for a questionid and optional variant no. * * @param int $questionid The id of the sub question. * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats. * @return calculated|calculated_for_subquestion stats instance for a questionid and optional variant no. * Will be a calculated_for_subquestion if no variant specified. * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats. */ public function for_subq($questionid, $variant = null) { if ($variant === null) { if (!isset($this->subquestionstats[$questionid])) { throw new \coding_exception('Reference to unknown question id ' . $questionid); } else { return $this->subquestionstats[$questionid]; } } else { if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) { throw new \coding_exception('Reference to unknown question id ' . $questionid . ' variant ' . $variant); } else { return $this->subquestionstats[$questionid]->variantstats[$variant]; } } } /** * ids of all randomly selected question for all slots. * * @return int[] An array of all sub-question ids. */ public function get_all_subq_ids() { return array_keys($this->subquestionstats); } /** * All slots nos that stats have been calculated for. * * @return int[] An array of all slot nos. */ public function get_all_slots() { return array_keys($this->questionstats); } /** * Do we have stats for a particular slot (and optionally variant)? * * @param int $slot The slot no. * @param int|null $variant if provided then we want the object which stores a variant of a position's stats. * @return bool whether those stats exist (yet). */ public function has_slot($slot, $variant = null) { if ($variant === null) { return isset($this->questionstats[$slot]); } else { return isset($this->questionstats[$slot]->variantstats[$variant]); } } /** * Get position stats instance for a slot and optional variant no. * * @param int $slot The slot no. * @param int|null $variant if provided then we want the object which stores a variant of a position's stats. * @return calculated|calculated_for_subquestion An instance of the class storing the calculated position stats. * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats. */ public function for_slot($slot, $variant = null) { if ($variant === null) { if (!isset($this->questionstats[$slot])) { throw new \coding_exception('Reference to unknown slot ' . $slot); } else { return $this->questionstats[$slot]; } } else { if (!isset($this->questionstats[$slot]->variantstats[$variant])) { throw new \coding_exception('Reference to unknown slot ' . $slot . ' variant ' . $variant); } else { return $this->questionstats[$slot]->variantstats[$variant]; } } } /** * Load cached statistics from the database. * * @param \qubaid_condition $qubaids Which question usages to load stats for? */ public function get_cached($qubaids) { global $DB; $timemodified = self::get_last_calculated_time($qubaids); $questionstatrecs = $DB->get_records('question_statistics', ['hashcode' => $qubaids->get_hash_code(), 'timemodified' => $timemodified]); $questionids = array(); foreach ($questionstatrecs as $fromdb) { if (is_null($fromdb->variant) && !$fromdb->slot) { $questionids[] = $fromdb->questionid; } } $this->subquestions = question_load_questions($questionids); foreach ($questionstatrecs as $fromdb) { if (is_null($fromdb->variant)) { if ($fromdb->slot) { if (!isset($this->questionstats[$fromdb->slot])) { debugging('Statistics found for slot ' . $fromdb->slot . ' in stats ' . json_encode($qubaids->from_where_params()) . ' which is not an analysable question.', DEBUG_DEVELOPER); } $this->questionstats[$fromdb->slot]->populate_from_record($fromdb); } else { $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion(); $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb); if (isset($this->subquestions[$fromdb->questionid])) { $this->subquestionstats[$fromdb->questionid]->question = $this->subquestions[$fromdb->questionid]; } else { $this->subquestionstats[$fromdb->questionid]->question = question_bank::get_qtype( 'missingtype', false)->make_deleted_instance($fromdb->questionid, 1); } } } } // Add cached variant stats to data structure. foreach ($questionstatrecs as $fromdb) { if (!is_null($fromdb->variant)) { if ($fromdb->slot) { if (!isset($this->questionstats[$fromdb->slot])) { debugging('Statistics found for slot ' . $fromdb->slot . ' in stats ' . json_encode($qubaids->from_where_params()) . ' which is not an analysable question.', DEBUG_DEVELOPER); continue; } $newcalcinstance = new calculated(); $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance; $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question; } else { $newcalcinstance = new calculated_for_subquestion(); $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance; if (isset($this->subquestions[$fromdb->questionid])) { $newcalcinstance->question = $this->subquestions[$fromdb->questionid]; } else { $newcalcinstance->question = question_bank::get_qtype( 'missingtype', false)->make_deleted_instance($fromdb->questionid, 1); } } $newcalcinstance->populate_from_record($fromdb); } } } /** * Find time of non-expired statistics in the database. * * @param \qubaid_condition $qubaids Which question usages to look for stats for? * @return int|bool Time of cached record that matches this qubaid_condition or false if non found. */ public function get_last_calculated_time($qubaids) { global $DB; $lastcalculatedtime = $DB->get_field('question_statistics', 'COALESCE(MAX(timemodified), 0)', ['hashcode' => $qubaids->get_hash_code()]); if ($lastcalculatedtime) { return $lastcalculatedtime; } else { return false; } } /** * Save stats to db, first cleaning up any old ones. * * @param \qubaid_condition $qubaids Which question usages are we caching the stats of? */ public function cache($qubaids) { global $DB; $transaction = $DB->start_delegated_transaction(); $timemodified = time(); foreach ($this->get_all_slots() as $slot) { $this->for_slot($slot)->cache($qubaids, $timemodified); } foreach ($this->get_all_subq_ids() as $subqid) { $this->for_subq($subqid)->cache($qubaids, $timemodified); } $transaction->allow_commit(); } /** * Return all sub-questions used. * * @return \object[] array of questions. */ public function get_sub_questions() { return $this->subquestions; } /** * Return all stats for one slot, stats for the slot itself, and either : * - variants of question * - variants of randomly selected questions * - randomly selected questions * * @param int $slot the slot no * @param bool|int $limitvariants limit number of variants and sub-questions displayed? * @return calculated|calculated_for_subquestion[] stats to display */ public function structure_analysis_for_one_slot($slot, $limitvariants = false) { return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants)); } /** * Call after calculations to output any error messages. * * @return string[] Array of strings describing error messages found during stats calculation. */ public function any_error_messages() { $errors = array(); foreach ($this->get_all_slots() as $slot) { foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { if ($this->for_subq($subqid)->differentweights) { $name = $this->for_subq($subqid)->question->name; $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name); } } } return $errors; } /** * Return all stats for variants of question in slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_variant_stats_for_one_slot($slot) { $toreturn = array(); foreach ($this->for_slot($slot)->get_variants() as $variant) { $toreturn[] = $this->for_slot($slot, $variant); } return $toreturn; } /** * Return all stats for variants of randomly selected questions for one slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_subq_variants_for_one_slot($slot) { $toreturn = array(); $displayorder = 1; foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { if ($variants = $this->for_subq($subqid)->get_variants()) { foreach ($variants as $variant) { $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant); } } $displayorder++; } return $toreturn; } /** * Return all stats for randomly selected questions for one slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_subqs_for_one_slot($slot) { $displayorder = 1; $toreturn = array(); foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid); $displayorder++; } return $toreturn; } /** * Return all variant or 'sub-question' stats one slot, either : * - variants of question * - variants of randomly selected questions * - randomly selected questions * * @param int $slot the slot no * @param bool $limited limit number of variants and sub-questions displayed? * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display */ protected function all_subq_and_variant_stats_for_slot($slot, $limited) { // Random question in this slot? if ($this->for_slot($slot)->get_sub_question_ids()) { $toreturn = array(); if ($limited) { $randomquestioncalculated = $this->for_slot($slot); if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) { // There are some variants from randomly selected questions. // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each subquestion. $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats); $toreturn = array_merge($toreturn, [$summarystat]); } if ($subqstats = $this->all_subqs_for_one_slot($slot)) { // There are some randomly selected questions. // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each subquestion. $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats); $toreturn = array_merge($toreturn, [$summarystat]); } foreach ($toreturn as $index => $calculated) { $calculated->subqdisplayorder = $index; } } else { $displaynumber = 1; foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid); if ($variants = $this->for_subq($subqid)->get_variants()) { foreach ($variants as $variant) { $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant); } } $displaynumber++; } } return $toreturn; } else { $variantstats = $this->all_variant_stats_for_one_slot($slot); if ($limited && $variantstats) { $variantquestioncalculated = $this->for_slot($slot); // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each variation. $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats); return [$summarystat]; } else { return $variantstats; } } } /** * We need a new object for display. Sub-question stats can appear more than once in different slots. * So we create a clone of the object and then we can set properties on the object that are per slot. * * @param int $displaynumber The display number for this sub question. * @param int $slot The slot number. * @param int $subqid The sub question id. * @param null|int $variant The variant no. * @return calculated_for_subquestion The object for display. */ protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) { $slotstat = fullclone($this->for_subq($subqid, $variant)); $slotstat->question->number = $this->for_slot($slot)->question->number; $slotstat->subqdisplayorder = $displaynumber; return $slotstat; } /** * Create a summary calculated object for a calculated question. This is used as a placeholder * to indicate that a calculated question has sub questions or variations to show rather than listing each * subquestion or variation directly. * * @param calculated $randomquestioncalculated The calculated instance for the random question slot. * @param calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised. * @return calculated_question_summary */ protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) { $question = $randomquestioncalculated->question; $slot = $randomquestioncalculated->slot; $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats); return $calculatedsummary; } } PK\Vmc#c#+classes/statistics/questions/calculated.phpnu[. /** * Question statistics calculations class. Used in the quiz statistics report but also available for use elsewhere. * * @package core * @subpackage questionbank * @copyright 2013 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * This class is used to return the stats as calculated by {@link \core_question\statistics\questions\calculator} * * @copyright 2013 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated { public $questionid; // These first fields are the final fields cached in the db and shown in reports. // See : http://docs.moodle.org/dev/Quiz_statistics_calculations#Position_statistics . public $slot = null; /** * @var null|integer if this property is not null then this is the stats for a variant of a question or when inherited by * calculated_for_subquestion and not null then this is the stats for a variant of a sub question. */ public $variant = null; /** * @var bool is this a sub question. */ public $subquestion = false; /** * @var string if this stat has been picked as a min, median or maximum facility value then this string says which stat this * is. Prepended to question name for display. */ public $minmedianmaxnotice = ''; /** * @var int total attempts at this question. */ public $s = 0; /** * @var float effective weight of this question. */ public $effectiveweight; /** * @var bool is covariance of this questions mark with other question marks negative? */ public $negcovar; /** * @var float */ public $discriminationindex; /** * @var float */ public $discriminativeefficiency; /** * @var float standard deviation */ public $sd; /** * @var float */ public $facility; /** * @var float max mark achievable for this question. */ public $maxmark; /** * @var string comma separated list of the positions in which this question appears. */ public $positions; /** * @var null|float The average score that students would have got by guessing randomly. Or null if not calculable. */ public $randomguessscore = null; // End of fields in db. protected $fieldsindb = array('questionid', 'slot', 'subquestion', 's', 'effectiveweight', 'negcovar', 'discriminationindex', 'discriminativeefficiency', 'sd', 'facility', 'subquestions', 'maxmark', 'positions', 'randomguessscore', 'variant'); // Fields used for intermediate calculations. public $totalmarks = 0; public $totalothermarks = 0; /** * @var float The total of marks achieved for all positions in all attempts where this item was seen. */ public $totalsummarks = 0; public $markvariancesum = 0; public $othermarkvariancesum = 0; public $covariancesum = 0; public $covariancemaxsum = 0; public $subquestions = ''; public $covariancewithoverallmarksum = 0; public $markarray = array(); public $othermarksarray = array(); public $markaverage; public $othermarkaverage; /** * @var float The average for all attempts, of the sum of the marks for all positions in which this item appeared. */ public $summarksaverage; public $markvariance; public $othermarkvariance; public $covariance; public $covariancemax; public $covariancewithoverallmark; /** * @var object full question data */ public $question; /** * An array of calculated stats for each variant of the question. Even when there is just one variant we still calculate this * data as there is no way to know if there are variants before we have finished going through the attempt data one time. * * @var calculated[] $variants */ public $variantstats = array(); /** * Set if this record has been retrieved from cache. This is the time that the statistics were calculated. * * @var integer */ public $timemodified; /** * Set up a calculated instance ready to store a question's (or a variant of a slot's question's) * stats for one slot in the quiz. * * @param null|object $question * @param null|int $slot * @param null|int $variant */ public function __construct($question = null, $slot = null, $variant = null) { if ($question !== null) { $this->questionid = $question->id; $this->maxmark = $question->maxmark; $this->positions = $question->number; $this->question = $question; } if ($slot !== null) { $this->slot = $slot; } if ($variant !== null) { $this->variant = $variant; } } /** * Used to determine which random questions pull sub questions from the same pools. Where pool means category and possibly * all the sub categories of that category. * * @return null|string represents the pool of questions from which this question draws if it is random, or null if not. */ public function random_selector_string() { if ($this->question->qtype == 'random') { return $this->question->category .'/'. $this->question->questiontext; } else { return null; } } /** * Cache calculated stats stored in this object in 'question_statistics' table. * * @param \qubaid_condition $qubaids * @param int|null $timemodified the modified time to store. Defaults to the current time. */ public function cache($qubaids, $timemodified = null) { global $DB; $toinsert = new \stdClass(); $toinsert->hashcode = $qubaids->get_hash_code(); $toinsert->timemodified = $timemodified ?? time(); foreach ($this->fieldsindb as $field) { $toinsert->{$field} = $this->{$field}; } $DB->insert_record('question_statistics', $toinsert, false); if ($this->get_variants()) { foreach ($this->variantstats as $variantstat) { $variantstat->cache($qubaids, $timemodified); } } } /** * Load properties of this class from db record. * * @param object $record Given a record from 'question_statistics' copy stats from record to properties. */ public function populate_from_record($record) { foreach ($this->fieldsindb as $field) { $this->$field = $record->$field; } $this->timemodified = $record->timemodified; } /** * Sort the variants of this question by variant number. */ public function sort_variants() { ksort($this->variantstats); } /** * Get any sub question ids for this question. * * @return int[] array of sub-question ids or empty array if there are none. */ public function get_sub_question_ids() { if ($this->subquestions !== '') { return explode(',', $this->subquestions); } else { return array(); } } /** * Array of variants that have appeared in the attempt data for this question. Or an empty array if there is only one variant. * * @return int[] the variant nos. */ public function get_variants() { $variants = array_keys($this->variantstats); if (count($variants) > 1 || reset($variants) != 1) { return $variants; } else { return array(); } } /** * Do we break down the stats for this question by variant or not? * * @return bool Do we? */ public function break_down_by_variant() { $qtype = \question_bank::get_qtype($this->question->qtype); return $qtype->break_down_stats_and_response_analysis_by_variant($this->question); } /** * Delete the data structure for storing variant stats. */ public function clear_variants() { $this->variantstats = array(); } } PK\d :O;classes/statistics/questions/calculated_for_subquestion.phpnu[. /** * Class for storing calculated sub question statistics and intermediate calculation values. * * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * A class to store calculated stats for a sub question. * * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated_for_subquestion extends calculated { public $subquestion = true; /** * @var array What slots is this sub question used in? */ public $usedin = array(); /** * @var bool Have the slots this sub question has been used in got different grades? */ public $differentweights = false; public $negcovar = 0; /** * @var int only set immediately before display in the table. The order of display in the table. */ public $subqdisplayorder; /** * Constructor. * * @param object|null $step the step data for the step that this sub-question was first encountered in. * @param int|null $variant the variant no */ public function __construct($step = null, $variant = null) { if ($step !== null) { $this->questionid = $step->questionid; $this->maxmark = $step->maxmark; } $this->variant = $variant; } } PK\ȁVV+classes/statistics/questions/calculator.phpnu[. /** * Question statistics calculator class. Used in the quiz statistics report but also available for use elsewhere. * * @package core * @subpackage questionbank * @copyright 2013 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * This class has methods to compute the question statistics from the raw data. * * @copyright 2013 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculator { /** * @var all_calculated_for_qubaid_condition all the stats calculated for slots and sub-questions and variants of those * questions. */ protected $stats; /** * @var float */ protected $sumofmarkvariance = 0; /** * @var array[] keyed by a string representing the pool of questions that this random question draws from. * string as returned from {@link \core_question\statistics\questions\calculated::random_selector_string} */ protected $randomselectors = array(); /** * @var \progress_trace */ protected $progress; /** * @var string The class name of the class to instantiate to store statistics calculated. */ protected $statscollectionclassname = '\core_question\statistics\questions\all_calculated_for_qubaid_condition'; /** * Constructor. * * @param object[] questions to analyze, keyed by slot, also analyses sub questions for random questions. * we expect some extra fields - slot, maxmark and number on the full question data objects. * @param \core\progress\base|null $progress the element to send progress messages to, default is {@link \core\progress\none}. */ public function __construct($questions, $progress = null) { if ($progress === null) { $progress = new \core\progress\none(); } $this->progress = $progress; $this->stats = new $this->statscollectionclassname(); foreach ($questions as $slot => $question) { $this->stats->initialise_for_slot($slot, $question); $this->stats->for_slot($slot)->randomguessscore = $this->get_random_guess_score($question); } } /** * Calculate the stats. * * @param \qubaid_condition $qubaids Which question usages to calculate the stats for? * @return all_calculated_for_qubaid_condition The calculated stats. */ public function calculate($qubaids) { $this->progress->start_progress('', 6); list($lateststeps, $summarks) = $this->get_latest_steps($qubaids); if ($lateststeps) { $this->progress->start_progress('', count($lateststeps), 1); // Compute the statistics of position, and for random questions, work // out which questions appear in which positions. foreach ($lateststeps as $step) { $this->progress->increment_progress(); $israndomquestion = ($step->questionid != $this->stats->for_slot($step->slot)->questionid); $breakdownvariants = !$israndomquestion && $this->stats->for_slot($step->slot)->break_down_by_variant(); // If this is a variant we have not seen before create a place to store stats calculations for this variant. if ($breakdownvariants && !$this->stats->has_slot($step->slot, $step->variant)) { $question = $this->stats->for_slot($step->slot)->question; $this->stats->initialise_for_slot($step->slot, $question, $step->variant); $this->stats->for_slot($step->slot, $step->variant)->randomguessscore = $this->get_random_guess_score($question); } // Step data walker for main question. $this->initial_steps_walker($step, $this->stats->for_slot($step->slot), $summarks, true, $breakdownvariants); // If this is a random question do the calculations for sub question stats. if ($israndomquestion) { if (!$this->stats->has_subq($step->questionid)) { $this->stats->initialise_for_subq($step); } else if ($this->stats->for_subq($step->questionid)->maxmark != $step->maxmark) { $this->stats->for_subq($step->questionid)->differentweights = true; } // If this is a variant of this subq we have not seen before create a place to store stats calculations for it. if (!$this->stats->has_subq($step->questionid, $step->variant)) { $this->stats->initialise_for_subq($step, $step->variant); } $this->initial_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks, false); // Extra stuff we need to do in this loop for subqs to keep track of where they need to be displayed later. $number = $this->stats->for_slot($step->slot)->question->number; $this->stats->for_subq($step->questionid)->usedin[$number] = $number; // Keep track of which random questions are actually selected from each pool of questions that random // questions are pulled from. $randomselectorstring = $this->stats->for_slot($step->slot)->random_selector_string(); if (!isset($this->randomselectors[$randomselectorstring])) { $this->randomselectors[$randomselectorstring] = array(); } $this->randomselectors[$randomselectorstring][$step->questionid] = $step->questionid; } } $this->progress->end_progress(); foreach ($this->randomselectors as $key => $notused) { ksort($this->randomselectors[$key]); $this->randomselectors[$key] = implode(',', $this->randomselectors[$key]); } $this->stats->subquestions = question_load_questions($this->stats->get_all_subq_ids()); // Compute the statistics for sub questions, if there are any. $this->progress->start_progress('', count($this->stats->subquestions), 1); foreach ($this->stats->subquestions as $qid => $subquestion) { $this->progress->increment_progress(); $subquestion->maxmark = $this->stats->for_subq($qid)->maxmark; $this->stats->for_subq($qid)->question = $subquestion; $this->stats->for_subq($qid)->randomguessscore = $this->get_random_guess_score($subquestion); if ($variants = $this->stats->for_subq($qid)->get_variants()) { foreach ($variants as $variant) { $this->stats->for_subq($qid, $variant)->question = $subquestion; $this->stats->for_subq($qid, $variant)->randomguessscore = $this->get_random_guess_score($subquestion); } $this->stats->for_subq($qid)->sort_variants(); } $this->initial_question_walker($this->stats->for_subq($qid)); if ($this->stats->for_subq($qid)->usedin) { sort($this->stats->for_subq($qid)->usedin, SORT_NUMERIC); $this->stats->for_subq($qid)->positions = implode(',', $this->stats->for_subq($qid)->usedin); } else { $this->stats->for_subq($qid)->positions = ''; } } $this->progress->end_progress(); // Finish computing the averages, and put the sub-question data into the // corresponding questions. $slots = $this->stats->get_all_slots(); $totalnumberofslots = count($slots); $maxindex = $totalnumberofslots - 1; $this->progress->start_progress('', $totalnumberofslots, 1); foreach ($slots as $index => $slot) { $this->stats->for_slot($slot)->sort_variants(); $this->progress->increment_progress(); $nextslotindex = $index + 1; $nextslot = ($nextslotindex > $maxindex) ? false : $slots[$nextslotindex]; $this->initial_question_walker($this->stats->for_slot($slot)); // The rest of this loop is to finish working out where randomly selected question stats should be displayed. if ($this->stats->for_slot($slot)->question->qtype == 'random') { $randomselectorstring = $this->stats->for_slot($slot)->random_selector_string(); if ($nextslot && ($randomselectorstring == $this->stats->for_slot($nextslot)->random_selector_string())) { continue; // Next loop iteration. } if (isset($this->randomselectors[$randomselectorstring])) { $this->stats->for_slot($slot)->subquestions = $this->randomselectors[$randomselectorstring]; } } } $this->progress->end_progress(); // Go through the records one more time. $this->progress->start_progress('', count($lateststeps), 1); foreach ($lateststeps as $step) { $this->progress->increment_progress(); $israndomquestion = ($this->stats->for_slot($step->slot)->question->qtype == 'random'); $this->secondary_steps_walker($step, $this->stats->for_slot($step->slot), $summarks); if ($israndomquestion) { $this->secondary_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks); } } $this->progress->end_progress(); $slots = $this->stats->get_all_slots(); $this->progress->start_progress('', count($slots), 1); $sumofcovariancewithoverallmark = 0; foreach ($this->stats->get_all_slots() as $slot) { $this->progress->increment_progress(); $this->secondary_question_walker($this->stats->for_slot($slot)); $this->sumofmarkvariance += $this->stats->for_slot($slot)->markvariance; $covariancewithoverallmark = $this->stats->for_slot($slot)->covariancewithoverallmark; if (null !== $covariancewithoverallmark && $covariancewithoverallmark >= 0) { $sumofcovariancewithoverallmark += sqrt($covariancewithoverallmark); } } $this->progress->end_progress(); $subqids = $this->stats->get_all_subq_ids(); $this->progress->start_progress('', count($subqids), 1); foreach ($subqids as $subqid) { $this->progress->increment_progress(); $this->secondary_question_walker($this->stats->for_subq($subqid)); } $this->progress->end_progress(); foreach ($this->stats->get_all_slots() as $slot) { if ($sumofcovariancewithoverallmark) { if ($this->stats->for_slot($slot)->negcovar) { $this->stats->for_slot($slot)->effectiveweight = null; } else { $this->stats->for_slot($slot)->effectiveweight = 100 * sqrt($this->stats->for_slot($slot)->covariancewithoverallmark) / $sumofcovariancewithoverallmark; } } else { $this->stats->for_slot($slot)->effectiveweight = null; } } $this->stats->cache($qubaids); } // All finished. $this->progress->end_progress(); return $this->stats; } /** * Used when computing Coefficient of Internal Consistency by quiz statistics. * * @return float */ public function get_sum_of_mark_variance() { return $this->sumofmarkvariance; } /** * Get the latest step data from the db, from which we will calculate stats. * * @param \qubaid_condition $qubaids Which question usages to get the latest steps for? * @return array with two items * - $lateststeps array of latest step data for the question usages * - $summarks array of total marks for each usage, indexed by usage id */ protected function get_latest_steps($qubaids) { $dm = new \question_engine_data_mapper(); $fields = " qas.id, qa.questionusageid, qa.questionid, qa.variant, qa.slot, qa.maxmark, qas.fraction * qa.maxmark as mark"; $lateststeps = $dm->load_questions_usages_latest_steps($qubaids, $this->stats->get_all_slots(), $fields); $summarks = array(); if ($lateststeps) { foreach ($lateststeps as $step) { if (!isset($summarks[$step->questionusageid])) { $summarks[$step->questionusageid] = 0; } $summarks[$step->questionusageid] += $step->mark; } } return array($lateststeps, $summarks); } /** * Calculating the stats is a four step process. * * We loop through all 'last step' data first. * * Update $stats->totalmarks, $stats->markarray, $stats->totalothermarks * and $stats->othermarksarray to include another state. * * @param object $step the state to add to the statistics. * @param calculated $stats the question statistics we are accumulating. * @param array $summarks of the sum of marks for each question usage, indexed by question usage id * @param bool $positionstat whether this is a statistic of position of question. * @param bool $dovariantalso do we also want to do the same calculations for this variant? */ protected function initial_steps_walker($step, $stats, $summarks, $positionstat = true, $dovariantalso = true) { $stats->s++; $stats->totalmarks += $step->mark; $stats->markarray[] = $step->mark; if ($positionstat) { $stats->totalothermarks += $summarks[$step->questionusageid] - $step->mark; $stats->othermarksarray[] = $summarks[$step->questionusageid] - $step->mark; } else { $stats->totalothermarks += $summarks[$step->questionusageid]; $stats->othermarksarray[] = $summarks[$step->questionusageid]; } if ($dovariantalso) { $this->initial_steps_walker($step, $stats->variantstats[$step->variant], $summarks, $positionstat, false); } } /** * Then loop through all questions for the first time. * * Perform some computations on the per-question statistics calculations after * we have been through all the step data. * * @param calculated $stats question stats to update. */ protected function initial_question_walker($stats) { if ($stats->s != 0) { $stats->markaverage = $stats->totalmarks / $stats->s; $stats->othermarkaverage = $stats->totalothermarks / $stats->s; $stats->summarksaverage = $stats->totalsummarks / $stats->s; } else { $stats->markaverage = 0; $stats->othermarkaverage = 0; $stats->summarksaverage = 0; } if ($stats->maxmark != 0) { $stats->facility = $stats->markaverage / $stats->maxmark; } else { $stats->facility = null; } sort($stats->markarray, SORT_NUMERIC); sort($stats->othermarksarray, SORT_NUMERIC); // Here we have collected enough data to make the decision about which questions have variants whose stats we also want to // calculate. We delete the initialised structures where they are not needed. if (!$stats->get_variants() || !$stats->break_down_by_variant()) { $stats->clear_variants(); } foreach ($stats->get_variants() as $variant) { $this->initial_question_walker($stats->variantstats[$variant]); } } /** * Loop through all last step data again. * * Now we know the averages, accumulate the date needed to compute the higher * moments of the question scores. * * @param object $step the state to add to the statistics. * @param calculated $stats the question statistics we are accumulating. * @param float[] $summarks of the sum of marks for each question usage, indexed by question usage id */ protected function secondary_steps_walker($step, $stats, $summarks) { $markdifference = $step->mark - $stats->markaverage; if ($stats->subquestion) { $othermarkdifference = $summarks[$step->questionusageid] - $stats->othermarkaverage; } else { $othermarkdifference = $summarks[$step->questionusageid] - $step->mark - $stats->othermarkaverage; } $overallmarkdifference = $summarks[$step->questionusageid] - $stats->summarksaverage; $sortedmarkdifference = array_shift($stats->markarray) - $stats->markaverage; $sortedothermarkdifference = array_shift($stats->othermarksarray) - $stats->othermarkaverage; $stats->markvariancesum += pow($markdifference, 2); $stats->othermarkvariancesum += pow($othermarkdifference, 2); $stats->covariancesum += $markdifference * $othermarkdifference; $stats->covariancemaxsum += $sortedmarkdifference * $sortedothermarkdifference; $stats->covariancewithoverallmarksum += $markdifference * $overallmarkdifference; if (isset($stats->variantstats[$step->variant])) { $this->secondary_steps_walker($step, $stats->variantstats[$step->variant], $summarks); } } /** * And finally loop through all the questions again. * * Perform more per-question statistics calculations. * * @param calculated $stats question stats to update. */ protected function secondary_question_walker($stats) { if ($stats->s > 1) { $stats->markvariance = $stats->markvariancesum / ($stats->s - 1); $stats->othermarkvariance = $stats->othermarkvariancesum / ($stats->s - 1); $stats->covariance = $stats->covariancesum / ($stats->s - 1); $stats->covariancemax = $stats->covariancemaxsum / ($stats->s - 1); $stats->covariancewithoverallmark = $stats->covariancewithoverallmarksum / ($stats->s - 1); $stats->sd = sqrt($stats->markvariancesum / ($stats->s - 1)); if ($stats->covariancewithoverallmark >= 0) { $stats->negcovar = 0; } else { $stats->negcovar = 1; } } else { $stats->markvariance = null; $stats->othermarkvariance = null; $stats->covariance = null; $stats->covariancemax = null; $stats->covariancewithoverallmark = null; $stats->sd = null; $stats->negcovar = 0; } if ($stats->markvariance * $stats->othermarkvariance) { $stats->discriminationindex = 100 * $stats->covariance / sqrt($stats->markvariance * $stats->othermarkvariance); } else { $stats->discriminationindex = null; } if ($stats->covariancemax) { $stats->discriminativeefficiency = 100 * $stats->covariance / $stats->covariancemax; } else { $stats->discriminativeefficiency = null; } foreach ($stats->variantstats as $variantstat) { $this->secondary_question_walker($variantstat); } } /** * Given the question data find the average grade that random guesses would get. * * @param object $questiondata the full question object. * @return float the random guess score for this question. */ protected function get_random_guess_score($questiondata) { return \question_bank::get_qtype( $questiondata->qtype, false)->get_random_guess_score($questiondata); } /** * Find time of non-expired statistics in the database. * * @param \qubaid_condition $qubaids Which question usages to look for? * @return int|bool Time of cached record that matches this qubaid_condition or false is non found. */ public function get_last_calculated_time($qubaids) { return $this->stats->get_last_calculated_time($qubaids); } /** * Load cached statistics from the database. * * @param \qubaid_condition $qubaids Which question usages to load the cached stats for? * @return all_calculated_for_qubaid_condition The cached stats. */ public function get_cached($qubaids) { $this->stats->get_cached($qubaids); return $this->stats; } } PK\R & &6classes/statistics/responses/analysis_for_question.phpnu[. /** * This file contains the code to analyse all the responses to a particular * question. * * @package core_question * @copyright 2013 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; defined('MOODLE_INTERNAL') || die(); /** * Analysis for possible responses for parts of a question. It is up to a question type designer to decide on how many parts their * question has. See {@link \question_type::get_possible_responses()} and sub classes where the sub parts and response classes are * defined. * * A sub part might represent a sub question embedded in the question for example in a matching question there are * several sub parts. A numeric question with a unit might be divided into two sub parts for the purposes of response analysis * or the question type designer might decide to treat the answer, both the numeric and unit part, * as a whole for the purposes of response analysis. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_question { /** * Constructor method. * * @param array[] Two index array, first index is unique string for each sub question part, * the second string index is the 'class' that sub-question part can be classified into. * Value in array is instance of {@link \question_possible_response} * This is the return value from {@link \question_type::get_possible_responses()} * see that method for fuller documentation. */ public function __construct(?array $possiblereponses = null) { if ($possiblereponses !== null) { $this->possibleresponses = $possiblereponses; } } /** * @var array[] See description above in constructor method. */ protected $possibleresponses = array(); /** * A multidimensional array whose first index is variant no and second index is subpart id, array contents are of type * {@link analysis_for_subpart}. * * @var array[] */ protected $subparts = array(); /** * Initialise data structure for response analysis of one variant. * * @param int $variantno */ protected function initialise_stats_for_variant($variantno) { $this->subparts[$variantno] = array(); foreach ($this->possibleresponses as $subpartid => $classes) { $this->subparts[$variantno][$subpartid] = new analysis_for_subpart($classes); } } /** * Variant nos found in this question's attempt data. * * @return int[] */ public function get_variant_nos() { return array_keys($this->subparts); } /** * Unique ids for sub parts. * * @param int $variantno * @return string[] */ public function get_subpart_ids($variantno) { return array_keys($this->subparts[$variantno]); } /** * Get the response counts etc. for variant $variantno, question sub part $subpartid. * * Or if there is no recorded analysis yet then initialise the data structure for that part of the analysis and return the * initialised analysis objects. * * @param int $variantno * @param string $subpartid id for sub part. * @return analysis_for_subpart */ public function get_analysis_for_subpart($variantno, $subpartid) { if (!isset($this->subparts[$variantno])) { $this->initialise_stats_for_variant($variantno); } if (!isset($this->subparts[$variantno][$subpartid])) { debugging('Unexpected sub-part id ' . $subpartid . ' encountered.'); $this->subparts[$variantno][$subpartid] = new analysis_for_subpart(); } return $this->subparts[$variantno][$subpartid]; } /** * Used to work out what kind of table is needed to display stats. * * @return bool whether this question has (a subpart with) more than one response class. */ public function has_multiple_response_classes() { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_multiple_response_classes()) { return true; } } } return false; } /** * Used to work out what kind of table is needed to display stats. * * @return bool whether this analysis has more than one subpart. */ public function has_subparts() { foreach ($this->get_variant_nos() as $variantno) { if (count($this->get_subpart_ids($variantno)) > 1) { return true; } } return false; } /** * @return bool Does this response analysis include counts for responses for multiple tries of the question? */ public function has_multiple_tries_data() { return false; } /** * What is the highest number of tries at this question? * * @return int always 1 as this class is for analysing only one try. */ public function get_maximum_tries() { return 1; } /** * Takes an array of {@link \question_classified_response} and adds counts of the responses to the sub parts and classes. * * @param int $variantno * @param \question_classified_response[] $responseparts keys are sub-part id. */ public function count_response_parts($variantno, $responseparts) { foreach ($responseparts as $subpartid => $responsepart) { $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart); } } /** * Save the analysis to the DB, first cleaning up any old ones. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int|null $calculationtime time when the analysis was done. (Defaults to time()). */ public function cache($qubaids, $whichtries, $questionid, $calculationtime = null) { global $DB; $transaction = $DB->start_delegated_transaction(); $analysisids = $DB->get_fieldset( 'question_response_analysis', 'id', [ 'hashcode' => $qubaids->get_hash_code(), 'whichtries' => $whichtries, 'questionid' => $questionid, ] ); if (!empty($analysisids)) { [$insql, $params] = $DB->get_in_or_equal($analysisids); $DB->delete_records_select('question_response_count', 'analysisid ' . $insql, $params); $DB->delete_records_select('question_response_analysis', 'id ' . $insql, $params); } foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { $analysisforsubpart = $this->get_analysis_for_subpart($variantno, $subpartid); $analysisforsubpart->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime); } } $transaction->allow_commit(); } /** * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_actual_responses()) { return true; } } } return false; } } PK\. /** * * Data structure to count responses for each of the sub parts of a question. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Representing the analysis of each of the sub parts of each variant of the question. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_subpart { /** * @var analysis_for_class[] */ protected $responseclasses; /** * Takes an array of possible_responses as returned from {@link \question_type::get_possible_responses()}. * * @param \question_possible_response[] $responseclasses as returned from {@link \question_type::get_possible_responses()}. */ public function __construct(?array $responseclasses = null) { if (is_array($responseclasses)) { foreach ($responseclasses as $responseclassid => $responseclass) { $this->responseclasses[$responseclassid] = new analysis_for_class($responseclass, $responseclassid); } } else { $this->responseclasses = []; } } /** * Unique ids for response classes. * * @return string[] */ public function get_response_class_ids() { return array_keys($this->responseclasses); } /** * Get the instance of the class handling the analysis of $classid for this sub part. * * @param string $classid id for response class. * @return analysis_for_class */ public function get_response_class($classid) { if (!isset($this->responseclasses[$classid])) { debugging('Unexpected class id ' . $classid . ' encountered.'); $this->responseclasses[$classid] = new analysis_for_class('[Unknown]', $classid); } return $this->responseclasses[$classid]; } /** * Whether there is more than one response class for responses in this question sub part? * * @return bool Are there? */ public function has_multiple_response_classes() { return count($this->get_response_class_ids()) > 1; } /** * Count a part of a response. * * @param \question_classified_response $subpart * @param int $try the try number or zero if not keeping track of try number */ public function count_response($subpart, $try = 0) { $responseanalysisforclass = $this->get_response_class($subpart->responseclassid); $responseanalysisforclass->count_response($subpart->response, $subpart->fraction, $try); } /** * Cache analysis for sub part. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part. * @param int|null $calculationtime time when the analysis was done. (Defaults to time()). */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime = null) { foreach ($this->get_response_class_ids() as $responseclassid) { $analysisforclass = $this->get_response_class($responseclassid); $analysisforclass->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime); } } /** * Has actual responses different to the model response for this class? * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { foreach ($this->get_response_class_ids() as $responseclassid) { if ($this->get_response_class($responseclassid)->has_actual_responses()) { return true; } } return false; } /** * What is the highest try number for this sub part? * * @return int max tries */ public function get_maximum_tries() { $max = 1; foreach ($this->get_response_class_ids() as $responseclassid) { $max = max($max, $this->get_response_class($responseclassid)->get_maximum_tries()); } return $max; } } PK\[{ 3classes/statistics/responses/analysis_for_class.phpnu[. /** * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Counts a class of responses for this sub part of the question. * * No response is one possible class of response to a question. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_class { /** * @var string must be unique for each response class within this sub part. */ protected $responseclassid; /** * @var string represent this class in the response analysis table. */ protected $modelresponse; /** @var string the (partial) credit awarded for this responses. */ protected $fraction; /** @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report. */ protected $actualresponses = array(); /** * Constructor, just an easy way to set the fields. * * @param \question_possible_response $possibleresponse * @param string $responseclassid */ public function __construct($possibleresponse, $responseclassid) { $this->modelresponse = $possibleresponse->responseclass; $this->fraction = $possibleresponse->fraction; $this->responseclassid = $responseclassid; } /** * Keep a count of a response to this question sub part that falls within this class. * * @param string $actualresponse * @param float|null $fraction * @param int $try * @return \core_question\statistics\responses\analysis_for_actual_response */ public function count_response($actualresponse, $fraction, $try) { if (!isset($this->actualresponses[$actualresponse])) { if ($fraction === null) { $fraction = $this->fraction; } $this->add_response($actualresponse, $fraction); } $this->get_response($actualresponse)->increment_count($try); } /** * Cache analysis for class. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part. * @param int|null $calculationtime time when the analysis was done. (Defaults to time()). */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime = null) { foreach ($this->get_responses() as $response) { $analysisforactualresponse = $this->get_response($response); $analysisforactualresponse->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $this->responseclassid, $calculationtime); } } /** * Add an actual response to the data structure. * * @param string $response A string representing the actual response. * @param float $fraction The fraction of grade awarded for this response. */ public function add_response($response, $fraction) { $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction); } /** * Used when loading cached counts. * * @param string $response * @param int $try the try number, will be zero if not keeping track of try. * @param int $count the count */ public function set_response_count($response, $try, $count) { $this->actualresponses[$response]->set_count($try, $count); } /** * Are there actual responses to sub parts that where classified into this class? * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { $actualresponses = $this->get_responses(); if (count($actualresponses) > 1) { return true; } else if (count($actualresponses) === 1) { $singleactualresponse = reset($actualresponses); return (string)$singleactualresponse !== (string)$this->modelresponse; } return false; } /** * Return the data to display in the response analysis table. * * @param bool $responseclasscolumn * @param string $partid * @return object[] */ public function data_for_question_response_table($responseclasscolumn, $partid) { $return = array(); if (count($this->get_responses()) == 0) { $rowdata = new \stdClass(); $rowdata->part = $partid; $rowdata->responseclass = $this->modelresponse; if (!$responseclasscolumn) { $rowdata->response = $this->modelresponse; } else { $rowdata->response = ''; } $rowdata->fraction = $this->fraction; $rowdata->totalcount = 0; $rowdata->trycount = array(); $return[] = $rowdata; } else { foreach ($this->get_responses() as $actualresponse) { $response = $this->get_response($actualresponse); $return[] = $response->data_for_question_response_table($partid, $this->modelresponse); } } return $return; } /** * What is the highest try number that an actual response of this response class has been seen? * * @return int try number */ public function get_maximum_tries() { $max = 1; foreach ($this->get_responses() as $actualresponse) { $max = max($max, $this->get_response($actualresponse)->get_maximum_tries()); } return $max; } /** * Return array of the actual responses to this sub part that were classified into this class. * * @return string[] the actual responses we are counting tries at. */ protected function get_responses() { return array_keys($this->actualresponses); } /** * Get the data structure used to count the responses that match an actual response within this class of responses. * * @param string $response * @return analysis_for_actual_response the instance for keeping count of tries for $response. */ protected function get_response($response) { return $this->actualresponses[$response]; } } PK\ r)classes/statistics/responses/analyser.phpnu[. /** * This file contains the code to analyse all the responses to a particular question. * * @package core_question * @copyright 2014 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; defined('MOODLE_INTERNAL') || die(); /** * This class can compute, store and cache the analysis of the responses to a particular question. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analyser { /** * @var int When analysing responses and breaking down the count of responses per try, how many columns should we break down * tries into? This is set to 5 columns, any response in a try more than try 5 will be counted in the fifth column. */ const MAX_TRY_COUNTED = 5; /** @var object full question data from db. */ protected $questiondata; /** * @var analysis_for_question|analysis_for_question_all_tries */ public $analysis; /** * @var int used during calculations, so all results are stored with the same timestamp. */ protected $calculationtime; /** * @var array Two index array first index is unique string for each sub question part, the second string index is the 'class' * that sub-question part can be classified into. * * This is the return value from {@link \question_type::get_possible_responses()} see that method for fuller documentation. */ public $responseclasses = array(); /** * @var bool whether to break down response analysis by variant. This only applies to questions that have variants and is * used to suppress the break down of analysis by variant when there are going to be very many variants. */ protected $breakdownbyvariant; /** * Create a new instance of this class for holding/computing the statistics * for a particular question. * * @param object $questiondata the full question data from the database defining this question. * @param string $whichtries which tries to analyse. */ public function __construct($questiondata, $whichtries = \question_attempt::LAST_TRY) { $this->questiondata = $questiondata; $qtypeobj = \question_bank::get_qtype($this->questiondata->qtype); if ($whichtries != \question_attempt::ALL_TRIES) { $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata)); } else { $this->analysis = new analysis_for_question_all_tries($qtypeobj->get_possible_responses($this->questiondata)); } $this->breakdownbyvariant = $qtypeobj->break_down_stats_and_response_analysis_by_variant($this->questiondata); } /** * Does the computed analysis have sub parts? * * @return bool whether this analysis has more than one subpart. */ public function has_subparts() { return count($this->responseclasses) > 1; } /** * Does the computed analysis's sub parts have classes? * * @return bool whether this analysis has (a subpart with) more than one response class. */ public function has_response_classes() { foreach ($this->responseclasses as $partclasses) { if (count($partclasses) > 1) { return true; } } return false; } /** * Analyse all the response data for all the specified attempts at this question. * * @param \qubaid_condition $qubaids which attempts to consider. * @param string $whichtries which tries to analyse. Will be one of * \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @return analysis_for_question */ public function calculate($qubaids, $whichtries = \question_attempt::LAST_TRY) { $this->calculationtime = time(); // Load data. $dm = new \question_engine_data_mapper(); $questionattempts = $dm->load_attempts_at_question($this->questiondata->id, $qubaids); // Analyse it. foreach ($questionattempts as $qa) { $responseparts = $qa->classify_response($whichtries); if ($this->breakdownbyvariant) { $this->analysis->count_response_parts($qa->get_variant(), $responseparts); } else { $this->analysis->count_response_parts(1, $responseparts); } } $this->analysis->cache($qubaids, $whichtries, $this->questiondata->id, $this->calculationtime); return $this->analysis; } /** * Retrieve the computed response analysis from the question_response_analysis table. * * @param \qubaid_condition $qubaids load the analysis of which question usages? * @param string $whichtries load the analysis of which tries? * @return analysis_for_question|boolean analysis or false if no cached analysis found. */ public function load_cached($qubaids, $whichtries) { global $DB; $timemodified = self::get_last_analysed_time($qubaids, $whichtries); // Variable name 'analyses' is the plural of 'analysis'. $responseanalyses = $DB->get_records('question_response_analysis', ['hashcode' => $qubaids->get_hash_code(), 'whichtries' => $whichtries, 'questionid' => $this->questiondata->id, 'timemodified' => $timemodified]); if (!$responseanalyses) { return false; } $analysisids = []; foreach ($responseanalyses as $responseanalysis) { $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); $class = $analysisforsubpart->get_response_class($responseanalysis->aid); $class->add_response($responseanalysis->response, $responseanalysis->credit); $analysisids[] = $responseanalysis->id; } [$sql, $params] = $DB->get_in_or_equal($analysisids); $counts = $DB->get_records_select('question_response_count', "analysisid {$sql}", $params); foreach ($counts as $count) { $responseanalysis = $responseanalyses[$count->analysisid]; $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); $class = $analysisforsubpart->get_response_class($responseanalysis->aid); $class->set_response_count($responseanalysis->response, $count->try, $count->rcount); } return $this->analysis; } /** * Find time of non-expired analysis in the database. * * @param \qubaid_condition $qubaids check for the analysis of which question usages? * @param string $whichtries check for the analysis of which tries? * @return integer|boolean Time of cached record that matches this qubaid_condition or false if none found. */ public function get_last_analysed_time($qubaids, $whichtries) { global $DB; return $DB->get_field('question_response_analysis', 'MAX(timemodified)', ['hashcode' => $qubaids->get_hash_code(), 'whichtries' => $whichtries, 'questionid' => $this->questiondata->id]); } } PK\J@classes/statistics/responses/analysis_for_question_all_tries.phpnu[. /** * This file contains a class to analyse all the responses for multiple tries at a particular question. * * @package core_question * @copyright 2014 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Analysis for possible responses for parts of a question with multiple submitted responses. * * If the analysis was for a single try it would be handled by {@link \core_question\statistics\responses\analysis_for_question}. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_question_all_tries extends analysis_for_question{ /** * Constructor. * * @param int $variantno variant number * @param \array[] $responsepartsforeachtry for question with multiple tries we expect an array with first index being try no * then second index is subpartid and values are \question_classified_response */ public function count_response_parts($variantno, $responsepartsforeachtry) { foreach ($responsepartsforeachtry as $try => $responseparts) { foreach ($responseparts as $subpartid => $responsepart) { $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart, $try); } } } public function has_multiple_tries_data() { return true; } /** * What is the highest number of tries at this question? * * @return int try number */ public function get_maximum_tries() { $max = 1; foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { $max = max($max, $this->get_analysis_for_subpart($variantno, $subpartid)->get_maximum_tries()); } } return $max; } } PK\݅vbb=classes/statistics/responses/analysis_for_actual_response.phpnu[. /** * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * The leafs of the analysis data structure. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_actual_response { /** * @var int[] count per try for this response. */ protected $trycount = array(); /** * @var int total count of tries with this response. */ protected $totalcount = 0; /** * @var float grade for this response, normally between 0 and 1. */ protected $fraction; /** * @var string the response as it will be displayed in report. */ protected $response; /** * @param string $response * @param float $fraction */ public function __construct($response, $fraction) { $this->response = $response; $this->fraction = $fraction; } /** * Used to count the occurrences of response sub parts. * * @param int $try the try number, or 0 if only keeping one count, not a count for each try. */ public function increment_count($try = 0) { $this->totalcount++; if ($try != 0) { if ($try > analyser::MAX_TRY_COUNTED) { $try = analyser::MAX_TRY_COUNTED; } if (!isset($this->trycount[$try])) { $this->trycount[$try] = 0; } $this->trycount[$try]++; } } /** * Used to set the count of occurrences of response sub parts, when loading count from cache. * * @param int $try the try number, or 0 if only keeping one count, not a count for each try. * @param int $count */ public function set_count($try, $count) { $this->totalcount = $this->totalcount + $count; $this->trycount[$try] = $count; } /** * Cache analysis for class. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part is this actual response in? * @param string $responseclassid which response class is this actual response in? * @param int|null $calculationtime time when the analysis was done. (Defaults to time()). */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid, $calculationtime = null) { global $DB; $row = new \stdClass(); $row->hashcode = $qubaids->get_hash_code(); $row->whichtries = $whichtries; $row->questionid = $questionid; $row->variant = $variantno; $row->subqid = $subpartid; if ($responseclassid === '') { $row->aid = null; } else { $row->aid = $responseclassid; } $row->response = $this->response; $row->credit = $this->fraction; $row->timemodified = $calculationtime ?? time(); $analysisid = $DB->insert_record('question_response_analysis', $row); if ($whichtries === \question_attempt::ALL_TRIES) { foreach ($this->trycount as $try => $count) { $countrow = new \stdClass(); $countrow->try = $try; $countrow->rcount = $count; $countrow->analysisid = $analysisid; $DB->insert_record('question_response_count', $countrow, false); } } else { $countrow = new \stdClass(); $countrow->try = 0; $countrow->rcount = $this->totalcount; $countrow->analysisid = $analysisid; $DB->insert_record('question_response_count', $countrow, false); } } /** * Returns an object with a property for each column of the question response analysis table. * * @param string $partid * @param string $modelresponse * @return object */ public function data_for_question_response_table($partid, $modelresponse) { $rowdata = new \stdClass(); $rowdata->part = $partid; $rowdata->responseclass = $modelresponse; $rowdata->response = $this->response; $rowdata->fraction = $this->fraction; $rowdata->totalcount = $this->totalcount; $rowdata->trycount = $this->trycount; return $rowdata; } /** * What is the highest try number that this response has been seen? * * @return int try number */ public function get_maximum_tries() { return max(array_keys($this->trycount)); } } PK\q;;(templates/question_version_info.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_version_info Displays a badge containing the version information of a question * versioninfo - The version information string, including the number and whether it is the latest verision. Example context (json): { "versioninfo": "Version 2 (latest)" } }} {{versioninfo}} PK\Q'bb'templates/switch_question_bank.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/switch_question_bank Example context (json): { "quizname": "Quiz 1", "quizcmid": 1, "quizcontextid": 1, "hascoursesharedbanks": true, "coursesharedbanks": [ { "name": "Question bank 1", "modid": "2", "contextid": 2, "coursenamebankname": "c1 - Question bank 1", "cminfo": {}, "questioncategories": [] }, { "name": "Question bank 2", "modid": "3", "contextid": 3, "coursenamebankname": "c1 - Question bank 2", "cminfo": {}, "questioncategories": [] } ], "hasrecentlyviewedbanks": true, "recentlyviewedbanks": [ { "name": "Question bank 3", "modid": "4", "contextid": 4, "coursenamebankname": "c2 - Question bank 4", "cminfo": {}, "questioncategories": [] }, { "name": "Question bank 4", "modid": "6", "contextid": 6, "coursenamebankname": "c3 - Question bank 5", "cminfo": {}, "questioncategories": [] } ], "hassharedbanks": true, "sharedbanks": [ { "name": "Question bank 1", "modid": "2", "contextid": 2, "coursenamebankname": "c1 - Question bank 1", "cminfo": {}, "questioncategories": [] }, { "name": "Question bank 2", "modid": "3", "contextid": 3, "coursenamebankname": "c1 - Question bank 2", "cminfo": {}, "questioncategories": [] }, { "name": "Question bank 3", "modid": "4", "contextid": 4, "coursenamebankname": "c2 - Question bank 4", "cminfo": {}, "questioncategories": [] }, { "name": "Question bank 4", "modid": "6", "contextid": 6, "coursenamebankname": "c3 - Question bank 5", "cminfo": {}, "questioncategories": [] } ] } }}
    {{#str}}quizquestionbank, core_question{{/str}}
    {{quizname}}

    {{#hascoursesharedbanks}}
    {{#str}}banksincourse, core_question{{/str}}
    {{#coursesharedbanks}} {{/coursesharedbanks}}

    {{/hascoursesharedbanks}} {{#hasrecentlyviewedbanks}}
    {{#str}}recentlyviewedquestionbanks, core_question{{/str}}
    {{#recentlyviewedbanks}} {{/recentlyviewedbanks}}

    {{/hasrecentlyviewedbanks}}
    {{#str}}otherquestionbank, core_question{{/str}}
    PK\FFh h &templates/question_banks_list.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_banks_list Example context (json): { "purpose": "content", "iconurl": {}, "modname": "Course 1 course question bank", "description": "
    Description<\/div>", "managequestions": { "id": "action_link669a882ce8ec613", "disabled": false, "text": "Course 1 course question bank", "url": "example/index.php?id=2", "icon": null, "classes": "", "attributes": [], "actions": [], "hasactions": false }, "managebank": { "instance": 1, "classes": "moodle-actionmenu", "attributes": [ { "name": "id", "value": "action-menu-1" }, { "name": "data-enhance", "value": "moodle-core-actionmenu" } ], "primary": { "title": "", "prioritise": false, "classes": "menubar", "attributes": [ { "name": "id", "value": "action-menu-1-menubar" } ], "triggerattributes": [], "menutrigger": "<\/i>", "triggerextraclasses": "btn btn-icon d-flex no-caret ", "triggerrole": "button", "iconraw": "", "actiontext": "", "items": [] }, "secondary": { "classes": "menu", "attributes": [ { "name": "id", "value": "action-menu-1-menu" }, { "name": "data-rel", "value": "menu-content" }, { "name": "aria-labelledby", "value": "action-menu-toggle-1" }, { "name": "role", "value": "menu" } ], "items": [ { "simpleitem": false, "actionmenulink": { "id": "action_link669a882ce8ec67", "disabled": false, "text": "Edit settings", "url": "https:example.com/course/mod.php?sesskey=abcde12345&update=2", "icon": { "key": "t\/edit", "component": "moodle", "title": "" }, "classes": "editing_update menu-action", "attributes": [ { "name": "data-action", "value": "update" }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } }, { "simpleitem": false, "actionmenulink": { "id": "action_link669a882ce8ec611", "disabled": false, "text": "Assign roles", "url": "https:example.com/admin/roles/assign.php?contextid=16", "icon": { "key": "t\/assignroles", "component": "moodle", "title": "" }, "classes": "editing_assign menu-action", "attributes": [ { "name": "data-action", "value": "assignroles" }, { "name": "data-sectionreturn", "value": null }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } }, { "simpleitem": false, "actionmenulink": { "id": "action_link669a882ce8ec612", "disabled": false, "text": "Delete", "url": "https:example.com/course/mod.php?sesskey=abcde12345&delete=2", "icon": { "key": "t\/delete", "component": "moodle", "title": "" }, "classes": "editing_delete text-danger menu-action", "attributes": [ { "name": "data-action", "value": "cmDelete" }, { "name": "data-sectionreturn", "value": null }, { "name": "data-id", "value": "2" }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } } ] }, "dropdownalignment": "dropdown-menu-right" } } }}
    • {{#managequestions}} {{> core/action_link}} {{/managequestions}}
      {{#managebank}} {{> core/action_menu}} {{/managebank}}
      {{{description}}}
    PK\S44&templates/question_pagination.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_pagination Example context (json): { "sortdata": [ { "extraclasses": "classes", "pagination": "pagination html", "showall": "true/false", "showallurl": "moodle url", "biggertotal": "true/false", "totalnumber": "number of questions" } ] } }}
    {{{pagination}}}
    {{#showall}} {{/showall}} PK\stgg"templates/showtext_option.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/showtext_option Example context (json): { "selected1": true } }}
    PK\;##-templates/question_version_selection.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_version_selection The version selection for question bank. * options - Versions of the question * uniqueidentifier - identifier to target the change from js Example context (json): { "options": [{ "name": "Version 1", "selected": true, "value": "10" }, { "name": "Version 2", "selected": false, "value": "20" }], "uniqueidentifier": "question_comment_version_selection" } }} PK\ȓ)templates/question_bank_controls.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_bank_controls Flex container for displaying plugin controls at the top of the question bank table. Context variables required for this template: * controls - Rendered HTML for controls. Example context (json): { "controls": "
    " } }}
    {{{controls}}}
    PK\i~00templates/view_banks.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/view_banks Example context (json): { "addqbank": { "id": "single_button669e2ffb6fafe23", "formid": null, "method": "post", "url": "/course/modedit.php", "label": "Add", "classes": "singlebutton", "disabled": false, "tooltip": null, "type": "primary", "attributes": [], "params": [ { "name": "add", "value": "qbank" }, { "name": "course", "value": "1" }, { "name": "section", "value": "0" }, { "name": "return", "value": "0" }, { "name": "sr", "value": "0" }, { "name": "beforemod", "value": "0" }, { "name": "sesskey", "value": "abcde1234" } ], "actions": [], "hasactions": false }, "hassharedbanks": true, "sharedbanks": [ { "purpose": "content", "iconurl": {}, "modname": "Question bank 1", "description": "
    Description<\/div>", "managequestions": { "id": "action_link669e2ffb6fafe13", "disabled": false, "text": "Question bank 1", "url": "/mod/qbank/view.php?id=1", "icon": null, "classes": "", "attributes": [], "actions": [], "hasactions": false }, "managebank": { "instance": 1, "classes": "moodle-actionmenu", "attributes": [ { "name": "id", "value": "action-menu-1" }, { "name": "data-enhance", "value": "moodle-core-actionmenu" } ], "primary": { "title": "", "prioritise": false, "classes": "menubar", "attributes": [ { "name": "id", "value": "action-menu-1-menubar" } ], "triggerattributes": [], "menutrigger": "<\/i>", "triggerextraclasses": "btn btn-icon d-flex no-caret ", "triggerrole": "button", "iconraw": "", "actiontext": "", "items": [] }, "secondary": { "classes": "menu", "attributes": [ { "name": "id", "value": "action-menu-1-menu" }, { "name": "data-rel", "value": "menu-content" }, { "name": "aria-labelledby", "value": "action-menu-toggle-1" }, { "name": "role", "value": "menu" } ], "items": [ { "simpleitem": false, "actionmenulink": { "id": "action_link669e2ffb6fafe7", "disabled": false, "text": "Edit settings", "url": "/course/mod.php?sesskey=abcde12345&update=2", "icon": { "key": "t\/edit", "component": "moodle", "title": "" }, "classes": "editing_update menu-action", "attributes": [ { "name": "data-action", "value": "update" }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } }, { "simpleitem": false, "actionmenulink": { "id": "action_link669e2ffb6fafe11", "disabled": false, "text": "Assign roles", "url": "/admin/roles/assign.php?contextid=1", "icon": { "key": "t\/assignroles", "component": "moodle", "title": "" }, "classes": "editing_assign menu-action", "attributes": [ { "name": "data-action", "value": "assignroles" }, { "name": "data-sectionreturn", "value": null }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } }, { "simpleitem": false, "actionmenulink": { "id": "action_link669e2ffb6fafe12", "disabled": false, "text": "Delete", "url": "/course/mod.php?sesskey=abcde12345&delete=2", "icon": { "key": "t\/delete", "component": "moodle", "title": "" }, "classes": "editing_delete text-danger menu-action", "attributes": [ { "name": "data-action", "value": "cmDelete" }, { "name": "data-sectionreturn", "value": null }, { "name": "data-id", "value": "2" }, { "name": "role", "value": "menuitem" }, { "name": "tabindex", "value": "-1" } ], "actions": [], "hasactions": false, "showtext": true } } ] }, "dropdownalignment": "dropdown-menu-right" } } ], "hasprivatebanks": true, "privatebanks": [ { "purpose": "assessment", "iconurl": {}, "modname": "Quiz 1", "description": "", "managequestions": { "id": "action_link669e2ffb6fafe21", "disabled": false, "text": "Quiz 1", "url": "/mod/quiz/view.php?id=1", "icon": null, "classes": "", "attributes": [], "actions": [], "hasactions": false }, "managebank": null } ], "addcustombanks": [], "createdefault": { "id": "single_button669e2ffb6fafe24", "formid": null, "method": "post", "url": "/question/banks.php", "label": "Create default question bank", "classes": "singlebutton", "disabled": false, "tooltip": null, "type": "secondary", "attributes": [], "params": [ { "name": "courseid", "value": "1" }, { "name": "createdefault", "value": "1" }, { "name": "sesskey", "value": "abcde12345" } ], "actions": [], "hasactions": false } } }}

    {{#str}}banksincourse, question{{/str}} {{#addqbank}}
    {{> core/single_button }}
    {{/addqbank}}

    {{#addcustombanks}}
    • {{> core/action_link}}
    {{/addcustombanks}} {{^sharedbanks}}
    {{#str}}nobanks, question{{/str}} {{#createdefault}}
    {{> core/single_button }}
    {{/createdefault}}
    {{/sharedbanks}} {{#hassharedbanks}}
    {{#sharedbanks}} {{< core_question/question_banks_list}} {{/core_question/question_banks_list}} {{/sharedbanks}}
    {{/hassharedbanks}} {{#hasprivatebanks}}

    {{#str}}otherbanks, question{{/str}}

    {{#str}}otherbanksdesc, question{{/str}}

    {{#privatebanks}} {{< core_question/question_banks_list}} {{/core_question/question_banks_list}} {{/privatebanks}}
    {{/hasprivatebanks}} PK\\s-*templates/question_bank_filter_ui.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/qbank_filter Template for the form containing one or more filter rows. Example context (json): { "tableregionid": "qbank-table", "courseid": "1", "categoryid": "2", "perpage": "100", "contextid": "3", "quizcmid": "4", "component": "core_quiz", "callback": "get_question_data", "view": "\\core\\question\\local\\bank\\view", "cmid": "4", "pagevars": "[]", "extraparams": "[]" } }}
    {{> core/datafilter/filter}}
    {{#js}} require(['core_question/filter'], function(Filter) { Filter.init( {{#quote}}core-filter-{{uniqid}}{{/quote}}, {{courseid}}, {{categoryid}}, {{perpage}}, {{contextid}}, {{quizcmid}}, {{#quote}}{{component}}{{/quote}}, {{#quote}}{{callback}}{{/quote}}, {{#quote}}{{view}}{{/quote}}, {{cmid}}, {{{pagevars}}}, {{{extraparams}}}, ); }); {{/js}} PK\S templates/column_sort.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/column_sort Example context (json): { "sortdata": [ { "sorturl": "url", "sortname": "Sort name", "sortorder": "Sort order when activate this sort", "sorttip": "Sort according to first name", "sortcontent": "Sort title sort icon" } ] } }} {{{sortcontent}}} PK\g"templates/bulk_actions_ui.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/bulk_actions_ui Example context (json): { "displaydata": [ { "bulkactionitems": "", "actionname": "Move to", "actionkey": "deleteselected", "actionurl": "/question/bank/bulkmove/move.php?courseid=2" } ] } }}
    PK\lntM M $templates/qbank_action_menu.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/qbank_action_menu Question bank tertiary navigtion. Classes required for JS: * none Context variables required for this template: * see question/classes/output/qbank_actionbar.php Example context (json): { "questionbankselect": { "action": "http://localhost/moodle/course/jumpto.php", "classes": "urlselect", "formid": "questionbankaction", "id": "url_select61a85bc543dca7", "label": "Question bank tertiary navigation", "options": [{ "name": "Questions", "selected": true, "value": "/question/edit.php?cmid=7" }, { "name": "Categories", "selected": false, "value": "/question/bank/managecategories/category.php?cmid=7" }, { "name": "Import", "selected": false, "value": "/question/bank/importquestions/import.php?cmid=7" }, { "name": "Export", "selected": false, "value": "/question/bank/exportquestions/export.php?cmid=7" } ] }, "actionbutton": { "url": "http://localhost/moodle/question/bank/managecategories/addcategory.php?cmid=71", "label": "Add category" } } }}
    {{#questionbankselect}} {{/questionbankselect}} {{#actionbutton}} {{/actionbutton}}
    PK\\3SS-templates/question_category_selector.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/question_category_selector Template for core_question\output\question_category_selector Example context (json): { "categories": [ { "value": "0", "disabled": true, "label": "Bank name 1" }, { "value": "123,456", "selected": true, "label": "Category 1" }, { "value": "124,456", "label": "Category 2" } ] } }}
    PK\EV   templates/column_header.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template core_question/column_header Displays the header cell for a column in the question bank. Context variables required for this template: * extraclasses: CSS classes to add to the th. E.g. "questionstatus pe-3". * help: (optional, may be omitted) help icon. The structure here needs to be as from help_icon::export_for_template(). Other fields vary, depending on whether the column is sortable. Column with plain title, not sortable: * sortable: false * tiptitle: Column name (e.g. "Status") * sorttip: (optional) must be true if tip is present. * tip: (optional) a tool tip to show, if the title needs explanation. E.g. "Select questions for bulk actions". Sortable column, with a single type of sort (e.g. question type) * sortable: true * sortlinks: HTML of the title, in a link to sort by this. E.g. "\n T<\/i>\n<\/a>" Sortable column, with an overall title, and multiple sort types (e.g. question name/idnumber) * sortable: true * title: "Question" * sortlinks: Several sort links concatenated. E.g. "\n Question name\n<\/a> \/ \n ID number\n<\/a>" Example context (json): { "sortable": false, "extraclasses": "checkbox pe-1", "tiptitle": "

    ' . $description; if (!$quizattemptid) { $quizattemptid = $this->attemptid; } if ($quizattemptid) { $message .= ' (Review this attempt)'; } $message .= "

    \n"; fwrite($this->handle, $message); } public function __destruct() { fwrite($this->handle, ''); fclose($this->handle); } } /** * Subclass of question_engine_assumption_logger that does nothing, for testing. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class dummy_question_engine_assumption_logger extends question_engine_assumption_logger { protected $attemptid; public function __construct() { } public function log_assumption($description, $quizattemptid = null) { } public function __destruct() { } } PK\8,|Iengine/bank.phpnu[. /** * More object oriented wrappers around parts of the Moodle question bank. * * In due course, I expect that the question bank will be converted to a * fully object oriented structure, at which point this file can be a * starting point. * * @package moodlecore * @subpackage questionbank * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\output\notification; use core_cache\application_cache; use core_cache\data_source_interface; use core_cache\definition; use core_question\local\bank\question_version_status; use core_question\output\question_version_info; use qbank_previewquestion\question_preview_options; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../type/questiontypebase.php'); /** * This static class provides access to the other question bank. * * It provides functions for managing question types and question definitions. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_bank { // TODO: This limit can be deleted if someday we move all TEXTS to BIG ones. MDL-19603 const MAX_SUMMARY_LENGTH = 32000; /** @var array question type name => question_type subclass. */ private static $questiontypes = array(); /** @var array question type name => 1. Records which question definitions have been loaded. */ private static $loadedqdefs = array(); /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */ private static $testmode = false; private static $testdata = array(); private static $questionconfig = null; /** * @var array string => string The standard set of grade options (fractions) * to use when editing questions, in the range 0 to 1 inclusive. Array keys * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't * have float array keys in PHP. * Initialised by {@link ensure_grade_options_initialised()}. */ private static $fractionoptions = null; /** @var array string => string The full standard set of (fractions) -1 to 1 inclusive. */ private static $fractionoptionsfull = null; /** * @param string $qtypename a question type name, e.g. 'multichoice'. * @return bool whether that question type is installed in this Moodle. */ public static function is_qtype_installed($qtypename) { $plugindir = core_component::get_plugin_directory('qtype', $qtypename); return $plugindir && is_readable($plugindir . '/questiontype.php'); } /** * Check if a given question type is one that is installed and usable. * * Use this before doing things like rendering buttons/options which will only work for * installed question types. * * When loaded through most of the core_question areas, qtype will still be the uninstalled type, e.g. 'mytype', * but when we get to the quiz pages, it will have been converted to 'missingtype'. So we need to check that * as well here. * * @param string $qtypename e.g. 'multichoice'. * @return bool */ public static function is_qtype_usable(string $qtypename): bool { return self::is_qtype_installed($qtypename) && $qtypename !== 'missingtype'; } /** * Get the question type class for a particular question type. * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. * @param bool $mustexist if false, the missing question type is returned when * the requested question type is not installed. * @return question_type the corresponding question type class. */ public static function get_qtype($qtypename, $mustexist = true) { global $CFG; if (isset(self::$questiontypes[$qtypename])) { return self::$questiontypes[$qtypename]; } $file = core_component::get_plugin_directory('qtype', $qtypename) . '/questiontype.php'; if (!is_readable($file)) { if ($mustexist || $qtypename == 'missingtype') { throw new coding_exception('Unknown question type ' . $qtypename); } else { return self::get_qtype('missingtype'); } } include_once($file); $class = 'qtype_' . $qtypename; if (!class_exists($class)) { throw new coding_exception("Class {$class} must be defined in {$file}."); } self::$questiontypes[$qtypename] = new $class(); return self::$questiontypes[$qtypename]; } /** * Load the question configuration data from config_plugins. * @return object get_config('question') with caching. */ public static function get_config() { if (is_null(self::$questionconfig)) { self::$questionconfig = get_config('question'); } return self::$questionconfig; } /** * @param string $qtypename the internal name of a question type. For example multichoice. * @return bool whether users are allowed to create questions of this type. */ public static function qtype_enabled($qtypename) { $config = self::get_config(); $enabledvar = $qtypename . '_disabled'; return self::qtype_exists($qtypename) && empty($config->$enabledvar) && self::get_qtype($qtypename)->menu_name() != ''; } /** * @param string $qtypename the internal name of a question type. For example multichoice. * @return bool whether this question type exists. */ public static function qtype_exists($qtypename) { return array_key_exists($qtypename, core_component::get_plugin_list('qtype')); } /** * @param $qtypename the internal name of a question type, for example multichoice. * @return string the human_readable name of this question type, from the language pack. */ public static function get_qtype_name($qtypename) { return self::get_qtype($qtypename)->local_name(); } /** * @return array all the installed question types. */ public static function get_all_qtypes() { $qtypes = array(); foreach (core_component::get_plugin_list('qtype') as $plugin => $notused) { try { $qtypes[$plugin] = self::get_qtype($plugin); } catch (coding_exception $e) { // Catching coding_exceptions here means that incompatible // question types do not cause the rest of Moodle to break. } } return $qtypes; } /** * Sort an array of question types according to the order the admin set up, * and then alphabetically for the rest. * @param array qtype->name() => qtype->local_name(). * @return array sorted array. */ public static function sort_qtype_array($qtypes, $config = null) { if (is_null($config)) { $config = self::get_config(); } $sortorder = array(); $otherqtypes = array(); foreach ($qtypes as $name => $localname) { $sortvar = $name . '_sortorder'; if (isset($config->$sortvar)) { $sortorder[$config->$sortvar] = $name; } else { $otherqtypes[$name] = $localname; } } ksort($sortorder); core_collator::asort($otherqtypes); $sortedqtypes = array(); foreach ($sortorder as $name) { $sortedqtypes[$name] = $qtypes[$name]; } foreach ($otherqtypes as $name => $notused) { $sortedqtypes[$name] = $qtypes[$name]; } return $sortedqtypes; } /** * @return array all the question types that users are allowed to create, * sorted into the preferred order set on the admin screen. */ public static function get_creatable_qtypes() { $config = self::get_config(); $allqtypes = self::get_all_qtypes(); $qtypenames = array(); foreach ($allqtypes as $name => $qtype) { if (self::qtype_enabled($name)) { $qtypenames[$name] = $qtype->local_name(); } } $qtypenames = self::sort_qtype_array($qtypenames); $creatableqtypes = array(); foreach ($qtypenames as $name => $notused) { $creatableqtypes[$name] = $allqtypes[$name]; } return $creatableqtypes; } /** * Load the question definition class(es) belonging to a question type. That is, * include_once('/question/type/' . $qtypename . '/question.php'), with a bit * of checking. * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. */ public static function load_question_definition_classes($qtypename) { global $CFG; if (isset(self::$loadedqdefs[$qtypename])) { return; } $file = $CFG->dirroot . '/question/type/' . $qtypename . '/question.php'; if (!is_readable($file)) { throw new coding_exception('Unknown question type (no definition) ' . $qtypename); } include_once($file); self::$loadedqdefs[$qtypename] = 1; } /** * This method needs to be called whenever a question is edited. */ public static function notify_question_edited($questionid) { question_finder::get_instance()->uncache_question($questionid); } /** * Load a question definition data from the database. The data will be * returned as a plain stdClass object. * @param int $questionid the id of the question to load. * @return object question definition loaded from the database. */ public static function load_question_data($questionid) { return question_finder::get_instance()->load_question_data($questionid); } /** * Load a question definition from the database. The object returned * will actually be of an appropriate {@link question_definition} subclass. * @param int $questionid the id of the question to load. * @param bool $allowshuffle if false, then any shuffle option on the selected * quetsion is disabled. * @return question_definition loaded from the database. */ public static function load_question($questionid, $allowshuffle = true) { if (self::$testmode) { // Evil, test code in production, but no way round it. return self::return_test_question_data($questionid); } $questiondata = self::load_question_data($questionid); if (!$allowshuffle) { $questiondata->options->shuffleanswers = false; } return self::make_question($questiondata); } /** * Convert the question information loaded with {@link get_question_options()} * to a question_definintion object. * @param object $questiondata raw data loaded from the database. * @return question_definition loaded from the database. */ public static function make_question($questiondata) { $definition = self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false); question_version_info::$pendingdefinitions[$definition->id] = $definition; return $definition; } /** * Render a throw-away preview of a question. * * If the question cannot be rendered (e.g. because it is not installed) * then display a message instead. * * @param question_definition $question a question. * @return string HTML to output. */ public static function render_preview_of_question(question_definition $question): string { global $DB, $OUTPUT, $USER; if (!self::is_qtype_usable($question->qtype->name())) { // TODO MDL-84902 ideally this would be changed to render at least the qeuestion text. // See, for example, test_render_missing in question/type/missingtype/tests/missingtype_test.php. return $OUTPUT->notification( get_string('invalidquestiontype', 'question', $question->qtype->name()), notification::NOTIFY_WARNING, closebutton: false); } // TODO MDL-84902 remove this dependency on a class from qbank_previewquestion plugin. if (!class_exists(question_preview_options::class)) { debugging('Preview cannot be rendered. The standard plugin ' . 'qbank_previewquestion plugin has been removed.', DEBUG_DEVELOPER); return ''; } $quba = question_engine::make_questions_usage_by_activity( 'core_question_preview', context_user::instance($USER->id)); $options = new question_preview_options($question); $quba->set_preferred_behaviour($options->behaviour); $slot = $quba->add_question($question, $options->maxmark); $quba->start_question($slot, $options->variant); $transaction = $DB->start_delegated_transaction(); question_engine::save_questions_usage_by_activity($quba); $transaction->allow_commit(); return $quba->render_question($slot, $options, '1'); } /** * Get all the versions of a particular question. * * @param int $questionid id of the question * @return array The array keys are version number, and the values are objects with three int fields * version (same as array key), versionid and questionid. */ public static function get_all_versions_of_question(int $questionid): array { global $DB; $sql = "SELECT qv.id AS versionid, qv.version, qv.questionid FROM {question_versions} qv WHERE qv.questionbankentryid = (SELECT DISTINCT qbe.id FROM {question_bank_entries} qbe JOIN {question_versions} qv ON qbe.id = qv.questionbankentryid JOIN {question} q ON qv.questionid = q.id WHERE q.id = ?) ORDER BY qv.version DESC"; return $DB->get_records_sql($sql, [$questionid]); } /** * Get all the versions of questions. * * @param array $questionids Array of question ids. * @return array two dimensional array question_bank_entries.id => version number => question.id. * Versions in descending order. */ public static function get_all_versions_of_questions(array $questionids): array { global $DB; [$listquestionid, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED); $sql = "SELECT qv.questionid, qv.version, qv.questionbankentryid FROM {question_versions} qv JOIN {question_versions} qv2 ON qv.questionbankentryid = qv2.questionbankentryid WHERE qv2.questionid $listquestionid ORDER BY qv.questionbankentryid, qv.version DESC"; $result = []; $rows = $DB->get_recordset_sql($sql, $params); foreach ($rows as $row) { $result[$row->questionbankentryid][$row->version] = $row->questionid; } return $result; } /** * Retrieves version information for a list of questions. * * @param array $questionids Array of question ids. * @return array An array question_bank_entries.id => version number => question.id. */ public static function get_version_of_questions(array $questionids): array { global $DB; [$listquestionid, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED); $sql = "SELECT qv.questionid, qv.version, qv.questionbankentryid FROM {question_versions} qv JOIN {question_bank_entries} qbe ON qv.questionbankentryid = qbe.id WHERE qv.questionid $listquestionid ORDER BY qv.version DESC"; $rows = $DB->get_recordset_sql($sql, $params); $result = []; foreach ($rows as $row) { $result[$row->questionbankentryid][$row->version] = $row->questionid; } return $result; } /** * @return question_finder a question finder. */ public static function get_finder() { return question_finder::get_instance(); } /** * Only to be called from unit tests. Allows {@link load_test_data()} to be used. */ public static function start_unit_test() { self::$testmode = true; } /** * Only to be called from unit tests. Allows {@link load_test_data()} to be used. */ public static function end_unit_test() { self::$testmode = false; self::$testdata = array(); } private static function return_test_question_data($questionid) { if (!isset(self::$testdata[$questionid])) { throw new coding_exception('question_bank::return_test_data(' . $questionid . ') called, but no matching question has been loaded by load_test_data.'); } return self::$testdata[$questionid]; } /** * To be used for unit testing only. Will throw an exception if * {@link start_unit_test()} has not been called first. * @param object $questiondata a question data object to put in the test data store. */ public static function load_test_question_data(question_definition $question) { if (!self::$testmode) { throw new coding_exception('question_bank::load_test_data called when ' . 'not in test mode.'); } self::$testdata[$question->id] = $question; } protected static function ensure_fraction_options_initialised() { if (!is_null(self::$fractionoptions)) { return; } // define basic array of grades. This list comprises all fractions of the form: // a. p/q for q <= 6, 0 <= p <= q // b. p/10 for 0 <= p <= 10 // c. 1/q for 1 <= q <= 10 // d. 1/20 $rawfractions = array( 0.9000000, 0.8333333, 0.8000000, 0.7500000, 0.7000000, 0.6666667, 0.6000000, 0.5000000, 0.4000000, 0.3333333, 0.3000000, 0.2500000, 0.2000000, 0.1666667, 0.1428571, 0.1250000, 0.1111111, 0.1000000, 0.0500000, ); // Put the None option at the top. self::$fractionoptions = array( '0.0' => get_string('none'), '1.0' => '100%', ); self::$fractionoptionsfull = array( '0.0' => get_string('none'), '1.0' => '100%', ); // The the positive grades in descending order. foreach ($rawfractions as $fraction) { $percentage = format_float(100 * $fraction, 5, true, true) . '%'; self::$fractionoptions["{$fraction}"] = $percentage; self::$fractionoptionsfull["{$fraction}"] = $percentage; } // The the negative grades in descending order. foreach (array_reverse($rawfractions) as $fraction) { self::$fractionoptionsfull['' . (-$fraction)] = format_float(-100 * $fraction, 5, true, true) . '%'; } self::$fractionoptionsfull['-1.0'] = '-100%'; } /** * @return array string => string The standard set of grade options (fractions) * to use when editing questions, in the range 0 to 1 inclusive. Array keys * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't * have float array keys in PHP. * Initialised by {@link ensure_grade_options_initialised()}. */ public static function fraction_options() { self::ensure_fraction_options_initialised(); return self::$fractionoptions; } /** @return array string => string The full standard set of (fractions) -1 to 1 inclusive. */ public static function fraction_options_full() { self::ensure_fraction_options_initialised(); return self::$fractionoptionsfull; } /** * Return a list of the different question types present in the given categories. * * @param array $categories a list of category ids * @return array the list of question types in the categories * @since Moodle 3.1 */ public static function get_all_question_types_in_categories($categories) { global $DB; list($categorysql, $params) = $DB->get_in_or_equal($categories); $sql = "SELECT DISTINCT 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 $categorysql"; $qtypes = $DB->get_fieldset_sql($sql, $params); return $qtypes; } } /** * Class for loading questions according to various criteria. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_finder implements data_source_interface { /** @var question_finder the singleton instance of this class. */ protected static $questionfinder = null; /** * @return question_finder a question finder. */ public static function get_instance() { if (is_null(self::$questionfinder)) { self::$questionfinder = new question_finder(); } return self::$questionfinder; } #[\Override] public static function get_instance_for_cache(definition $definition) { return self::get_instance(); } /** * @return application_cache the question definition cache we are using. */ protected function get_data_cache() { // Do not double cache here because it may break cache resetting. return cache::make('core', 'questiondata'); } /** * This method needs to be called whenever a question is edited. */ public function uncache_question($questionid) { $this->get_data_cache()->delete($questionid); } /** * Load a question definition data from the database. The data will be * returned as a plain stdClass object. * @param int $questionid the id of the question to load. * @return object question definition loaded from the database. */ public function load_question_data($questionid) { return $this->get_data_cache()->get($questionid); } /** * Get the ids of all the questions in a list of categories. * @param array $categoryids either a category id, or a comma-separated list * of category ids, or an array of them. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @return array questionid => questionid. */ public function get_questions_from_categories($categoryids, $extraconditions, $extraparams = array()) { global $DB; list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); if ($extraconditions) { $extraconditions = ' AND (' . $extraconditions . ')'; } $qcparams['readystatus'] = question_version_status::QUESTION_STATUS_READY; $qcparams['readystatusqv'] = question_version_status::QUESTION_STATUS_READY; $sql = "SELECT q.id, q.id AS id2 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 {$qcsql} AND q.parent = 0 AND qv.status = :readystatus AND qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id AND v.status = :readystatusqv) {$extraconditions}"; return $DB->get_records_sql_menu($sql, $qcparams + $extraparams); } /** * Get the ids of all the questions in a list of categories, with the number * of times they have already been used in a given set of usages. * * The result array is returned in order of increasing (count previous uses). * * @param array $categoryids an array question_category ids. * @param qubaid_condition $qubaids which question_usages to count previous uses from. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @return array questionid => count of number of previous uses. * * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function get_questions_from_categories_with_usage_counts($categoryids, qubaid_condition $qubaids, $extraconditions = '', $extraparams = array()) { debugging( 'Function get_questions_from_categories_with_usage_counts() is deprecated, please do not use the function.', DEBUG_DEVELOPER ); return $this->get_questions_from_categories_and_tags_with_usage_counts( $categoryids, $qubaids, $extraconditions, $extraparams); } /** * Get the ids of all the questions in a list of categories that have ALL the provided tags, * with the number of times they have already been used in a given set of usages. * * The result array is returned in order of increasing (count previous uses). * * @param array $categoryids an array of question_category ids. * @param qubaid_condition $qubaids which question_usages to count previous uses from. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @param array $tagids an array of tag ids * @return array questionid => count of number of previous uses. * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function get_questions_from_categories_and_tags_with_usage_counts($categoryids, qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) { debugging( 'Function get_questions_from_categories_and_tags_with_usage_counts() is deprecated, please do not use the function.', DEBUG_DEVELOPER ); global $DB; list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); $readystatus = question_version_status::QUESTION_STATUS_READY; $select = "q.id, (SELECT COUNT(1) FROM " . $qubaids->from_question_attempts('qa') . " WHERE qa.questionid = q.id AND " . $qubaids->where() . " ) AS previous_attempts"; $from = "{question} q"; $join = "JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid"; $from = $from . " " . $join; $where = "qbe.questioncategoryid {$qcsql} AND q.parent = 0 AND qv.status = '$readystatus' AND qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id)"; $params = $qcparams; if (!empty($tagids)) { // We treat each additional tag as an AND condition rather than // an OR condition. // // For example, if the user filters by the tags "foo" and "bar" then // we reduce the question list to questions that are tagged with both // "foo" AND "bar". Any question that does not have ALL of the specified // tags will be omitted. list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED, 'ti'); $tagparams['tagcount'] = count($tagids); $tagparams['questionitemtype'] = 'question'; $tagparams['questioncomponent'] = 'core_question'; $where .= " AND q.id IN (SELECT ti.itemid FROM {tag_instance} ti WHERE ti.itemtype = :questionitemtype AND ti.component = :questioncomponent AND ti.tagid {$tagsql} GROUP BY ti.itemid HAVING COUNT(itemid) = :tagcount)"; $params += $tagparams; } if ($extraconditions) { $extraconditions = ' AND (' . $extraconditions . ')'; } return $DB->get_records_sql_menu("SELECT $select FROM $from WHERE $where $extraconditions ORDER BY previous_attempts", $qubaids->from_where_params() + $params + $extraparams); } #[\Override] public function load_for_cache($questionid) { global $DB; $sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, q.length, q.stamp, q.timecreated, q.timemodified, q.createdby, q.modifiedby, qbe.idnumber, qc.contextid, qv.status, qv.id as versionid, qv.version, qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = :id'; $questiondata = $DB->get_record_sql($sql, ['id' => $questionid], MUST_EXIST); get_question_options($questiondata); return $questiondata; } #[\Override] public function load_many_for_cache(array $questionids) { global $DB; list($idcondition, $params) = $DB->get_in_or_equal($questionids); $sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, q.length, q.stamp, q.timecreated, q.timemodified, q.createdby, q.modifiedby, qbe.idnumber, qc.contextid, qv.status, qv.id as versionid, qv.version, qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id '; $questiondata = $DB->get_records_sql($sql . $idcondition, $params); foreach ($questionids as $id) { if (!array_key_exists($id, $questiondata)) { throw new dml_missing_record_exception('question', '', ['id' => $id]); } get_question_options($questiondata[$id]); } return $questiondata; } } PK\>H+WW-engine/tests/questionattemptiterator_test.phpnu[. namespace core_question; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../lib.php'); require_once(__DIR__ . '/helpers.php'); /** * This file contains tests for the {@link question_attempt_iterator} class. * * @package core_question * @category test * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class questionattemptiterator_test extends \advanced_testcase { private $quba; private $qas = array(); private $iterator; protected function setUp(): void { parent::setUp(); $this->quba = question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()); $this->quba->set_preferred_behaviour('deferredfeedback'); $slot = $this->quba->add_question(\test_question_maker::make_question('description')); $this->qas[$slot] = $this->quba->get_question_attempt($slot); $slot = $this->quba->add_question(\test_question_maker::make_question('description')); $this->qas[$slot] = $this->quba->get_question_attempt($slot); $this->iterator = $this->quba->get_attempt_iterator(); } protected function tearDown(): void { $this->quba = null; $this->iterator = null; parent::tearDown(); } public function test_foreach_loop(): void { $i = 1; foreach ($this->iterator as $key => $qa) { $this->assertEquals($i, $key); $this->assertSame($this->qas[$i], $qa); $i++; } $this->assertEquals(3, $i); } public function test_offsetExists_before_start(): void { $this->assertFalse(isset($this->iterator[0])); } public function test_offsetExists_at_start(): void { $this->assertTrue(isset($this->iterator[1])); } public function test_offsetExists_at_endt(): void { $this->assertTrue(isset($this->iterator[2])); } public function test_offsetExists_past_end(): void { $this->assertFalse(isset($this->iterator[3])); } public function test_offsetGet_before_start(): void { $this->expectException(\moodle_exception::class); $step = $this->iterator[0]; } public function test_offsetGet_at_start(): void { $this->assertSame($this->qas[1], $this->iterator[1]); } public function test_offsetGet_at_end(): void { $this->assertSame($this->qas[2], $this->iterator[2]); } public function test_offsetGet_past_end(): void { $this->expectException(\moodle_exception::class); $step = $this->iterator[3]; } public function test_cannot_set(): void { $this->expectException(\moodle_exception::class); $this->iterator[0] = null; } public function test_cannot_unset(): void { $this->expectException(\moodle_exception::class); unset($this->iterator[2]); } } PK\52$$#engine/tests/questionstate_test.phpnu[. namespace core_question; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../lib.php'); require_once($CFG->libdir . '/questionlib.php'); /** * Unit tests for the {@link question_state} class and subclasses. * * @package core_question * @category test * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \question_state */ final class questionstate_test extends \advanced_testcase { public function test_is_active(): void { $this->assertFalse(question_state::$notstarted->is_active()); $this->assertFalse(question_state::$unprocessed->is_active()); $this->assertTrue(question_state::$todo->is_active()); $this->assertTrue(question_state::$invalid->is_active()); $this->assertTrue(question_state::$complete->is_active()); $this->assertFalse(question_state::$needsgrading->is_active()); $this->assertFalse(question_state::$finished->is_active()); $this->assertFalse(question_state::$gaveup->is_active()); $this->assertFalse(question_state::$gradedwrong->is_active()); $this->assertFalse(question_state::$gradedpartial->is_active()); $this->assertFalse(question_state::$gradedright->is_active()); $this->assertFalse(question_state::$manfinished->is_active()); $this->assertFalse(question_state::$mangaveup->is_active()); $this->assertFalse(question_state::$mangrwrong->is_active()); $this->assertFalse(question_state::$mangrpartial->is_active()); $this->assertFalse(question_state::$mangrright->is_active()); } public function test_is_finished(): void { $this->assertFalse(question_state::$notstarted->is_finished()); $this->assertFalse(question_state::$unprocessed->is_finished()); $this->assertFalse(question_state::$todo->is_finished()); $this->assertFalse(question_state::$invalid->is_finished()); $this->assertFalse(question_state::$complete->is_finished()); $this->assertTrue(question_state::$needsgrading->is_finished()); $this->assertTrue(question_state::$finished->is_finished()); $this->assertTrue(question_state::$gaveup->is_finished()); $this->assertTrue(question_state::$gradedwrong->is_finished()); $this->assertTrue(question_state::$gradedpartial->is_finished()); $this->assertTrue(question_state::$gradedright->is_finished()); $this->assertTrue(question_state::$manfinished->is_finished()); $this->assertTrue(question_state::$mangaveup->is_finished()); $this->assertTrue(question_state::$mangrwrong->is_finished()); $this->assertTrue(question_state::$mangrpartial->is_finished()); $this->assertTrue(question_state::$mangrright->is_finished()); } public function test_is_graded(): void { $this->assertFalse(question_state::$notstarted->is_graded()); $this->assertFalse(question_state::$unprocessed->is_graded()); $this->assertFalse(question_state::$todo->is_graded()); $this->assertFalse(question_state::$invalid->is_graded()); $this->assertFalse(question_state::$complete->is_graded()); $this->assertFalse(question_state::$needsgrading->is_graded()); $this->assertFalse(question_state::$finished->is_graded()); $this->assertFalse(question_state::$gaveup->is_graded()); $this->assertTrue(question_state::$gradedwrong->is_graded()); $this->assertTrue(question_state::$gradedpartial->is_graded()); $this->assertTrue(question_state::$gradedright->is_graded()); $this->assertFalse(question_state::$manfinished->is_graded()); $this->assertFalse(question_state::$mangaveup->is_graded()); $this->assertTrue(question_state::$mangrwrong->is_graded()); $this->assertTrue(question_state::$mangrpartial->is_graded()); $this->assertTrue(question_state::$mangrright->is_graded()); } public function test_is_commented(): void { $this->assertFalse(question_state::$notstarted->is_commented()); $this->assertFalse(question_state::$unprocessed->is_commented()); $this->assertFalse(question_state::$todo->is_commented()); $this->assertFalse(question_state::$invalid->is_commented()); $this->assertFalse(question_state::$complete->is_commented()); $this->assertFalse(question_state::$needsgrading->is_commented()); $this->assertFalse(question_state::$finished->is_commented()); $this->assertFalse(question_state::$gaveup->is_commented()); $this->assertFalse(question_state::$gradedwrong->is_commented()); $this->assertFalse(question_state::$gradedpartial->is_commented()); $this->assertFalse(question_state::$gradedright->is_commented()); $this->assertTrue(question_state::$manfinished->is_commented()); $this->assertTrue(question_state::$mangaveup->is_commented()); $this->assertTrue(question_state::$mangrwrong->is_commented()); $this->assertTrue(question_state::$mangrpartial->is_commented()); $this->assertTrue(question_state::$mangrright->is_commented()); } public function test_graded_state_for_fraction(): void { $this->assertEquals(question_state::$gradedwrong, question_state::graded_state_for_fraction(-1)); $this->assertEquals(question_state::$gradedwrong, question_state::graded_state_for_fraction(0)); $this->assertEquals(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.000001)); $this->assertEquals(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.999999)); $this->assertEquals(question_state::$gradedright, question_state::graded_state_for_fraction(1)); } public function test_manually_graded_state_for_other_state(): void { $this->assertEquals(question_state::$manfinished, question_state::$finished->corresponding_commented_state(null)); $this->assertEquals(question_state::$mangaveup, question_state::$gaveup->corresponding_commented_state(null)); $this->assertEquals(question_state::$manfinished, question_state::$manfinished->corresponding_commented_state(null)); $this->assertEquals(question_state::$mangaveup, question_state::$mangaveup->corresponding_commented_state(null)); $this->assertEquals(question_state::$needsgrading, question_state::$mangrright->corresponding_commented_state(null)); $this->assertEquals(question_state::$needsgrading, question_state::$mangrright->corresponding_commented_state(null)); $this->assertEquals(question_state::$mangrwrong, question_state::$gaveup->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$needsgrading->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$gradedwrong->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$gradedpartial->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$gradedright->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$mangrright->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$mangrpartial->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrwrong, question_state::$mangrright->corresponding_commented_state(0)); $this->assertEquals(question_state::$mangrpartial, question_state::$gradedpartial->corresponding_commented_state(0.5)); $this->assertEquals(question_state::$mangrright, question_state::$gradedpartial->corresponding_commented_state(1)); } public function test_get(): void { $this->assertEquals(question_state::$todo, question_state::get('todo')); } public function test_get_bad_data(): void { question_state::get(''); $this->assertDebuggingCalled('Attempt to create a state from an empty string. ' . 'This is probably a sign of bad data in your database. See MDL-80127.'); } } PK\%engine/tests/helpers.phpnu[. /** * This file contains helper classes for testing the question engine. * * @package moodlecore * @subpackage questionengine * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../lib.php'); require_once($CFG->dirroot . '/lib/phpunit/lib.php'); /** * Makes some protected methods of question_attempt public to facilitate testing. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_question_attempt extends question_attempt { public function add_step(question_attempt_step $step) { parent::add_step($step); } public function set_min_fraction($fraction) { $this->minfraction = $fraction; } public function set_max_fraction($fraction) { $this->maxfraction = $fraction; } public function set_behaviour(question_behaviour $behaviour) { $this->behaviour = $behaviour; } } /** * Test subclass to allow access to some protected data so that the correct * behaviour can be verified. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_question_engine_unit_of_work extends question_engine_unit_of_work { public function get_modified() { return $this->modified; } public function get_attempts_added() { return $this->attemptsadded; } public function get_attempts_modified() { return $this->attemptsmodified; } public function get_steps_added() { return $this->stepsadded; } public function get_steps_modified() { return $this->stepsmodified; } public function get_steps_deleted() { return $this->stepsdeleted; } public function get_metadata_added() { return $this->metadataadded; } public function get_metadata_modified() { return $this->metadatamodified; } } /** * Base class for question type test helpers. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_test_helper { /** * @return array of example question names that can be passed as the $which * argument of {@link test_question_maker::make_question} when $qtype is * this question type. */ abstract public function get_test_questions(); /** * Set up a form to create a question in $cat. This method also sets cat and contextid on $questiondata object. * @param object $cat the category * @param object $questiondata form initialisation requires question data. * @return moodleform */ public static function get_question_editing_form($cat, $questiondata) { $catcontext = context::instance_by_id($cat->contextid, MUST_EXIST); $contexts = new core_question\local\bank\question_edit_contexts($catcontext); $dataforformconstructor = new stdClass(); $dataforformconstructor->createdby = $questiondata->createdby; $dataforformconstructor->qtype = $questiondata->qtype; $dataforformconstructor->contextid = $questiondata->contextid = $catcontext->id; $dataforformconstructor->category = $questiondata->category = $cat->id; $dataforformconstructor->status = $questiondata->status; $dataforformconstructor->formoptions = new stdClass(); $dataforformconstructor->formoptions->canmove = true; $dataforformconstructor->formoptions->cansaveasnew = true; $dataforformconstructor->formoptions->canedit = true; $dataforformconstructor->formoptions->repeatelements = true; $qtype = question_bank::get_qtype($questiondata->qtype); return $qtype->create_editing_form('question.php', $dataforformconstructor, $cat, $contexts, true); } } /** * This class creates questions of various types, which can then be used when * testing. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_question_maker { const STANDARD_OVERALL_CORRECT_FEEDBACK = 'Well done!'; const STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK = 'Parts, but only parts, of your response are correct.'; const STANDARD_OVERALL_INCORRECT_FEEDBACK = 'That is not right at all.'; /** @var array qtype => qtype test helper class. */ protected static $testhelpers = array(); /** * Just make a question_attempt at a question. Useful for unit tests that * need to pass a $qa to methods that call format_text. Probably not safe * to use for anything beyond that. * @param question_definition $question a question. * @param number $maxmark the max mark to set. * @return question_attempt the question attempt. */ public static function get_a_qa($question, $maxmark = 3) { return new question_attempt($question, 13, null, $maxmark); } /** * Initialise the common fields of a question of any type. */ public static function initialise_a_question($q) { global $USER; $q->id = 0; $q->category = 0; $q->idnumber = null; $q->parent = 0; $q->questiontextformat = FORMAT_HTML; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; $q->modifiedby = $USER->id; } public static function initialise_question_data($qdata) { global $USER; $qdata->id = 0; $qdata->category = 0; $qdata->idnumber = null; $qdata->contextid = 0; $qdata->parent = 0; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 1; $qdata->penalty = 0.3333333; $qdata->length = 1; $qdata->stamp = make_unique_id_code(); $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->version = 1; $qdata->timecreated = time(); $qdata->timemodified = time(); $qdata->createdby = $USER->id; $qdata->modifiedby = $USER->id; $qdata->hints = array(); } /** * Get the test helper class for a particular question type. * @param $qtype the question type name, e.g. 'multichoice'. * @return question_test_helper the test helper class. */ public static function get_test_helper($qtype) { global $CFG; if (array_key_exists($qtype, self::$testhelpers)) { return self::$testhelpers[$qtype]; } $file = core_component::get_plugin_directory('qtype', $qtype) . '/tests/helper.php'; if (!is_readable($file)) { throw new coding_exception('Question type ' . $qtype . ' does not have test helper code.'); } include_once($file); $class = 'qtype_' . $qtype . '_test_helper'; if (!class_exists($class)) { throw new coding_exception('Class ' . $class . ' is not defined in ' . $file); } self::$testhelpers[$qtype] = new $class(); return self::$testhelpers[$qtype]; } /** * Call a method on a qtype_{$qtype}_test_helper class and return the result. * * @param string $methodtemplate e.g. 'make_{qtype}_question_{which}'; * @param string $qtype the question type to get a test question for. * @param string $which one of the names returned by the get_test_questions * method of the relevant qtype_{$qtype}_test_helper class. * @param unknown_type $which */ protected static function call_question_helper_method($methodtemplate, $qtype, $which = null) { $helper = self::get_test_helper($qtype); $available = $helper->get_test_questions(); if (is_null($which)) { $which = reset($available); } else if (!in_array($which, $available)) { throw new coding_exception('Example question ' . $which . ' of type ' . $qtype . ' does not exist.'); } $method = str_replace(array('{qtype}', '{which}'), array($qtype, $which), $methodtemplate); if (!method_exists($helper, $method)) { throw new coding_exception('Method ' . $method . ' does not exist on the ' . $qtype . ' question type test helper class.'); } return $helper->$method(); } /** * Question types can provide a number of test question defintions. * They do this by creating a qtype_{$qtype}_test_helper class that extends * question_test_helper. The get_test_questions method returns the list of * test questions available for this question type. * * @param string $qtype the question type to get a test question for. * @param string $which one of the names returned by the get_test_questions * method of the relevant qtype_{$qtype}_test_helper class. * @return question_definition the requested question object. */ public static function make_question($qtype, $which = null) { return self::call_question_helper_method('make_{qtype}_question_{which}', $qtype, $which); } /** * Like {@link make_question()} but returns the datastructure from * get_question_options instead of the question_definition object. * * @param string $qtype the question type to get a test question for. * @param string $which one of the names returned by the get_test_questions * method of the relevant qtype_{$qtype}_test_helper class. * @return stdClass the requested question object. */ public static function get_question_data($qtype, $which = null) { return self::call_question_helper_method('get_{qtype}_question_data_{which}', $qtype, $which); } /** * Like {@link make_question()} but returns the data what would be saved from * the question editing form instead of the question_definition object. * * @param string $qtype the question type to get a test question for. * @param string $which one of the names returned by the get_test_questions * method of the relevant qtype_{$qtype}_test_helper class. * @return stdClass the requested question object. */ public static function get_question_form_data($qtype, $which = null) { return self::call_question_helper_method('get_{qtype}_question_form_data_{which}', $qtype, $which); } /** * Makes a multichoice question with choices 'A', 'B' and 'C' shuffled. 'A' * is correct, defaultmark 1. * @return qtype_multichoice_single_question */ public static function make_a_multichoice_single_question() { question_bank::load_question_definition_classes('multichoice'); $mc = new qtype_multichoice_single_question(); self::initialise_a_question($mc); $mc->name = 'Multi-choice question, single response'; $mc->questiontext = 'The answer is A.'; $mc->generalfeedback = 'You should have selected A.'; $mc->qtype = question_bank::get_qtype('multichoice'); $mc->shuffleanswers = 1; $mc->answernumbering = 'abc'; $mc->showstandardinstruction = 0; $mc->answers = array( 13 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML), 14 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML), 15 => new question_answer(15, 'C', -0.3333333, 'C is wrong', FORMAT_HTML), ); return $mc; } /** * Makes a multichoice question with choices 'A', 'B', 'C' and 'D' shuffled. * 'A' and 'C' is correct, defaultmark 1. * @return qtype_multichoice_multi_question */ public static function make_a_multichoice_multi_question() { question_bank::load_question_definition_classes('multichoice'); $mc = new qtype_multichoice_multi_question(); self::initialise_a_question($mc); $mc->name = 'Multi-choice question, multiple response'; $mc->questiontext = 'The answer is A and C.'; $mc->generalfeedback = 'You should have selected A and C.'; $mc->qtype = question_bank::get_qtype('multichoice'); $mc->shuffleanswers = 1; $mc->answernumbering = 'abc'; $mc->showstandardinstruction = 0; self::set_standard_combined_feedback_fields($mc); $mc->answers = array( 13 => new question_answer(13, 'A', 0.5, 'A is part of the right answer', FORMAT_HTML), 14 => new question_answer(14, 'B', -1, 'B is wrong', FORMAT_HTML), 15 => new question_answer(15, 'C', 0.5, 'C is part of the right answer', FORMAT_HTML), 16 => new question_answer(16, 'D', -1, 'D is wrong', FORMAT_HTML), ); return $mc; } /** * Makes a matching question to classify 'Dog', 'Frog', 'Toad' and 'Cat' as * 'Mammal', 'Amphibian' or 'Insect'. * defaultmark 1. Stems are shuffled by default. * @return qtype_match_question */ public static function make_a_matching_question() { return self::make_question('match'); } /** * Makes a truefalse question with correct ansewer true, defaultmark 1. * @return qtype_essay_question */ public static function make_an_essay_question() { question_bank::load_question_definition_classes('essay'); $essay = new qtype_essay_question(); self::initialise_a_question($essay); $essay->name = 'Essay question'; $essay->questiontext = 'Write an essay.'; $essay->generalfeedback = 'I hope you wrote an interesting essay.'; $essay->penalty = 0; $essay->qtype = question_bank::get_qtype('essay'); $essay->responseformat = 'editor'; $essay->responserequired = 1; $essay->responsefieldlines = 15; $essay->attachments = 0; $essay->attachmentsrequired = 0; $essay->responsetemplate = ''; $essay->responsetemplateformat = FORMAT_MOODLE; $essay->graderinfo = ''; $essay->graderinfoformat = FORMAT_MOODLE; return $essay; } /** * Add some standard overall feedback to a question. You need to use these * specific feedback strings for the corresponding contains_..._feedback * methods in {@link qbehaviour_walkthrough_test_base} to works. * @param question_definition|stdClass $q the question to add the feedback to. */ public static function set_standard_combined_feedback_fields($q) { $q->correctfeedback = self::STANDARD_OVERALL_CORRECT_FEEDBACK; $q->correctfeedbackformat = FORMAT_HTML; $q->partiallycorrectfeedback = self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK; $q->partiallycorrectfeedbackformat = FORMAT_HTML; $q->shownumcorrect = true; $q->incorrectfeedback = self::STANDARD_OVERALL_INCORRECT_FEEDBACK; $q->incorrectfeedbackformat = FORMAT_HTML; } /** * Add some standard overall feedback to a question's form data. */ public static function set_standard_combined_feedback_form_data($form) { $form->correctfeedback = array('text' => self::STANDARD_OVERALL_CORRECT_FEEDBACK, 'format' => FORMAT_HTML); $form->partiallycorrectfeedback = array('text' => self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK, 'format' => FORMAT_HTML); $form->shownumcorrect = true; $form->incorrectfeedback = array('text' => self::STANDARD_OVERALL_INCORRECT_FEEDBACK, 'format' => FORMAT_HTML); } } /** * Helper for tests that need to simulate records loaded from the database. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class testing_db_record_builder { public static function build_db_records(array $table) { $columns = array_shift($table); $records = array(); foreach ($table as $row) { if (count($row) != count($columns)) { throw new coding_exception("Row contains the wrong number of fields."); } $rec = new stdClass(); foreach ($columns as $i => $name) { $rec->$name = $row[$i]; } $records[] = $rec; } return $records; } } /** * Helper base class for tests that need to simulate records loaded from the * database. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class data_loading_method_test_base extends advanced_testcase { public function build_db_records(array $table) { return testing_db_record_builder::build_db_records($table); } } abstract class question_testcase extends advanced_testcase { /** * Tolerance accepted in some unit tests when float operations are involved. */ const GRADE_DELTA = 0.00000005; public function assert($expectation, $compare, $notused = '') { if (get_class($expectation) === 'question_pattern_expectation') { $this->assertMatchesRegularExpression($expectation->pattern, $compare, 'Expected regex ' . $expectation->pattern . ' not found in ' . $compare); return; } else if (get_class($expectation) === 'question_no_pattern_expectation') { $this->assertDoesNotMatchRegularExpression($expectation->pattern, $compare, 'Unexpected regex ' . $expectation->pattern . ' found in ' . $compare); return; } else if (get_class($expectation) === 'question_contains_tag_with_attributes') { $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->expectedvalues), $compare, 'Looking for a ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->expectedvalues) . ' in ' . $compare); foreach ($expectation->forbiddenvalues as $k=>$v) { $attr = $expectation->expectedvalues; $attr[$k] = $v; $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare, $expectation->tag . ' had a ' . $k . ' attribute that should not be there in ' . $compare); } return; } else if (get_class($expectation) === 'question_contains_tag_with_attribute') { $attr = array($expectation->attribute=>$expectation->value); $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare, 'Looking for a ' . $expectation->tag . ' with attribute ' . html_writer::attributes($attr) . ' in ' . $compare); return; } else if (get_class($expectation) === 'question_does_not_contain_tag_with_attributes') { $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->attributes), $compare, 'Unexpected ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->attributes) . ' found in ' . $compare); return; } else if (get_class($expectation) === 'question_contains_select_expectation') { $tag = array('tag'=>'select', 'attributes'=>array('name'=>$expectation->name), 'children'=>array('count'=>count($expectation->choices))); if ($expectation->enabled === false) { $tag['attributes']['disabled'] = 'disabled'; } else if ($expectation->enabled === true) { // TODO } foreach(array_keys($expectation->choices) as $value) { if ($expectation->selected === $value) { $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value, 'selected'=>'selected')); } else { $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value)); } } $this->assertTag($tag, $compare, 'expected select not found in ' . $compare); return; } else if (get_class($expectation) === 'question_check_specified_fields_expectation') { $expect = (array)$expectation->expect; $compare = (array)$compare; foreach ($expect as $k=>$v) { if (!array_key_exists($k, $compare)) { $this->fail("Property {$k} does not exist"); } if ($v != $compare[$k]) { $this->fail("Property {$k} is different"); } } $this->assertTrue(true); return; } else if (get_class($expectation) === 'question_contains_tag_with_contents') { $this->assertTag(array('tag'=>$expectation->tag, 'content'=>$expectation->content), $compare, 'Looking for a ' . $expectation->tag . ' with content ' . $expectation->content . ' in ' . $compare); return; } throw new coding_exception('Unknown expectiontion:'.get_class($expectation)); } /** * Use this function rather than assert when checking the value of options within a select element. * * @param question_contains_select_expectation $expectation The select expectation class * @param string $html The rendered output to check against */ public function assert_select_options($expectation, $html) { if (get_class($expectation) !== 'question_contains_select_expectation') { throw new coding_exception('Unsuitable expectiontion: '.get_class($expectation)); } $dom = new DOMDocument(); $dom->loadHTML($html); $selects = $dom->getElementsByTagName('select'); $this->assertGreaterThanOrEqual(1, $selects->count(), 'There is no '], ], ]; $errors = $form->validation($submitteddata, []); $this->assertArrayNotHasKey('choices[0]', $errors); $this->assertArrayNotHasKey('choices[1]', $errors); $this->assertArrayNotHasKey('choices[2]', $errors); $this->assertEquals('<textarea> is not allowed. ' . '(Only <sub>, <sup>, <b>, <i>, ' . '<em>, <strong>, <span> are permitted.)', $errors['choices[3]']); } } PK\V*dd+type/ddwtos/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package qtype_ddwtos * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_ddwtos\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\user_preference_provider; use qtype_ddwtos\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ddwtos/classes/privacy/provider.php'); /** * Privacy provider tests class. * * @package qtype_ddwtos * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_ddwtos'); $actual = \qtype_ddwtos\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_ddwtos_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_ddwtos'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_ddwtos_{$key}", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_ddwtos'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the multiple choice question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'default mark 2' => ['defaultmark', 2, 2], 'penalty 33.33333%' => ['penalty', 0.3333333, '33.33333%'], 'shuffle yes' => ['shuffleanswers', 1, 'Yes'], 'shuffle no' => ['shuffleanswers', 0, 'No'] ]; } } PK\ה77(type/ddwtos/tests/question_type_test.phpnu[. namespace qtype_ddwtos; use question_answer; use question_bank; use question_hint_with_parts; use question_possible_response; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddwtos/tests/helper.php'); /** * Unit tests for the drag-and-drop words into sentences question definition class. * * @package qtype_ddwtos * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \question_testcase { /** @var qtype_ddwtos instance of the question type class to test. */ protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = question_bank::get_qtype('ddwtos');; } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } /** * Get some test question data. * * @return object the data to construct a question like * {@link qtype_ddwtos_test_helper::make_ddwtos_question_fox()}. */ protected function get_test_question_data() { global $USER; $dd = new \stdClass(); $dd->id = 0; $dd->category = 0; $dd->contextid = 0; $dd->parent = 0; $dd->questiontextformat = FORMAT_HTML; $dd->generalfeedbackformat = FORMAT_HTML; $dd->defaultmark = 1; $dd->penalty = 0.3333333; $dd->length = 1; $dd->stamp = make_unique_id_code(); $dd->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $dd->version = 1; $dd->versionid = 0; $dd->questionbankentryid = 0; $dd->idnumber = null; $dd->timecreated = time(); $dd->timemodified = time(); $dd->createdby = $USER->id; $dd->modifiedby = $USER->id; $dd->name = 'Drag-and-drop words into sentences question'; $dd->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = 'ddwtos'; $dd->options = new \stdClass(); $dd->options->shuffleanswers = true; \test_question_maker::set_standard_combined_feedback_fields($dd->options); $dd->options->answers = array( (object) array('answer' => 'quick', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"1";s:8:"infinite";i:0;}'), (object) array('answer' => 'fox', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"2";s:8:"infinite";i:0;}'), (object) array('answer' => 'lazy', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"3";s:8:"infinite";i:0;}'), (object) array('answer' => 'assiduous', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"3";s:8:"infinite";i:0;}'), (object) array('answer' => 'dog', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"2";s:8:"infinite";i:0;}'), (object) array('answer' => 'slow', 'feedback' => 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"1";s:8:"infinite";i:0;}'), ); return $dd; } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'ddwtos'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_save_question(): void { $this->resetAfterTest(); $syscontext = \context_system::instance(); /** @var core_question_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(['contextid' => $syscontext->id]); $fromform = \test_question_maker::get_question_form_data('ddwtos', 'missingchoiceno'); $fromform->category = $category->id . ',' . $syscontext->id; $question = new \stdClass(); $question->category = $category->id; $question->qtype = 'ddwtos'; $question->createdby = 0; $this->qtype->save_question($question, $fromform); $q = question_bank::load_question($question->id); // We just want to verify that this does not cause errors, // but also verify some of the outcome. $this->assertEquals('The [[1]] sat on the [[2]].', $q->questiontext); $this->assertEquals([1 => 1, 2 => 1], $q->places); $this->assertEquals([1 => 1, 2 => 2], $q->rightchoices); } public function test_initialise_question_instance(): void { $qdata = $this->get_test_question_data(); $expected = \test_question_maker::make_question('ddwtos'); $expected->stamp = $qdata->stamp; $expected->idnumber = null; $q = $this->qtype->make_question($qdata); $this->assertEquals($expected, $q); } public function test_get_random_guess_score(): void { $q = $this->get_test_question_data(); $this->assertEqualsWithDelta(0.5, $this->qtype->get_random_guess_score($q), 0.0000001); } public function test_get_possible_responses(): void { $q = $this->get_test_question_data(); $this->assertEquals(array( 1 => array( 1 => new question_possible_response('quick', 1 / 3), 2 => new question_possible_response('slow', 0), null => question_possible_response::no_response()), 2 => array( 1 => new question_possible_response('fox', 1 / 3), 2 => new question_possible_response('dog', 0), null => question_possible_response::no_response()), 3 => array( 1 => new question_possible_response('lazy', 1 / 3), 2 => new question_possible_response('assiduous', 0), null => question_possible_response::no_response()), ), $this->qtype->get_possible_responses($q)); } public function test_xml_import(): void { $xml = ' A drag-and-drop question Put these in order: [[1]], [[2]], [[3]]. The answer is Alpha, Beta, Gamma. 3 0.3333333 0 1 Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    Alpha 1 Beta 1 Gamma 1 Try again. These are the first three letters of the Greek alphabet.
    '; $xmldata = xmlize($xml); $importer = new \qformat_xml(); $q = $importer->try_importing_using_qtypes( $xmldata['question'], null, null, 'ddwtos'); $expectedq = new \stdClass(); $expectedq->qtype = 'ddwtos'; $expectedq->name = 'A drag-and-drop question'; $expectedq->questiontext = 'Put these in order: [[1]], [[2]], [[3]].'; $expectedq->questiontextformat = FORMAT_MOODLE; $expectedq->generalfeedback = 'The answer is Alpha, Beta, Gamma.'; $expectedq->defaultmark = 3; $expectedq->length = 1; $expectedq->penalty = 0.3333333; $expectedq->shuffleanswers = 1; $expectedq->correctfeedback = array('text' => '

    Your answer is correct.

    ', 'format' => FORMAT_MOODLE); $expectedq->partiallycorrectfeedback = array( 'text' => '

    Your answer is partially correct.

    ', 'format' => FORMAT_MOODLE); $expectedq->shownumcorrect = true; $expectedq->incorrectfeedback = array('text' => '

    Your answer is incorrect.

    ', 'format' => FORMAT_MOODLE); $expectedq->choices = array( array('answer' => 'Alpha', 'choicegroup' => 1, 'infinite' => false), array('answer' => 'Beta', 'choicegroup' => 1, 'infinite' => false), array('answer' => 'Gamma', 'choicegroup' => 1, 'infinite' => true), ); $expectedq->hint = array( array('text' => 'Try again.', 'format' => FORMAT_MOODLE), array('text' => 'These are the first three letters of the Greek alphabet.', 'format' => FORMAT_MOODLE)); $expectedq->hintshownumcorrect = array(true, true); $expectedq->hintclearwrong = array(false, true); $this->assert(new \question_check_specified_fields_expectation($expectedq), $q); $this->assertEquals($expectedq->hint, $q->hint); } public function test_xml_export(): void { $qdata = new \stdClass(); $qdata->id = 123; $qdata->contextid = \context_system::instance()->id; $qdata->idnumber = null; $qdata->qtype = 'ddwtos'; $qdata->name = 'A drag-and-drop question'; $qdata->questiontext = 'Put these in order: [[1]], [[2]], [[3]].'; $qdata->questiontextformat = FORMAT_MOODLE; $qdata->generalfeedback = 'The answer is Alpha, Beta, Gamma.'; $qdata->generalfeedbackformat = FORMAT_MOODLE; $qdata->defaultmark = 3; $qdata->length = 1; $qdata->penalty = 0.3333333; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new \stdClass(); $qdata->options->shuffleanswers = 1; $qdata->options->correctfeedback = '

    Your answer is correct.

    '; $qdata->options->correctfeedbackformat = FORMAT_MOODLE; $qdata->options->partiallycorrectfeedback = '

    Your answer is partially correct.

    '; $qdata->options->partiallycorrectfeedbackformat = FORMAT_MOODLE; $qdata->options->shownumcorrect = 1; $qdata->options->incorrectfeedback = '

    Your answer is incorrect.

    '; $qdata->options->incorrectfeedbackformat = FORMAT_MOODLE; $qdata->options->answers = array( 13 => new question_answer(13, 'Alpha', 0, 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"1";s:8:"infinite";b:0;}', FORMAT_MOODLE), 14 => new question_answer(14, 'Beta', 0, 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"1";s:8:"infinite";b:0;}', FORMAT_MOODLE), 15 => new question_answer(15, 'Gamma', 0, 'O:8:"stdClass":2:{s:9:"draggroup";s:1:"1";s:8:"infinite";b:1;}', FORMAT_MOODLE), ); $qdata->hints = array( 1 => new question_hint_with_parts(1, 'Try again.', FORMAT_MOODLE, true, false), 2 => new question_hint_with_parts(2, 'These are the first three letters of the Greek alphabet.', FORMAT_MOODLE, true, true), ); $exporter = new \qformat_xml(); $xml = $exporter->writequestion($qdata); $expectedxml = ' A drag-and-drop question Put these in order: [[1]], [[2]], [[3]]. The answer is Alpha, Beta, Gamma. 3 0.3333333 0 1 Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    Alpha 1 Beta 1 Gamma 1 Try again. These are the first three letters of the Greek alphabet.
    '; $this->assert_same_xml($expectedxml, $xml); } } PK\y&type/ddwtos/tests/walkthrough_test.phpnu[. namespace qtype_ddwtos; use question_display_options; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddwtos/tests/helper.php'); /** * Unit tests for the drag-and-drop words into sentences question type. * * @package qtype_ddwtos * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { protected function get_contains_drop_box_expectation($place, $group, $readonly, $stateclass = '0') { $qa = $this->quba->get_question_attempt($this->slot); $class = "place{$place} drop group{$group}"; if ($readonly) { $class .= ' readonly'; } else { $expectedattrs['tabindex'] = 0; } $expectedattrs['class'] = $class; return new \question_contains_tag_with_attributes('span', $expectedattrs); } public function test_interactive_behaviour(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->hints = array( new question_hint_with_parts(13, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(14, 'This is the second hint.', FORMAT_HTML, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the wrong answer. $this->process_submission(array('p1' => '2', 'p2' => '2', 'p3' => '2')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '2'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Submit the wrong answer. $this->process_submission(array('p1' => '2', 'p2' => '2', 'p3' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true), $this->get_contains_drop_box_expectation('2', 2, true), $this->get_contains_drop_box_expectation('3', 3, true), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '2'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'correct'), $this->get_contains_drop_box_expectation('3', 3, true, 'correct'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); } public function test_deferred_feedback(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission(array('p1' => '1', 'p2' => '2')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save the right answer. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '1')); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '1'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'correct'), $this->get_contains_drop_box_expectation('3', 3, true, 'correct'), $this->get_contains_correct_expectation()); // Change the right answer a bit. $dd->rightchoices[2] = 2; // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); } public function test_deferred_feedback_unanswered(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save a blank response. $this->process_submission(array('p1' => '0', 'p2' => '0', 'p3' => '0')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true), $this->get_contains_drop_box_expectation('2', 2, true), $this->get_contains_drop_box_expectation('3', 3, true)); } public function test_deferred_feedback_partial_answer(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save a partial response. $this->process_submission(array('p1' => '1', 'p2' => '0', 'p3' => '0')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'incorrect'), $this->get_contains_drop_box_expectation('3', 3, true, 'incorrect'), $this->get_contains_partcorrect_expectation()); } public function test_interactive_grading(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->hints = array( new question_hint_with_parts(1, 'This is the first hint.', FORMAT_MOODLE, true, true), new question_hint_with_parts(2, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 9); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->assertEquals('interactivecountback', $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation()); // Submit an response with the first two parts right. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true), $this->get_contains_drop_box_expectation('2', 2, true), $this->get_contains_drop_box_expectation('3', 3, true), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the first hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0')); // Check that extract responses will return the reset data. $prefix = $this->quba->get_field_prefix($this->slot); $this->assertEquals(array('p1' => '1', 'p2' => '1'), $this->quba->extract_responses($this->slot, array($prefix . 'p1' => '1', $prefix . 'p2' => '1', '-tryagain' => 1))); // Do try again. $this->process_submission(array('p1' => '1', 'p2' => '1', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit an response with the first and last parts right. $this->process_submission(array('p1' => '1', 'p2' => '2', 'p3' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true), $this->get_contains_drop_box_expectation('2', 2, true), $this->get_contains_drop_box_expectation('3', 3, true), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the second hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '1')); // Do try again. $this->process_submission(array('p1' => '1', 'p3' => '1', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '1'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(1), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(6); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'correct'), $this->get_contains_drop_box_expectation('3', 3, true, 'correct'), $this->get_does_not_contain_submit_button_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_contains_standard_correct_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '1')); } public function test_interactive_correct_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, false), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the right answer. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '1')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'correct'), $this->get_contains_drop_box_expectation('3', 3, true, 'correct'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); } public function test_interactive_partial_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, false), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a partially right answer. $this->process_submission(array('p1' => '1', 'p2' => '2', 'p3' => '2')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'incorrect'), $this->get_contains_drop_box_expectation('3', 3, true, 'incorrect'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_partcorrect_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1); } public function test_interactive_no_right_clears(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, true), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a completely wrong answer. $this->process_submission(array('p1' => '2', 'p2' => '2', 'p3' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drop_box_expectation('1', 1, true), $this->get_contains_drop_box_expectation('2', 2, true), $this->get_contains_drop_box_expectation('3', 3, true), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission(array('p1' => '0', 'p2' => '0', 'p3' => '0', '-tryagain' => 1)); // Check that all the wrong answers have been cleared. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); } public function test_display_of_right_answer_when_shuffled(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddwtos'); $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '0'), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission($dd->get_correct_response()); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, false), $this->get_contains_drop_box_expectation('2', 2, false), $this->get_contains_drop_box_expectation('3', 3, false), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', $dd->get_right_choice_for(1)), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', $dd->get_right_choice_for(2)), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', $dd->get_right_choice_for(3)), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->displayoptions->rightanswer = question_display_options::VISIBLE; $this->assertEquals('{quick} {fox} {lazy}', $dd->get_right_answer_summary()); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_drop_box_expectation('1', 1, true, 'correct'), $this->get_contains_drop_box_expectation('2', 2, true, 'correct'), $this->get_contains_drop_box_expectation('3', 3, true, 'correct'), $this->get_contains_correct_expectation(), new \question_pattern_expectation('/' . preg_quote('The [quick] brown [fox] jumped over the [lazy] dog.', '/') . '/')); } } PK\nyJ(J(type/ddwtos/tests/helper.phpnu[. /** * Test helpers for the drag-and-drop words into sentences question type. * * @package qtype_ddwtos * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the drag-and-drop words into sentences question type. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddwtos_test_helper extends question_test_helper { public function get_test_questions() { return ['fox', 'maths', 'oddgroups', 'missingchoiceno', 'infinite', 'mathjax']; } /** * @return qtype_ddwtos_question */ public function make_ddwtos_question_fox() { question_bank::load_question_definition_classes('ddwtos'); $dd = new qtype_ddwtos_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop words into sentences question'; $dd->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddwtos'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = array( 1 => array( 1 => new qtype_ddwtos_choice('quick', 1), 2 => new qtype_ddwtos_choice('slow', 1)), 2 => array( 1 => new qtype_ddwtos_choice('fox', 2), 2 => new qtype_ddwtos_choice('dog', 2)), 3 => array( 1 => new qtype_ddwtos_choice('lazy', 3), 2 => new qtype_ddwtos_choice('assiduous', 3)), ); $dd->places = array(1 => 1, 2 => 2, 3 => 3); $dd->rightchoices = array(1 => 1, 2 => 1, 3 => 1); $dd->textfragments = array('The ', ' brown ', ' jumped over the ', ' dog.'); return $dd; } /** * This is a simple question with choices in three groups. * * @return stdClass data to create a ddwtos question. */ public function get_ddwtos_question_form_data_fox() { $fromform = new stdClass(); $fromform->name = 'Drag-and-drop words into sentences question'; $fromform->questiontext = array('text' => 'The [[1]] brown [[2]] jumped over the [[3]] dog.', 'format' => FORMAT_HTML); $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'This sentence uses each letter of the alphabet.', 'format' => FORMAT_HTML); $fromform->choices = array( array('answer' => 'quick', 'choicegroup' => '1'), array('answer' => 'fox', 'choicegroup' => '2'), array('answer' => 'lazy', 'choicegroup' => '3'), array('answer' => 'slow', 'choicegroup' => '1'), array('answer' => 'dog', 'choicegroup' => '2'), array('answer' => 'assiduous', 'choicegroup' => '3'), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $fromform->hint = [ [ 'text' => 'Fast', 'format' => FORMAT_HTML, ], ]; return $fromform; } /** * Similar to the 'fox' example above, but using different, non-continuous group numbers. * * @return stdClass data to create a ddwtos question. */ public function get_ddwtos_question_form_data_oddgroups() { $fromform = new stdClass(); $fromform->name = 'Drag-and-drop words with strange groups'; $fromform->questiontext = array('text' => 'The [[1]] brown [[2]] jumped over the [[3]] dog.', 'format' => FORMAT_HTML); $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'This sentence uses each letter of the alphabet.', 'format' => FORMAT_HTML); $fromform->choices = array( array('answer' => 'quick', 'choicegroup' => '5'), array('answer' => 'fox', 'choicegroup' => '7'), array('answer' => 'lazy', 'choicegroup' => '3'), array('answer' => 'slow', 'choicegroup' => '5'), array('answer' => 'dog', 'choicegroup' => '7'), array('answer' => 'assiduous', 'choicegroup' => '3'), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Get data required to save a drag-drop into text question where the author * missed out one of the group numbers. * * @return stdClass data to create a ddwtos question. */ public function get_ddwtos_question_form_data_missingchoiceno() { $fromform = new stdClass(); $fromform->name = 'Drag-drop into text question with one index missing'; $fromform->questiontext = ['text' => 'The [[1]] sat on the [[3]].', 'format' => FORMAT_HTML]; $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'The right answer is: "The cat sat on the mat."', 'format' => FORMAT_HTML); $fromform->choices = array( array('answer' => 'cat', 'choicegroup' => '1'), array('answer' => '', 'choicegroup' => '1'), array('answer' => 'mat', 'choicegroup' => '1'), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Get data required to save a drag-drop into text question where the the answer contain equation * * * @return stdClass data to create a ddwtos question. */ public function get_ddwtos_question_form_data_mathjax() { $fromform = new stdClass(); $fromform->name = 'Drag-and-drop words into sentences question with equation'; $fromform->questiontext = ['text' => 'Fill in the correct mathjax equation: y = 2, x =4 : [[1]]', 'format' => FORMAT_HTML]; $fromform->defaultmark = 1.0; $fromform->generalfeedback = ['text' => 'The right answer is: "y = x^2"', 'format' => FORMAT_HTML]; $fromform->choices = [ ['answer' => '$$ y = x^2 $$', 'choicegroup' => '1'], ['answer' => '$$ y = x^5 $$', 'choicegroup' => '1'], ]; test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * @return qtype_ddwtos_question */ public function make_ddwtos_question_maths() { question_bank::load_question_definition_classes('ddwtos'); $dd = new qtype_ddwtos_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop words into sentences question'; $dd->questiontext = 'Fill in the operators to make this equation work: ' . '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddwtos'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = array( 1 => array( 1 => new qtype_ddwtos_choice('+', 1, true), 2 => new qtype_ddwtos_choice('-', 1, true), 3 => new qtype_ddwtos_choice('*', 1, true), 4 => new qtype_ddwtos_choice('/', 1, true), )); $dd->places = array(1 => 1, 2 => 1, 3 => 1, 4 => 1); $dd->rightchoices = array(1 => 1, 2 => 2, 3 => 1, 4 => 2); $dd->textfragments = array('7 ', ' 11 ', ' 13 ', ' 17 ', ' 19 = 3'); return $dd; } /** * This is a simple question with infinite mode. * * @return stdClass data to create a ddwtos question. */ public function get_ddwtos_question_form_data_infinite() { $fromform = new stdClass(); $fromform->name = 'Drag-and-drop infinite question'; $fromform->questiontext = ['text' => 'One [[1]] Two [[2]] Three [[3]]', 'format' => FORMAT_HTML]; $fromform->defaultmark = 1.0; $fromform->generalfeedback = ['text' => 'This is general feedback', 'format' => FORMAT_HTML]; $fromform->choices = [ ['answer' => 'Option1', 'choicegroup' => '1', 'infinite' => true], ['answer' => 'Option2', 'choicegroup' => '1', 'infinite' => true], ['answer' => 'Option3', 'choicegroup' => '1', 'infinite' => true] ]; test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } } PK\.type/ddwtos/tests/behat/behat_qtype_ddwtos.phpnu[. /** * Behat steps definitions for drag and drop into text. * * @package qtype_ddwtos * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Steps definitions related with the drag and drop into text question type. * * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qtype_ddwtos extends behat_base { /** * Get the xpath for a given drag item. * @param string $dragitem the text of the item to drag. * @return string the xpath expression. */ protected function drag_xpath($dragitem) { return '//div[@class="answercontainer"]//span[contains(@class, "draghome") and contains(., "' . $this->escape($dragitem) . '") and not(contains(@class, "dragplaceholder"))]'; } /** * Get the xpath for a given drop box. * @param string $spacenumber the number of the drop box. * @return string the xpath expression. */ protected function drop_xpath($spacenumber) { return '//span[contains(@class, " drop ") and contains(@class, "place' . $spacenumber . ' ")]'; } /** * Get the xpath for a given drop box contain a placed drag. * @param string $placeddragnumber the number of the placed drag. * @return string the xpath expression. */ protected function inplace_xpath(string $placeddragnumber): string { return '//span[contains(@class, "inplace' . $placeddragnumber . '")]'; } /** * Drag the drag item with the given text to the given space. * * @param string $dragitem the text of the item to drag. * @param int $spacenumber the number of the gap to drop into. * * @Given /^I drag "(?P[^"]*)" to space "(?P\d+)" in the drag and drop into text question$/ */ public function i_drag_to_space_in_the_drag_and_drop_into_text_question($dragitem, $spacenumber) { $generalcontext = behat_context_helper::get('behat_general'); $generalcontext->i_drag_and_i_drop_it_in($this->drag_xpath($dragitem), 'xpath_element', $this->drop_xpath($spacenumber), 'xpath_element'); } /** * Drag the drag item with the given text to the given placed drag number. * * @param string $dragitem the text of the item to drag. * @param int $placeddragnumber the number of the placed drag to drop into. * * @Given /^I drag "(?P[^"]*)" to placed drag "(?P\d+)" in the drag and drop into text question$/ */ public function i_drag_to_placed_drag_number_in_the_drag_and_drop_into_text_question(string $dragitem, int $placeddragnumber): void { $generalcontext = behat_context_helper::get('behat_general'); $generalcontext->i_drag_and_i_drop_it_in($this->drag_xpath($dragitem), 'xpath_element', $this->inplace_xpath($placeddragnumber), 'xpath_element'); } /** * Type some characters while focussed on a given space. * * @param string $keys the characters to type. * @param int $spacenumber the number of the space to type into. * * @Given /^I type "(?P[^"]*)" into space "(?P\d+)" in the drag and drop onto image question$/ */ public function i_type_into_space_in_the_drag_and_drop_into_text_question($keys, $spacenumber) { $node = $this->get_selected_node('xpath_element', $this->drop_xpath($spacenumber)); $this->ensure_node_is_visible($node); $node->focus(); foreach (str_split($keys) as $key) { behat_base::type_keys($this->getSession(), [$key]); $this->wait_for_pending_js(); } } /** * Check that the given drag exist in drag home area * * @param string $dragitem the text of the drag item. * * @Given /^I should see "(?P[^"]*)" in the home area of drag and drop into text question$/ */ public function i_should_see_drag_in_the_home_area($dragitem) { $this->ensure_element_exists($this->drag_xpath($dragitem), 'xpath_element'); } } PK\@ @ #type/ddwtos/tests/behat/add.featurenu[@qtype @qtype_ddwtos Feature: Test creating a drag and drop into text question As a teacher In order to test my students I need to be able to create drag and drop into text questions Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript Scenario: Create a drag and drop into text question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Drag and drop into text" question filling the form with: | Question name | Drag and drop into text 001 | | Question text | The [[1]] [[2]] on the [[3]]. | | General feedback | The cat sat on the mat. | | id_shuffleanswers | 1 | | id_choices_0_answer | cat | | id_choices_1_answer | sat | | id_choices_2_answer | mat | | id_choices_3_answer | dog | | id_choices_4_answer | table | | Penalty for each incorrect try | 20% | | Hint 1 | First hint | | Hint 2 | Second hint | Then I should see "Drag and drop into text 001" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "item_qtype_ddwtos" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | id_shuffleanswers | 1 | | Penalty for each incorrect try | 20% | Scenario: Cannot create a drag and drop into text question when making the unsolvable questions When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Drag and drop into text" question filling the form with: | Question name | Drag and drop into text 001 | | Question text | The [[1]] [[2]] on the [[1]]. | | General feedback | The cat sat on the cat. | | id_shuffleanswers | 1 | | id_choices_0_answer | cat | | id_choices_1_answer | goes through | | id_choices_2_answer | mat | | id_choices_3_answer | dog | | id_choices_4_answer | table | | Penalty for each incorrect try | 20% | | Hint 1 | First hint | | Hint 2 | Second hint | Then I should see "Choice [[1]] has been used more than once without being set to \"Unlimited\". Please recheck this question." PK\'$type/ddwtos/tests/behat/edit.featurenu[@qtype @qtype_ddwtos Feature: Test editing a drag and drop into text questions As a teacher In order to be able to update my drag and drop into text questions I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddwtos | Drag to text | fox | @javascript Scenario: Edit a drag and drop into text question When I am on the "Drag to text" "core_question > edit" page logged in as teacher And I should see "Choice [[1]]" And I should see "Choice [[2]]" And I should see "Choice [[3]]" And I should see "Write the answers to be dragged into the gaps. You can include extra answers to increase difficulty." in the "Choices" "fieldset" And I set the following fields to these values: | Question name | Edited question name | And I press "id_submitbutton" Then I should see "Edited question name" Scenario: Cannot update a drag and drop into text question to the unsolvable questions When I am on the "Drag to text" "core_question > edit" page logged in as teacher And I should see "Choice [[1]]" And I should see "Choice [[2]]" And I should see "Choice [[3]]" And I set the following fields to these values: | Question name | Edited question name | | Question text | Choice [[1]] Choice [[2]] Choice [[1]] | And I press "id_submitbutton" Then I should see "Choice [[1]] has been used more than once without being set to \"Unlimited\". Please recheck this question." PK\PP&type/ddwtos/tests/behat/export.featurenu[@qtype @qtype_ddwtos Feature: Test exporting drag and drop into text questions As a teacher In order to be able to reuse my drag and drop into text questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddwtos | Drag to text | fox | @javascript Scenario: Export a drag and drop into text question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" And following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | Drag to text | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\7+&type/ddwtos/tests/behat/import.featurenu[@qtype @qtype_ddwtos Feature: Test importing drag and drop into text questions As a teacher In order to reuse drag and drop into text questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import drag and drop into text question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/ddwtos/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "1. The [[1]] [[2]] on the [[3]]." And I press "Continue" And I should see "Imported Drag and drop into text 001" PK\!$2type/ddwtos/tests/behat/backup_and_restore.featurenu[@qtype @qtype_ddwtos Feature: Test duplicating a quiz containing a drag and drop into text question As a teacher In order re-use my courses containing drag and drop into text questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddwtos | Drag to text | fox | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | Drag to text | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing a drag and drop into text question When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "Drag to text" in the question bank Then the following fields match these values: | Question name | Drag to text | | Question text | The [[1]] brown [[2]] jumped over the [[3]] dog. | | General feedback | This sentence uses each letter of the alphabet. | | Default mark | 1 | | Shuffle | 0 | | id_choices_0_answer | quick | | id_choices_0_choicegroup | 1 | | id_choices_1_answer | fox | | id_choices_1_choicegroup | 2 | | id_choices_2_answer | lazy | | id_choices_2_choicegroup | 3 | | id_choices_3_answer | slow | | id_choices_3_choicegroup | 1 | | id_choices_4_answer | dog | | id_choices_4_choicegroup | 2 | | id_choices_5_answer | assiduous | | id_choices_5_choicegroup | 3 | | For any correct response | Well done! | | For any partially correct response | Parts, but only parts, of your response are correct. | | id_shownumcorrect | 0 | | For any incorrect response | That is not right at all. | PK\ 'type/ddwtos/tests/behat/preview.featurenu[@qtype @qtype_ddwtos @_switch_window Feature: Preview a drag-drop into text question As a teacher In order to check my drag-drop into text questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddwtos | Drag to text | fox | | Test questions | ddwtos | Drag to text infinite | infinite | | Test questions | ddwtos | Drag to mathjax equation | mathjax | @javascript @_bug_phantomjs Scenario: Preview a question using the mouse. When I am on the "Drag to text" "core_question > preview" page logged in as teacher And I should see "quick" in the home area of drag and drop into text question And I should see "slow" in the home area of drag and drop into text question And I drag "quick" to space "1" in the drag and drop into text question And I drag "fox" to space "2" in the drag and drop into text question And I drag "assiduous" to space "3" in the drag and drop into text question And I drag "slow" to placed drag "1" in the drag and drop into text question And I should see "quick" in the home area of drag and drop into text question And I drag "quick" to placed drag "1" in the drag and drop into text question And I should see "slow" in the home area of drag and drop into text question And I press "Submit and finish" Then the state of "The" question is shown as "Partially correct" And I should see "Mark 0.67 out of 1.00" @javascript Scenario: Preview a question using the keyboard & submit incomplete. When I am on the "Drag to text" "core_question > preview" page logged in as teacher And I type " " into space "1" in the drag and drop onto image question And I type " " into space "2" in the drag and drop onto image question And I type " " into space "3" in the drag and drop onto image question And I press "Save" Then the state of "The" question is shown as "Incomplete answer" And I should see "Please put an answer in each box." @javascript Scenario: Preview a question using the keyboard. When I am on the "Drag to text" "core_question > preview" page logged in as teacher And I type " " into space "1" in the drag and drop onto image question And I type " " into space "2" in the drag and drop onto image question And I type " " into space "3" in the drag and drop onto image question And I press "Submit and finish" Then the state of "The" question is shown as "Incorrect" And I should see "Mark 0.00 out of 1.00" @javascript Scenario: Preview a question that uses strange group numbers using the keyboard. Given the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddwtos | Funny groups | oddgroups | When I am on the "Funny groups" "core_question > preview" page logged in as teacher And I type " " into space "1" in the drag and drop onto image question And I type " " into space "2" in the drag and drop onto image question And I type " " into space "3" in the drag and drop onto image question And I press "Submit and finish" Then the state of "The" question is shown as "Correct" And I should see "Mark 1.00 out of 1.00" @javascript Scenario: Preview a infinite question. When I am on the "Drag to text infinite" "core_question > preview" page logged in as teacher And I press "Fill in correct responses" Then I should see "Option1" in the home area of drag and drop into text question And I should see "Option2" in the home area of drag and drop into text question And I should see "Option3" in the home area of drag and drop into text question @javascript Scenario: Preview a drag-drop into text question with mathjax question. Given the "mathjaxloader" filter is "on" And the "mathjaxloader" filter applies to "content and headings" And I am on the "Drag to mathjax equation" "core_question > preview" page logged in as teacher And I press "Fill in correct responses" When I press "Submit and finish" Then ".filter_mathjaxloader_equation" "css_element" should exist in the ".draghome" "css_element" PK\octype/ddwtos/questiontype.phpnu[. /** * Question type class for the drag-and-drop words into sentences question type. * * @package qtype_ddwtos * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/engine/lib.php'); require_once($CFG->dirroot . '/question/format/xml/format.php'); require_once($CFG->dirroot . '/question/type/gapselect/questiontypebase.php'); /** * The drag-and-drop words into sentences question type class. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddwtos extends qtype_gapselect_base { protected function choice_group_key() { return 'draggroup'; } protected function choice_options_to_feedback($choice) { $output = new stdClass(); $output->draggroup = $choice['choicegroup']; $output->infinite = !empty($choice['infinite']); return serialize($output); } /** * Safely convert given serialized feedback string into valid feedback object * * @param string $feedback * @return stdClass */ protected function unserialize_feedback(string $feedback): stdClass { $feedbackobject = unserialize_object($feedback); return (object) [ 'draggroup' => $feedbackobject->draggroup ?? 1, 'infinite' => !empty($feedbackobject->infinite), ]; } protected function feedback_to_choice_options($feedback) { return (array) $this->unserialize_feedback($feedback); } protected function make_choice($choicedata) { $options = $this->unserialize_feedback($choicedata->feedback); return new qtype_ddwtos_choice( $choicedata->answer, $options->draggroup, $options->infinite); } public function import_from_xml($data, $question, qformat_xml $format, $extra=null) { if (!isset($data['@']['type']) || $data['@']['type'] != 'ddwtos') { return false; } $question = $format->import_headers($data); $question->qtype = 'ddwtos'; $question->shuffleanswers = $format->trans_single( $format->getpath($data, array('#', 'shuffleanswers', 0, '#'), 1)); // Import the choices. $question->answer = array(); $question->draggroup = array(); $question->infinite = array(); foreach ($data['#']['dragbox'] as $dragboxxml) { $question->choices[] = array( 'answer' => $format->getpath($dragboxxml, array('#', 'text', 0, '#'), '', true), 'choicegroup' => $format->getpath($dragboxxml, array('#', 'group', 0, '#'), 1), 'infinite' => array_key_exists('infinite', $dragboxxml['#']), ); } $format->import_combined_feedback($question, $data, true); $format->import_hints($question, $data, true, false, $format->get_format($question->questiontextformat)); return $question; } public function export_to_xml($question, qformat_xml $format, $extra = null) { $output = ''; $output .= ' ' . $question->options->shuffleanswers . "\n"; $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid); foreach ($question->options->answers as $answer) { $options = $this->unserialize_feedback($answer->feedback); $output .= " \n"; $output .= $format->writetext($answer->answer, 3); $output .= " {$options->draggroup}\n"; if ($options->infinite) { $output .= " \n"; } $output .= " \n"; } return $output; } } PK\ type/calculatedsimple/styles.cssnu[.que.calculatedsimple .answer { padding: 0.3em; display: inline; } .que.calculatedsimple .answer input[type="text"] { width: 30%; } /* Editing form. */ body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answeroptions_] { background: #eee; margin-top: 0; margin-bottom: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-bottom: 0; } body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answeroptions_] .fgrouplabel label { font-weight: bold; } body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answeroptions_] label[for^='id_answer_'], body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answertolerance_] label[for^='id_tolerance_'], body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answerdisplay_] label[for^='id_correctanswerlength_'] { position: absolute; left: -10000px; font-weight: normal; font-size: 1em; } body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answertolerance_], body#page-question-type-calculatedsimple div[id^=fgroup_id_][id*=answerdisplay_] { background: #eee; margin-bottom: 0; margin-top: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-top: 0; border-bottom: 0; } body#page-question-type-calculatedsimple div[id^=fitem_id_][id*=feedback_] { background: #eee; margin-bottom: 2em; margin-top: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-top: 0; } PK\ګo##"type/calculatedsimple/renderer.phpnu[. /** * Calculated (simple) question renderer class. * * @package qtype * @subpackage calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/renderer.php'); /** * Generates the output for simple calculated questions. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple_renderer extends qtype_calculated_renderer { } PK\G 'type/calculatedsimple/db/upgradelib.phpnu[. /** * Upgrade library code for the simple calculated question type. * * @package qtype * @subpackage calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/db/upgradelib.php'); /** * Class for converting attempt data for simple calculated questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple_qe2_attempt_updater extends qtype_calculated_qe2_attempt_updater { } PK\;{ { 8type/calculatedsimple/lang/en/qtype_calculatedsimple.phpnu[. /** * Strings for component 'qtype_calculatedsimple', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage calculatedsimple * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['atleastonewildcard'] = 'There must be at least one wild card {x..} present in the correct answer formulas'; $string['converttocalculated'] = 'Save as a new regular calculated question'; $string['findwildcards'] = 'Find the wild cards {x..} present in the correct answer formulas'; $string['generatenewitemsset'] = 'Generate'; $string['newsetwildcardvalues'] = 'new set(s) of wild card(s) values'; $string['pluginname'] = 'Calculated simple'; $string['pluginname_help'] = 'Simple calculated questions enable individual numerical questions to be created using wildcards that are substituted with individual values when the quiz is taken. Simple calculated questions offer the most used features of the calculated question with a simpler creation interface.'; $string['pluginname_link'] = 'question/type/calculatedsimple'; $string['pluginnameadding'] = 'Adding a Simple calculated question'; $string['pluginnameediting'] = 'Editing a Simple calculated question'; $string['pluginnamesummary'] = 'A simpler version of calculated questions which are like numerical questions but with the numbers used selected randomly from a set when the quiz is taken.'; $string['privacy:metadata'] = 'The Calculated simple question type plugin does not store any personal data.'; $string['setno'] = 'Set {$a}'; $string['setwildcardvalues'] = 'set(s) of wild card(s) values'; $string['showitems'] = 'Display'; $string['updatewildcardvalues'] = 'Update the wild card(s) values'; $string['useadvance'] = 'Use the advance button to see the errors'; $string['wildcard'] = 'Wild card {{$a}}'; $string['wildcardparam'] = 'Wild cards parameters used to generate the values'; $string['wildcardrole'] = 'The wild cards {x..} will be substituted by a numerical value from the generated values'; $string['wildcardvalues'] = 'Wild card(s) values'; $string['wildcardvaluesgenerated'] = 'Wild card(s) values generated'; $string['willconverttocalculated'] = 'If set, the Save as new question will save as a new calculated question'; $string['youmustaddatleastonevalue'] = 'You must add at least one set of wild card(s) values before you can save this question.'; PK\˚ggStype/calculatedsimple/backup/moodle2/backup_qtype_calculatedsimple_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/backup/moodle2/backup_qtype_calculated_plugin.class.php'); /** * Provides the information to backup calculatedsimple questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_calculatedsimple_plugin extends backup_qtype_calculated_plugin { } PK\BTtype/calculatedsimple/backup/moodle2/restore_qtype_calculatedsimple_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/backup/moodle2/restore_qtype_calculated_plugin.class.php'); /** * restore plugin class that provides the necessary information * needed to restore one calculatedsimple qtype plugin * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_calculatedsimple_plugin extends restore_qtype_calculated_plugin { } PK\E"type/calculatedsimple/pix/icon.gifnu[GIF89a000@@@!,] H@ @ &lȐjl@/>D0@H! `忕,],PB,SDž-Q %HW<46;PK\HWh h "type/calculatedsimple/pix/icon.svgnu[PK\ΦU||type/calculatedsimple/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_calculatedsimple * @copyright Dongsheng Cai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for simple calculated questions. * * @package qtype_calculatedsimple * @category files * * @param stdClass $course * @param stdClass $cm * @param stdClass $context * @param string $filearea * @param array $args * @param bool $forcedownload * @param array $options additional options affecting the file serving * @return bool */ function qtype_calculatedsimple_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_calculatedsimple', $filearea, $args, $forcedownload, $options); } PK\H!type/calculatedsimple/version.phpnu[. /** * Version information for the simple calculated question type. * * @package qtype * @subpackage calculatedsimple * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_calculatedsimple'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->dependencies = [ 'qtype_numerical' => 2025040800, 'qtype_calculated' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\U "type/calculatedsimple/question.phpnu[. /** * Calculated (simple) question definition class. * * @package qtype * @subpackage calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/question.php'); /** * Represents a simple calculated question. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple_question extends qtype_calculated_question { } PK\z.2type/calculatedsimple/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_calculatedsimple. * * @package qtype_calculatedsimple * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_calculatedsimple\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_calculatedsimple implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\H{{4type/calculatedsimple/edit_calculatedsimple_form.phpnu[. /** * Defines the editing form for the calculated simplequestion type. * * @package qtype * @subpackage calculatedsimple * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/edit_calculated_form.php'); /** * Editing form for the calculated simplequestion type. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple_edit_form extends qtype_calculated_edit_form { /** * Handle to the question type for this question. * * @var question_calculatedsimple_qtype */ public $qtypeobj; public $wildcarddisplay; public $questiondisplay; public $datasetdefs; public $reload = false; public $maxnumber = -1; public $regenerate = true; public $noofitems; public $outsidelimit = false; public $commentanswer = array(); public $answer = array(); public $nonemptyanswer = array(); public $numbererrors = array(); public $formdata = array(); public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { $this->regenerate = true; $this->question = $question; $this->qtypeobj = question_bank::get_qtype($this->question->qtype); // Get the dataset definitions for this question. // Coming here everytime even when using a NoSubmitButton. // This will only set the values to the actual question database content // which is not what we want, so this should be removed from here. // Get priority to paramdatasets. $this->reload = optional_param('reload', false, PARAM_BOOL); if (!$this->reload) { // Use database data as this is first pass // Question->id == 0 so no stored datasets. if (!empty($question->id)) { $this->datasetdefs = $this->qtypeobj->get_dataset_definitions( $question->id, array()); if (!empty($this->datasetdefs)) { foreach ($this->datasetdefs as $defid => $datasetdef) { // First get the items in case their number does not correspond to itemcount. if (isset($datasetdef->id)) { $this->datasetdefs[$defid]->items = $this->qtypeobj->get_database_dataset_items($datasetdef->id); if ($this->datasetdefs[$defid]->items != '') { $datasetdef->itemcount = count($this->datasetdefs[$defid]->items); } else { $datasetdef->itemcount = 0; } } // Get maxnumber. if ($this->maxnumber == -1 || $datasetdef->itemcount < $this->maxnumber) { $this->maxnumber = $datasetdef->itemcount; } } } $i = 0; foreach ($this->question->options->answers as $answer) { $this->answer[$i] = $answer; $i++; } $this->nonemptyanswer = $this->answer; } $datasettoremove = false; $newdatasetvalues = false; $newdataset = false; } else { // Handle reload to get values from the form-elements // answers, datasetdefs and data_items. In any case the validation // step will warn the user of any error in settings the values. // Verification for the specific dataset values as the other parameters // units, feeedback etc are handled elsewhere. // Handle request buttons : // 'analyzequestion' (Identify the wild cards {x..} present in answers). // 'addbutton' (create new set of datatitems). // 'updatedatasets' is handled automatically on each reload. // The analyzequestion is done every time on reload // to detect any new wild cards so that the current display reflects // the mandatory (i.e. in answers) datasets. // To implement : don't do any changes if the question is used in a quiz. // If new datadef, new properties should erase items. // most of the data. $datasettoremove = false; $newdatasetvalues = false; $newdataset = false; $dummyform = new stdClass(); $mandatorydatasets = array(); // Should not test on adding a new answer. // Should test if there are already olddatasets or if the 'analyzequestion'. // submit button has been clicked. if (optional_param_array('datasetdef', false, PARAM_BOOL) || optional_param('analyzequestion', false, PARAM_BOOL)) { if ($dummyform->answer = optional_param_array('answer', '', PARAM_NOTAGS)) { // There is always at least one answer... $fraction = optional_param_array('fraction', '', PARAM_FLOAT); $tolerance = optional_param_array('tolerance', '', PARAM_LOCALISEDFLOAT); $tolerancetype = optional_param_array('tolerancetype', '', PARAM_FLOAT); $correctanswerlength = optional_param_array('correctanswerlength', '', PARAM_INT); $correctanswerformat = optional_param_array('correctanswerformat', '', PARAM_INT); foreach ($dummyform->answer as $key => $answer) { if (trim($answer) != '') { // Just look for non-empty. $this->answer[$key] = new stdClass(); $this->answer[$key]->answer = $answer; $this->answer[$key]->fraction = $fraction[$key]; $this->answer[$key]->tolerance = $tolerance[$key]; $this->answer[$key]->tolerancetype = $tolerancetype[$key]; $this->answer[$key]->correctanswerlength = $correctanswerlength[$key]; $this->answer[$key]->correctanswerformat = $correctanswerformat[$key]; $this->nonemptyanswer[]= $this->answer[$key]; $mandatorydatasets += $this->qtypeobj->find_dataset_names($answer); } } } $this->datasetdefs = array(); // Rebuild datasetdefs from old values. if ($olddef = optional_param_array('datasetdef', '', PARAM_RAW)) { $calcmin = optional_param_array('calcmin', '', PARAM_LOCALISEDFLOAT); $calclength = optional_param_array('calclength', '', PARAM_INT); $calcmax = optional_param_array('calcmax', '', PARAM_LOCALISEDFLOAT); $oldoptions = optional_param_array('defoptions', '', PARAM_RAW); $newdatasetvalues = false; $sizeofolddef = count($olddef); for ($key = 1; $key <= $sizeofolddef; $key++) { $def = $olddef[$key]; $this->datasetdefs[$def]= new stdClass(); $this->datasetdefs[$def]->type = 1; $this->datasetdefs[$def]->category = 0; $this->datasetdefs[$def]->options = $oldoptions[$key]; $this->datasetdefs[$def]->calcmin = $calcmin[$key]; $this->datasetdefs[$def]->calcmax = $calcmax[$key]; $this->datasetdefs[$def]->calclength = $calclength[$key]; // Then compare with new values. if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', $this->datasetdefs[$def]->options, $regs)) { if ($this->datasetdefs[$def]->calcmin != $regs[2]|| $this->datasetdefs[$def]->calcmax != $regs[3] || $this->datasetdefs[$def]->calclength != $regs[4]) { $newdatasetvalues = true; } } $this->datasetdefs[$def]->options = "uniform:" . $this->datasetdefs[$def]->calcmin . ":" . $this->datasetdefs[$def]->calcmax . ":" . $this->datasetdefs[$def]->calclength; } } // Detect new datasets. $newdataset = false; foreach ($mandatorydatasets as $datasetname) { if (!isset($this->datasetdefs["1-0-{$datasetname}"])) { $key = "1-0-{$datasetname}"; $this->datasetdefs[$key] = new stdClass(); $this->datasetdefs[$key]->type = 1; $this->datasetdefs[$key]->category = 0; $this->datasetdefs[$key]->name = $datasetname; $this->datasetdefs[$key]->options = "uniform:1.0:10.0:1"; $newdataset = true; } else { $this->datasetdefs["1-0-{$datasetname}"]->name = $datasetname; } } // Remove obsolete datasets. $datasettoremove = false; foreach ($this->datasetdefs as $defkey => $datasetdef) { if (!isset($datasetdef->name)) { $datasettoremove = true; unset($this->datasetdefs[$defkey]); } } } } // Handle reload. // Create items if $newdataset and noofitems > 0 and !$newdatasetvalues. // Eliminate any items if $newdatasetvalues. // Eliminate any items if $datasettoremove, $newdataset, $newdatasetvalues. if ($datasettoremove ||$newdataset ||$newdatasetvalues) { foreach ($this->datasetdefs as $defkey => $datasetdef) { $datasetdef->itemcount = 0; unset($datasetdef->items); } } $maxnumber = -1; if (optional_param('addbutton', false, PARAM_BOOL)) { $maxnumber = optional_param('selectadd', '', PARAM_INT); // FIXME: sloppy coding. foreach ($this->datasetdefs as $defid => $datasetdef) { $datasetdef->itemcount = $maxnumber; unset($datasetdef->items); for ($numberadded = 1; $numberadded <= $maxnumber; $numberadded++) { $datasetitem = new stdClass(); $datasetitem->itemnumber = $numberadded; $datasetitem->id = 0; $datasetitem->value = $this->qtypeobj->generate_dataset_item( $datasetdef->options); $this->datasetdefs[$defid]->items[$numberadded] = $datasetitem; } } $this->maxnumber = $maxnumber; } else { // Handle reload dataset items. if (optional_param_array('definition', '', PARAM_NOTAGS) && !($datasettoremove ||$newdataset ||$newdatasetvalues)) { $i = 1; $fromformdefinition = optional_param_array('definition', '', PARAM_NOTAGS); $fromformnumber = optional_param_array('number', '', PARAM_LOCALISEDFLOAT); $fromformitemid = optional_param_array('itemid', '', PARAM_INT); ksort($fromformdefinition); foreach ($fromformdefinition as $key => $defid) { $addeditem = new stdClass(); $addeditem->id = $fromformitemid[$i]; $addeditem->value = $fromformnumber[$i]; $addeditem->itemnumber = ceil($i / count($this->datasetdefs)); $this->datasetdefs[$defid]->items[$addeditem->itemnumber] = $addeditem; $this->datasetdefs[$defid]->itemcount = $i; $i++; } } if (isset($addeditem->itemnumber) && $this->maxnumber < $addeditem->itemnumber) { $this->maxnumber = $addeditem->itemnumber; if (!empty($this->datasetdefs)) { foreach ($this->datasetdefs as $datasetdef) { $datasetdef->itemcount = $this->maxnumber; } } } } parent::__construct($submiturl, $question, $category, $contexts, $formeditable); } /** * Add question-type specific form fields. * * @param MoodleQuickForm $mform the form being built. */ protected function definition_inner($mform) { $strquestionlabel = $this->qtypeobj->comment_header($this->nonemptyanswer); $label = get_string("sharedwildcards", "qtype_calculated"); $mform->addElement('hidden', 'synchronize', 0); $mform->addElement('hidden', 'initialcategory', 1); $mform->setType('synchronize', PARAM_BOOL); $mform->setType('initialcategory', PARAM_INT); $mform->addElement('hidden', 'reload', 1); $mform->setType('reload', PARAM_INT); $addfieldsname = 'updatequestion value'; $addstring = get_string("updatecategory", "qtype_calculated"); $mform->registerNoSubmitButton($addfieldsname); $this->add_per_answer_fields($mform, get_string('answerhdr', 'qtype_calculated', '{no}'), question_bank::fraction_options(), 1, 1); $this->add_unit_options($mform, $this); $this->add_unit_fields($mform, $this); $this->add_interactive_settings(); $label = "
    " . get_string('wildcardrole', 'qtype_calculatedsimple') . "
    "; $mform->addElement('html', "
     
    "); // Explaining the role of datasets so other strings can be shortened. $mform->addElement('html', $label); $mform->addElement('submit', 'analyzequestion', get_string('findwildcards', 'qtype_calculatedsimple')); $mform->registerNoSubmitButton('analyzequestion'); $mform->closeHeaderBefore('analyzequestion'); if ($this->maxnumber != -1) { $this->noofitems = $this->maxnumber; } else { $this->noofitems = 0; } if (!empty($this->datasetdefs)) {// So there are some datadefs. // We put them on the page. $key = 0; $mform->addElement('header', 'additemhdr', get_string('wildcardparam', 'qtype_calculatedsimple')); $idx = 1; if (!empty($this->datasetdefs)) {// Unnecessary test. $j = (($this->noofitems) * count($this->datasetdefs))+1;// foreach ($this->datasetdefs as $defkey => $datasetdef) { $mform->addElement('static', "na[{$j}]", get_string('param', 'qtype_calculated', $datasetdef->name)); $this->qtypeobj->custom_generator_tools_part($mform, $idx, $j); $mform->addElement('hidden', "datasetdef[{$idx}]"); $mform->setType("datasetdef[{$idx}]", PARAM_RAW); $mform->addElement('hidden', "defoptions[{$idx}]"); $mform->setType("defoptions[{$idx}]", PARAM_RAW); $idx++; $mform->addElement('static', "divider[{$j}]", '', '
    '); $j++; } } // This should be done before the elements are created and stored as $this->formdata. // Fill out all data sets and also the fields for the next item to add. /*Here we do already the values error analysis so that * we could force all wild cards values display if there is an error in values. * as using a , in a number */ $this->numbererrors = array(); if (!empty($this->datasetdefs)) { $j = $this->noofitems * count($this->datasetdefs); for ($itemnumber = $this->noofitems; $itemnumber >= 1; $itemnumber--) { $data = array(); $numbererrors = ''; $comment = new stdClass(); $comment->stranswers = array(); $comment->outsidelimit = false; $comment->answers = array(); foreach ($this->datasetdefs as $defid => $datasetdef) { if (isset($datasetdef->items[$itemnumber])) { $this->formdata["definition[{$j}]"] = $defid; $this->formdata["itemid[{$j}]"] = $datasetdef->items[$itemnumber]->id; $data[$datasetdef->name] = $datasetdef->items[$itemnumber]->value; $this->formdata["number[{$j}]"] = $number = $datasetdef->items[$itemnumber]->value; if (! is_numeric($number)) { $a = new stdClass(); $a->name = '{'.$datasetdef->name.'}'; $a->value = $datasetdef->items[$itemnumber]->value; if (stristr($number, ',')) { $this->numbererrors["number[{$j}]"] = get_string('nocommaallowed', 'qtype_calculated'); $numbererrors .= $this->numbererrors['number['.$j.']']."
    "; } else { $this->numbererrors["number[{$j}]"] = get_string('notvalidnumber', 'qtype_calculated', $a); $numbererrors .= $this->numbererrors['number['.$j.']']."
    "; } } else if (stristr($number, 'x')) { // Hexa will pass the test. $a = new stdClass(); $a->name = '{'.$datasetdef->name.'}'; $a->value = $datasetdef->items[$itemnumber]->value; $this->numbererrors['number['.$j.']'] = get_string('hexanotallowed', 'qtype_calculated', $a); $numbererrors .= $this->numbererrors['number['.$j.']']."
    "; } else if (is_nan($number)) { $a = new stdClass(); $a->name = '{'.$datasetdef->name.'}'; $a->value = $datasetdef->items[$itemnumber]->value; $this->numbererrors["number[{$j}]"] = get_string('notvalidnumber', 'qtype_calculated', $a); $numbererrors .= $this->numbererrors['number['.$j.']']."
    "; } } $j--; } if ($this->noofitems != 0) { if (empty($numbererrors)) { if (!isset($this->question->id)) { $this->question->id = 0; } $this->question->questiontext = !empty($this->question->questiontext) ? $this->question->questiontext : ''; $comment = $this->qtypeobj->comment_on_datasetitems( $this->qtypeobj, $this->question->id, $this->question->questiontext, $this->nonemptyanswer, $data, $itemnumber); if ($comment->outsidelimit) { $this->outsidelimit = $comment->outsidelimit; } $totalcomment = ''; foreach ($this->nonemptyanswer as $key => $answer) { $totalcomment .= $comment->stranswers[$key].'
    '; } $this->formdata['answercomment['.$itemnumber.']'] = $totalcomment; } } } $this->formdata['selectdelete'] = '1'; $this->formdata['selectadd'] = '1'; $j = $this->noofitems * count($this->datasetdefs)+1; $data = array(); // Data for comment_on_datasetitems later. $idx = 1; foreach ($this->datasetdefs as $defid => $datasetdef) { $this->formdata["datasetdef[{$idx}]"] = $defid; $idx++; } $this->formdata = $this->qtypeobj->custom_generator_set_data( $this->datasetdefs, $this->formdata); } $addoptions = Array(); $addoptions['1'] = '1'; for ($i = 10; $i <= 100; $i += 10) { $addoptions["{$i}"] = "{$i}"; } $showoptions = Array(); $showoptions['1'] = '1'; $showoptions['2'] = '2'; $showoptions['5'] = '5'; for ($i = 10; $i <= 100; $i += 10) { $showoptions["{$i}"] = "{$i}"; } $mform->closeHeaderBefore('additemhdr'); $addgrp = array(); $addgrp[] = $mform->createElement('submit', 'addbutton', get_string('generatenewitemsset', 'qtype_calculatedsimple')); $addgrp[] = $mform->createElement('select', "selectadd", '', $addoptions); $addgrp[] = $mform->createElement('static', "stat", '', get_string('newsetwildcardvalues', 'qtype_calculatedsimple')); $mform->addGroup($addgrp, 'addgrp', '', ' ', false); $mform->registerNoSubmitButton('addbutton'); $mform->closeHeaderBefore('addgrp'); $addgrp1 = array(); $addgrp1[] = $mform->createElement('submit', 'showbutton', get_string('showitems', 'qtype_calculatedsimple')); $addgrp1[] = $mform->createElement('select', "selectshow", '', $showoptions); $addgrp1[] = $mform->createElement('static', "stat", '', get_string('setwildcardvalues', 'qtype_calculatedsimple')); $mform->addGroup($addgrp1, 'addgrp1', '', ' ', false); $mform->registerNoSubmitButton('showbutton'); $mform->closeHeaderBefore('addgrp1'); $mform->addElement('static', "divideradd", '', ''); if ($this->noofitems == 0) { $mform->addElement('static', 'warningnoitems', '', '' . get_string('youmustaddatleastonevalue', 'qtype_calculatedsimple') . ''); $mform->closeHeaderBefore('warningnoitems'); } else { $mform->addElement('header', 'additemhdr1', get_string('wildcardvalues', 'qtype_calculatedsimple')); $mform->closeHeaderBefore('additemhdr1'); if (!empty($this->numbererrors) || $this->outsidelimit) { $mform->addElement('static', "alert", '', '' . get_string('useadvance', 'qtype_calculatedsimple').''); } $mform->addElement('submit', 'updatedatasets', get_string('updatewildcardvalues', 'qtype_calculatedsimple')); $mform->registerNoSubmitButton('updatedatasets'); $mform->setAdvanced("updatedatasets", true); // ...--------------------------------------------------------------. $j = $this->noofitems * count($this->datasetdefs); $k = optional_param('selectshow', 1, PARAM_INT); for ($i = $this->noofitems; $i >= 1; $i--) { foreach ($this->datasetdefs as $defkey => $datasetdef) { if ($k > 0 || $this->outsidelimit || !empty($this->numbererrors)) { $mform->addElement('float', "number[{$j}]", get_string( 'wildcard', 'qtype_calculatedsimple', $datasetdef->name)); // Forcing new generated sets of wildcard values into the question edit form, // so they can be saved. $mform->setConstant("number[{$j}]", $datasetdef->items[$i]->value); $mform->setAdvanced("number[{$j}]", true); if (!empty($this->numbererrors['number['.$j.']'])) { $mform->addElement('static', "numbercomment[{$j}]", '', '' . $this->numbererrors['number['.$j.']'] . ''); $mform->setAdvanced("numbercomment[{$j}]", true); } } else { $mform->addElement('hidden', "number[{$j}]", ''); $mform->setType("number[{$j}]", PARAM_LOCALISEDFLOAT); // Localisation handling has to be done manually. if (isset($this->formdata["number[{$j}]"])) { $this->formdata["number[{$j}]"] = format_float($this->formdata["number[{$j}]"], -1); } } $mform->addElement('hidden', "itemid[{$j}]"); $mform->setType("itemid[{$j}]", PARAM_INT); $mform->addElement('hidden', "definition[{$j}]"); $mform->setType("definition[{$j}]", PARAM_NOTAGS); $j--; } if (!empty($strquestionlabel) && ($k > 0 || $this->outsidelimit || !empty($this->numbererrors))) { $mform->addElement('static', "answercomment[{$i}]", "" . get_string('setno', 'qtype_calculatedsimple', $i) . "  " . $strquestionlabel); } if ($k > 0 || $this->outsidelimit || !empty($this->numbererrors)) { $mform->addElement('static', "divider1[{$j}]", '', '
    '); } $k--; } } } else { $mform->addElement('static', 'warningnowildcards', '', '' . get_string('atleastonewildcard', 'qtype_calculatedsimple') . ''); $mform->closeHeaderBefore('warningnowildcards'); } // ...----------------------------------------------------------------------. // Non standard name for button element needed so not using add_action_buttons. // Hidden elements. $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'courseid'); $mform->setType('courseid', PARAM_INT); $mform->setDefault('courseid', 0); $mform->addElement('hidden', 'cmid'); $mform->setType('cmid', PARAM_INT); $mform->setDefault('cmid', 0); if (!empty($this->question->id)) { if ($this->question->formoptions->cansaveasnew) { $mform->addElement('header', 'additemhdr', get_string('converttocalculated', 'qtype_calculatedsimple')); $mform->closeHeaderBefore('additemhdr'); $mform->addElement('checkbox', 'convert', '', get_string('willconverttocalculated', 'qtype_calculatedsimple')); $mform->setDefault('convert', 0); } } } protected function can_preview() { return empty($this->question->beingcopied) && !empty($this->question->id) && $this->question->formoptions->canedit && $this->noofitems > 0; } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_answers($question); $question = $this->data_preprocessing_hints($question); $question = $this->data_preprocessing_units($question); $question = $this->data_preprocessing_unit_options($question); // This is a bit ugly, but it loads all the dataset values. $question = (object)((array)$question + $this->formdata); return $question; } public function qtype() { return 'calculatedsimple'; } public function validation($data, $files) { $errors = parent::validation($data, $files); if (array_key_exists('number', $data)) { $numbers = $data['number']; } else { $numbers = array(); } foreach ($numbers as $key => $number) { if (! is_numeric($number)) { if (stristr($number, ',')) { $errors['number['.$key.']'] = get_string('nocommaallowed', 'qtype_calculated'); } else { $errors['number['.$key.']'] = get_string('notvalidnumber', 'qtype_calculated'); } } else if (stristr($number, 'x')) { $a = new stdClass(); $a->name = ''; $a->value = $number; $errors['number['.$key.']'] = get_string('hexanotallowed', 'qtype_calculated', $a); } else if (is_nan($number)) { $errors['number['.$key.']'] = get_string('notvalidnumber', 'qtype_calculated'); } } if (empty($data['definition'])) { $errors['selectadd'] = get_string('youmustaddatleastonevalue', 'qtype_calculatedsimple'); } return $errors; } } PK\>  -type/calculatedsimple/tests/question_test.phpnu[. namespace qtype_calculatedsimple; use question_attempt_step; use question_classified_response; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for qtype_calculatedsimple_definition. * * @package qtype_calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \advanced_testcase { public function test_is_complete_response(): void { $question = \test_question_maker::make_question('calculatedsimple'); $this->assertFalse($question->is_complete_response(array())); $this->assertTrue($question->is_complete_response(array('answer' => '0'))); $this->assertTrue($question->is_complete_response(array('answer' => 0))); $this->assertFalse($question->is_complete_response(array('answer' => 'test'))); } public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('calculatedsimple'); $this->assertFalse($question->is_gradable_response(array())); $this->assertTrue($question->is_gradable_response(array('answer' => '0'))); $this->assertTrue($question->is_gradable_response(array('answer' => 0))); $this->assertTrue($question->is_gradable_response(array('answer' => 'test'))); } public function test_grading(): void { $question = \test_question_maker::make_question('calculatedsimple'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array(0, question_state::$gradedwrong), $question->grade_response(array('answer' => $values['a'] - $values['b']))); $this->assertEquals(array(1, question_state::$gradedright), $question->grade_response(array('answer' => $values['a'] + $values['b']))); } public function test_get_correct_response(): void { $question = \test_question_maker::make_question('calculatedsimple'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array('answer' => $values['a'] + $values['b']), $question->get_correct_response()); } public function test_get_question_summary(): void { $question = \test_question_maker::make_question('calculatedsimple'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $qsummary = $question->get_question_summary(); $this->assertEquals('What is ' . $values['a'] . ' + ' . $values['b'] . '?', $qsummary); } public function test_summarise_response(): void { $question = \test_question_maker::make_question('calculatedsimple'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals('3.1', $question->summarise_response(array('answer' => '3.1'))); } public function test_classify_response(): void { $question = \test_question_maker::make_question('calculatedsimple'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array( new question_classified_response(13, $values['a'] + $values['b'], 1.0)), $question->classify_response(array('answer' => $values['a'] + $values['b']))); $this->assertEquals(array( new question_classified_response(14, $values['a'] - $values['b'], 0.0)), $question->classify_response(array('answer' => $values['a'] - $values['b']))); $this->assertEquals(array( new question_classified_response(17, 7 * $values['a'], 0.0)), $question->classify_response(array('answer' => 7 * $values['a']))); $this->assertEquals(array( question_classified_response::no_response()), $question->classify_response(array('answer' => ''))); } } PK\d_2type/calculatedsimple/tests/question_type_test.phpnu[. namespace qtype_calculatedsimple; use qtype_calculated_dataset_loader; use qtype_calculatedsimple; use qtype_calculatedsimple_edit_form; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculatedsimple/questiontype.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/calculatedsimple/edit_calculatedsimple_form.php'); /** * Unit tests for the calculatedsimple question type class. * * @package qtype_calculatedsimple * @copyright 2007 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @covers \question_type * @covers \qtype_calculatedsimple * @covers \question_wizard_form * @covers \question_edit_form * @covers \qtype_calculated_edit_form * @covers \qtype_calculatedsimple_edit_form * */ final class question_type_test extends \advanced_testcase { protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_calculatedsimple(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'calculatedsimple'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_question_saving_sumwithvariants(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questiondata = \test_question_maker::get_question_data('calculatedsimple', 'sumwithvariants'); $formdata = \test_question_maker::get_question_form_data('calculatedsimple', 'sumwithvariants'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(array()); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_calculatedsimple_edit_form::mock_submit((array)$formdata); $form = \qtype_calculatedsimple_test_helper::get_question_editing_form($cat, $questiondata); $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'idnumber', 'hints'])) { $this->assertEquals($value, $actualquestiondata->$property); } } foreach ($questiondata->options as $optionname => $value) { if ($optionname != 'answers') { $this->assertEquals($value, $actualquestiondata->options->$optionname); } } foreach ($questiondata->options->answers as $answer) { $actualanswer = array_shift($actualquestiondata->options->answers); foreach ($answer as $ansproperty => $ansvalue) { if (!in_array($ansproperty, array('id', 'question', 'answerformat'))) { $this->assertEquals($ansvalue, $actualanswer->$ansproperty); } } } $this->assertCount(1, $actualquestiondata->hints); $hint = array_pop($actualquestiondata->hints); $this->assertEquals($formdata->hint[0]['text'], $hint->hint); $this->assertEquals($formdata->hint[0]['format'], $hint->hintformat); $datasetloader = new qtype_calculated_dataset_loader($actualquestiondata->id); $this->assertEquals(10, $datasetloader->get_number_of_items()); for ($itemno = 1; $itemno <= 10; $itemno++) { $item = $datasetloader->get_values($itemno); $this->assertEquals((float)$formdata->number[($itemno -1)*2 + 2], $item['a']); $this->assertEquals((float)$formdata->number[($itemno -1)*2 + 1], $item['b']); } } } PK\,0type/calculatedsimple/tests/walkthrough_test.phpnu[. namespace qtype_calculatedsimple; use question_hint; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the simple calculated question type. * * @package qtype_calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_interactive(): void { // Create a gapselect question. $q = \test_question_maker::make_question('calculatedsimple'); $q->hints = array( new question_hint(1, 'This is the first hint.', FORMAT_HTML), new question_hint(2, 'This is the second hint.', FORMAT_HTML), ); $this->start_attempt_at_question($q, 'interactive', 3); $values = $q->vs->get_values(); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Submit blank. $this->process_submission(array('-submit' => 1, 'answer' => '')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Sumit something that does not look like a number. $this->process_submission(array('-submit' => 1, 'answer' => 'newt')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), new \question_pattern_expectation('/' . preg_quote(get_string('invalidnumber', 'qtype_numerical'), '/') . '/'), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'answer' => $values['a'] + $values['b'])); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mark_summary(3), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_no_hint_visible_expectation()); } } PK\ǔ$$&type/calculatedsimple/tests/helper.phpnu[. /** * Test helpers for the simple calculated question type. * * @package qtype * @subpackage calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculated/tests/helper.php'); /** * Test helper class for the simple calculated question type. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple_test_helper extends question_test_helper { public function get_test_questions() { return array('sum', 'sumwithvariants'); } /** * Makes a simple calculated question about summing two numbers. * @return qtype_calculatedsimple_question */ public function make_calculatedsimple_question_sum() { question_bank::load_question_definition_classes('calculatedsimple'); $q = new qtype_calculatedsimple_question(); test_question_maker::initialise_a_question($q); $q->name = 'Simple sum'; $q->questiontext = 'What is {a} + {b}?'; $q->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $q->answers = array( 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} + {b}', 1.0, 'Very good.', FORMAT_HTML, 0), 14 => new \qtype_calculated\qtype_calculated_answer(14, '{a} - {b}', 0.0, 'Add. not subtract!.', FORMAT_HTML, 0), 17 => new \qtype_calculated\qtype_calculated_answer(17, '*', 0.0, 'Completely wrong.', FORMAT_HTML, 0), ); foreach ($q->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 1; } $q->qtype = question_bank::get_qtype('calculated'); $q->unitdisplay = qtype_numerical::UNITNONE; $q->unitgradingtype = 0; $q->unitpenalty = 0; $q->ap = new qtype_numerical_answer_processor(array()); $q->datasetloader = new qtype_calculated_test_dataset_loader(0, array( array('a' => 1, 'b' => 5), array('a' => 3, 'b' => 4), )); return $q; } public function get_calculatedsimple_question_form_data_sumwithvariants() { $form = new stdClass(); $form->name = 'Calculated simple'; $form->qtype = 'calculatedsimple'; $form->questiontext = array(); $form->questiontext['text'] = '

    This is a simple sum of two variables.

    '; $form->questiontext['format'] = '1'; $form->defaultmark = 1; $form->generalfeedback = array(); $form->generalfeedback['text'] = '

    The answer is  {a} + {b}

    '; $form->generalfeedback['format'] = '1'; $form->synchronize = 0; $form->initialcategory = 1; $form->reload = 1; $form->mform_isexpanded_id_answerhdr = 1; $form->noanswers = 1; $form->answer = array('{a} + {b}'); $form->fraction = array('1.0'); $form->tolerance = array(0.01); $form->tolerancetype = array('1'); $form->correctanswerlength = array('2'); $form->correctanswerformat = array('1'); $form->feedback = array(); $form->feedback[0] = array(); $form->feedback[0]['text'] = ''; $form->feedback[0]['format'] = '1'; $form->unitrole = '3'; $form->unitpenalty = 0.1; $form->unitgradingtypes = '1'; $form->unitsleft = '0'; $form->nounits = 1; $form->multiplier = array('1.0'); $form->penalty = '0.3333333'; $form->numhints = 2; $form->hint = array(); $form->hint[0] = array(); $form->hint[0]['text'] = ''; $form->hint[0]['format'] = '1'; $form->hint[1] = array(); $form->hint[1]['text'] = ''; $form->hint[1]['format'] = '1'; $form->calcmin = array(); $form->calcmin[1] = 1; $form->calcmin[2] = 1; $form->calcmax = array(); $form->calcmax[1] = 10; $form->calcmax[2] = 10; $form->calclength = array(); $form->calclength[1] = '1'; $form->calclength[2] = '1'; $form->calcdistribution = array(); $form->calcdistribution[1] = 0; $form->calcdistribution[2] = 0; $form->datasetdef = array(); $form->datasetdef[1] = '1-0-a'; $form->datasetdef[2] = '1-0-b'; $form->defoptions = array(); $form->defoptions[1] = ''; $form->defoptions[2] = ''; $form->selectadd = '10'; $form->selectshow = '10'; $form->number = array(); $form->number[1] = '2.3'; $form->number[2] = '7.6'; $form->number[3] = '2.1'; $form->number[4] = '6.4'; $form->number[5] = '1.4'; $form->number[6] = '1.9'; $form->number[7] = '9.9'; $form->number[8] = '9.5'; $form->number[9] = '9.0'; $form->number[10] = '5.2'; $form->number[11] = '2.1'; $form->number[12] = '7.3'; $form->number[13] = '7.9'; $form->number[14] = '1.2'; $form->number[15] = '2.3'; $form->number[16] = '3.4'; $form->number[17] = '1.9'; $form->number[18] = '5.2'; $form->number[19] = '3.4'; $form->number[20] = '3.4'; $form->itemid = array_fill(1, 20, 0); $form->definition = array(); $form->definition[1] = '1-0-b'; $form->definition[2] = '1-0-a'; $form->definition[3] = '1-0-b'; $form->definition[4] = '1-0-a'; $form->definition[5] = '1-0-b'; $form->definition[6] = '1-0-a'; $form->definition[7] = '1-0-b'; $form->definition[8] = '1-0-a'; $form->definition[9] = '1-0-b'; $form->definition[10] = '1-0-a'; $form->definition[11] = '1-0-b'; $form->definition[12] = '1-0-a'; $form->definition[13] = '1-0-b'; $form->definition[14] = '1-0-a'; $form->definition[15] = '1-0-b'; $form->definition[16] = '1-0-a'; $form->definition[17] = '1-0-b'; $form->definition[18] = '1-0-a'; $form->definition[19] = '1-0-b'; $form->definition[20] = '1-0-a'; $form->hint = [ [ 'text' => 'Add', 'format' => FORMAT_HTML, ], ]; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } public function get_calculatedsimple_question_data_sumwithvariants() { global $USER; $q = new stdClass(); $q->name = 'Calculated simple'; $q->createdby = $USER->id; $q->questiontext = '

    This is a simple sum of two variables.

    '; $q->questiontextformat = '1'; $q->generalfeedback = '

    The answer is  {a} + {b}

    '; $q->generalfeedbackformat = '1'; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->qtype = 'calculatedsimple'; $q->length = '1'; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->options = new stdClass(); $q->options->synchronize = 0; $q->options->single = 0; $q->options->answernumbering = 'abc'; $q->options->shuffleanswers = 0; $q->options->correctfeedback = ''; $q->options->partiallycorrectfeedback = ''; $q->options->incorrectfeedback = ''; $q->options->correctfeedbackformat = 0; $q->options->partiallycorrectfeedbackformat = 0; $q->options->incorrectfeedbackformat = 0; $q->options->answers = array(); $q->options->answers[0] = new stdClass(); $q->options->answers[0]->id = '6977'; $q->options->answers[0]->question = '3379'; $q->options->answers[0]->answer = '{a} + {b}'; $q->options->answers[0]->answerformat = '0'; $q->options->answers[0]->fraction = 1.0; $q->options->answers[0]->feedback = ''; $q->options->answers[0]->feedbackformat = '1'; $q->options->answers[0]->tolerance = '0.01'; $q->options->answers[0]->tolerancetype = '1'; $q->options->answers[0]->correctanswerlength = '2'; $q->options->answers[0]->correctanswerformat = '1'; $q->options->units = array(); $q->options->unitgradingtype = '0'; $q->options->unitpenalty = 0.1; $q->options->showunits = '3'; $q->options->unitsleft = '0'; $q->hints = array(); return $q; } } PK\{BP:[:[=type/calculatedsimple/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_calculatedsimple; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of simple calculated question attempts. * * @package qtype_calculatedsimple * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_calculatedsimple_adaptive_qsession95(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '13', 'uniqueid' => '13', 'quiz' => '4', 'userid' => '4', 'attempt' => '1', 'sumgrades' => '0.00000', 'timestart' => '1305830650', 'timefinish' => '1305830656', 'timemodified' => '1305830656', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '16', 'category' => '2', 'parent' => '0', 'name' => 'Calculated simple', 'questiontext' => '

    What is {={a}} + {={b}} ?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedsimple', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110517161007+2Barhu', 'version' => 'tjh238.vledev2.open.ac.uk+110517161008+Mu6OQu', 'hidden' => '0', 'timecreated' => '1305648607', 'timemodified' => '1305648607', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'synchronize' => 0, 'single' => 0, 'answernumbering' => 'abc', 'shuffleanswers' => 0, 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctfeedbackformat' => 0, 'partiallycorrectfeedbackformat' => 0, 'incorrectfeedbackformat' => 0, 'answers' => array( 23 => (object) array( 'id' => '23', 'question' => '16', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '

    Well done!

    ', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( ), 'unitgradingtype' => '0', 'unitpenalty' => '0.1000000', 'showunits' => '3', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '95', 'attemptid' => '13', 'questionid' => '16', 'newest' => '256', 'newgraded' => '256', 'sumpenalty' => '0.1000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 253 => (object) array( 'id' => '253', 'attempt' => '13', 'question' => '16', 'seq_number' => '0', 'answer' => 'dataset7-|||||', 'timestamp' => '1305830650', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 256 => (object) array( 'id' => '256', 'attempt' => '13', 'question' => '16', 'seq_number' => '1', 'answer' => 'dataset7-|||||', 'timestamp' => '1305830650', 'event' => '6', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 7, array('a' => '3', 'b' => '6')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 16, 'variant' => 7, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 3 + 6 ?', 'rightanswer' => '9', 'responsesummary' => '', 'timemodified' => 1305830650, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('_separators' => '.$,', '_var_a' => '3', '_var_b' => '6'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'gradedwrong', 'fraction' => 0, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('answer' => '', '-finish' => 1, '-_try' => 1, '-_rawfraction' => 0), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculatedsimple_adaptive_qsession98(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '14', 'uniqueid' => '14', 'quiz' => '4', 'userid' => '4', 'attempt' => '2', 'sumgrades' => '2.80000', 'timestart' => '1305830661', 'timefinish' => '1305830729', 'timemodified' => '1305830729', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '16', 'category' => '2', 'parent' => '0', 'name' => 'Calculated simple', 'questiontext' => '

    What is {={a}} + {={b}} ?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedsimple', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110517161007+2Barhu', 'version' => 'tjh238.vledev2.open.ac.uk+110517161008+Mu6OQu', 'hidden' => '0', 'timecreated' => '1305648607', 'timemodified' => '1305648607', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'synchronize' => 0, 'single' => 0, 'answernumbering' => 'abc', 'shuffleanswers' => 0, 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctfeedbackformat' => 0, 'partiallycorrectfeedbackformat' => 0, 'incorrectfeedbackformat' => 0, 'answers' => array( 23 => (object) array( 'id' => '23', 'question' => '16', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '

    Well done!

    ', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( ), 'unitgradingtype' => '0', 'unitpenalty' => '0.1000000', 'showunits' => '3', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '98', 'attemptid' => '14', 'questionid' => '16', 'newest' => '267', 'newgraded' => '267', 'sumpenalty' => '0.3000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 259 => (object) array( 'id' => '259', 'attempt' => '14', 'question' => '16', 'seq_number' => '0', 'answer' => 'dataset4-|||||', 'timestamp' => '1305830661', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 262 => (object) array( 'id' => '262', 'attempt' => '14', 'question' => '16', 'seq_number' => '1', 'answer' => 'dataset4-9.00|||||', 'timestamp' => '1305830668', 'event' => '3', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), 263 => (object) array( 'id' => '263', 'attempt' => '14', 'question' => '16', 'seq_number' => '2', 'answer' => 'dataset4-15.40|||||', 'timestamp' => '1305830679', 'event' => '3', 'grade' => '0.9000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), 267 => (object) array( 'id' => '267', 'attempt' => '14', 'question' => '16', 'seq_number' => '3', 'answer' => 'dataset4-15.40|||||', 'timestamp' => '1305830679', 'event' => '6', 'grade' => '0.9000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 4, array('a' => '6.4', 'b' => '9')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 16, 'variant' => 4, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 6.4 + 9 ?', 'rightanswer' => '15.4', 'responsesummary' => '15.40', 'timemodified' => 1305830679, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830661, 'userid' => 4, 'data' => array('_separators' => '.$,', '_var_a' => '6.4', '_var_b' => '9'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'todo', 'fraction' => 0, 'timecreated' => 1305830668, 'userid' => 4, 'data' => array('answer' => '9.00', '-submit' => 1, '-_try' => 1, '-_rawfraction' => 0), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'todo', 'fraction' => 0.9, 'timecreated' => 1305830679, 'userid' => 4, 'data' => array('answer' => '15.40', '-submit' => 1, '-_try' => 2, '-_rawfraction' => 1), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'gradedright', 'fraction' => 0.9, 'timecreated' => 1305830679, 'userid' => 4, 'data' => array('answer' => '15.40', '-finish' => 1, '-_try' => 2, '-_rawfraction' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculatedsimple_adaptive_qsession101(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '15', 'uniqueid' => '15', 'quiz' => '4', 'userid' => '3', 'attempt' => '1', 'sumgrades' => '0.70000', 'timestart' => '1305830744', 'timefinish' => '0', 'timemodified' => '1305830792', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '16', 'category' => '2', 'parent' => '0', 'name' => 'Calculated simple', 'questiontext' => '

    What is {={a}} + {={b}} ?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedsimple', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110517161007+2Barhu', 'version' => 'tjh238.vledev2.open.ac.uk+110517161008+Mu6OQu', 'hidden' => '0', 'timecreated' => '1305648607', 'timemodified' => '1305648607', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'synchronize' => 0, 'single' => 0, 'answernumbering' => 'abc', 'shuffleanswers' => 0, 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctfeedbackformat' => 0, 'partiallycorrectfeedbackformat' => 0, 'incorrectfeedbackformat' => 0, 'answers' => array( 23 => (object) array( 'id' => '23', 'question' => '16', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '

    Well done!

    ', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( ), 'unitgradingtype' => '0', 'unitpenalty' => '0.1000000', 'showunits' => '3', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '101', 'attemptid' => '15', 'questionid' => '16', 'newest' => '273', 'newgraded' => '270', 'sumpenalty' => '0.0000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 270 => (object) array( 'id' => '270', 'attempt' => '15', 'question' => '16', 'seq_number' => '0', 'answer' => 'dataset6-|||||', 'timestamp' => '1305830744', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 273 => (object) array( 'id' => '273', 'attempt' => '15', 'question' => '16', 'seq_number' => '1', 'answer' => 'dataset6-13.1|||||', 'timestamp' => '1305830755', 'event' => '2', 'grade' => '0.0000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 6, array('a' => '6.1', 'b' => '7')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 16, 'variant' => 6, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 6.1 + 7 ?', 'rightanswer' => '13.1', 'responsesummary' => '13.1', 'timemodified' => 1305830755, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830744, 'userid' => 3, 'data' => array('_separators' => '.$,', '_var_a' => '6.1', '_var_b' => '7'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1305830755, 'userid' => 3, 'data' => array('answer' => 13.1), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\8 4 <type/calculatedsimple/tests/behat/generate_wildcards.featurenu[@qtype @qtype_calculatedsimple Feature: Test creating sets of wildcard values As a teacher In order to be able to update my calculated simple questions I need to be able to create and re-create sets of wildcard values Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "user preferences" exist: | user | preference | value | | teacher | htmleditor | textarea | Scenario: Add a calculated simple question and re-generate different wild card values When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Calculated simple" question filling the form with: | Question name | calculatedsimple-001 | | Question text | {x1} + {x2} | | Answer 1 formula = | {x1} + {x2} | | Grade | 100% | And I should see "There must be at least one wild card" And I press "id_analyzequestion" And I should see "You must add at least one set of wild card(s) values before you can save this question." # Creating one set of wild card values by default. And I press "id_addbutton" And I should see "Wild card(s) values" # As the wild card values are random edit them to some known values. And I set the following fields to these values: | number[2] | 3.5 | | number[1] | 5.3 | And I press "id_updatedatasets" # Save changes and continue editing. And I press "id_updatebutton" Then I should see "3.5 + 5.3 = 8.80" And I should see "Correct answer : 8.80 inside limits of true value" # Now generate a new set of wild card values. And I press "id_addbutton" # Save changes and continue editing. And I press "id_updatebutton" # Make sure the old values are no longer stored. Then I should not see "3.5 + 5.3 = 8.80" And I should not see "Correct answer : 8.80 inside limits of true value" PK\-v//&type/calculatedsimple/questiontype.phpnu[. /** * Question type class for the simple calculated question type. * * @package qtype * @subpackage calculatedsimple * @copyright 2009 Pierre Pichet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); /** * The simple calculated question type. * * @copyright 2009 Pierre Pichet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedsimple extends qtype_calculated { // Used by the function custom_generator_tools. public $wizard_pages_number = 1; public function save_question_options($question) { global $CFG, $DB; $context = $question->context; // Make it impossible to save bad formulas anywhere. $this->validate_question_data($question); // Get old versions of the objects. if (!$oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { $oldanswers = array(); } if (!$oldoptions = $DB->get_records('question_calculated', array('question' => $question->id), 'answer ASC')) { $oldoptions = array(); } // Save the units. $virtualqtype = $this->get_virtual_qtype(); $result = $virtualqtype->save_units($question); if (isset($result->error)) { return $result; } else { $units = &$result->units; } // Insert all the new answers. foreach ($question->answer as $key => $answerdata) { if (is_array($answerdata)) { $answerdata = $answerdata['text']; } if (trim($answerdata) == '') { continue; } // Update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer->answer = trim($answerdata); $answer->fraction = $question->fraction[$key]; $answer->feedback = $this->import_or_save_files($question->feedback[$key], $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedback[$key]['format']; $DB->update_record("question_answers", $answer); // Set up the options object. if (!$options = array_shift($oldoptions)) { $options = new stdClass(); } $options->question = $question->id; $options->answer = $answer->id; $options->tolerance = trim($question->tolerance[$key]); $options->tolerancetype = trim($question->tolerancetype[$key]); $options->correctanswerlength = trim($question->correctanswerlength[$key]); $options->correctanswerformat = trim($question->correctanswerformat[$key]); // Save options. if (isset($options->id)) { // Reusing existing record. $DB->update_record('question_calculated', $options); } else { // New options. $DB->insert_record('question_calculated', $options); } } // Delete old answer records. if (!empty($oldanswers)) { foreach ($oldanswers as $oa) { $DB->delete_records('question_answers', array('id' => $oa->id)); } } // Delete old answer records. if (!empty($oldoptions)) { foreach ($oldoptions as $oo) { $DB->delete_records('question_calculated', array('id' => $oo->id)); } } if (isset($question->import_process) && $question->import_process) { $this->import_datasets($question); } else { // Save datasets and datatitems from form i.e in question. $question->dataset = $question->datasetdef; // Save datasets. $datasetdefinitions = $this->get_dataset_definitions($question->id, $question->dataset); $tmpdatasets = array_flip($question->dataset); $defids = array_keys($datasetdefinitions); $datasetdefs = array(); foreach ($defids as $defid) { $datasetdef = &$datasetdefinitions[$defid]; if (isset($datasetdef->id)) { if (!isset($tmpdatasets[$defid])) { // This dataset is not used any more, delete it. $DB->delete_records('question_datasets', array('question' => $question->id, 'datasetdefinition' => $datasetdef->id)); $DB->delete_records('question_dataset_definitions', array('id' => $datasetdef->id)); $DB->delete_records('question_dataset_items', array('definition' => $datasetdef->id)); } // This has already been saved or just got deleted. unset($datasetdefinitions[$defid]); continue; } $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); $datasetdefs[] = clone($datasetdef); $questiondataset = new stdClass(); $questiondataset->question = $question->id; $questiondataset->datasetdefinition = $datasetdef->id; $DB->insert_record('question_datasets', $questiondataset); unset($datasetdefinitions[$defid]); } // Remove local obsolete datasets as well as relations // to datasets in other categories. if (!empty($datasetdefinitions)) { foreach ($datasetdefinitions as $def) { $DB->delete_records('question_datasets', array('question' => $question->id, 'datasetdefinition' => $def->id)); if ($def->category == 0) { // Question local dataset. $DB->delete_records('question_dataset_definitions', array('id' => $def->id)); $DB->delete_records('question_dataset_items', array('definition' => $def->id)); } } } $datasetdefs = $this->get_dataset_definitions($question->id, $question->dataset); // Handle adding and removing of dataset items. $i = 1; ksort($question->definition); foreach ($question->definition as $key => $defid) { $addeditem = new stdClass(); $addeditem->definition = $datasetdefs[$defid]->id; $addeditem->value = $question->number[$i]; $addeditem->itemnumber = ceil($i / count($datasetdefs)); if (empty($question->makecopy) && $question->itemid[$i]) { // Reuse any previously used record. $addeditem->id = $question->itemid[$i]; $DB->update_record('question_dataset_items', $addeditem); } else { $DB->insert_record('question_dataset_items', $addeditem); } $i++; } $maxnumber = -1; if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber) { $maxnumber = $addeditem->itemnumber; foreach ($datasetdefs as $key => $newdef) { if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { $newdef->itemcount = $maxnumber; // Save the new value for options. $DB->update_record('question_dataset_definitions', $newdef); } } } } $this->save_hints($question); // Report any problems. if (!empty($question->makecopy) && !empty($question->convert)) { $DB->set_field('question', 'qtype', 'calculated', array('id' => $question->id)); } $result = $virtualqtype->save_unit_options($question); if (isset($result->error)) { return $result; } if (!empty($result->notice)) { return $result; } return true; } public function finished_edit_wizard($form) { return true; } public function wizard_pages_number() { return 1; } public function custom_generator_tools_part($mform, $idx, $j) { $minmaxgrp = array(); $minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]", get_string('calcmin', 'qtype_calculated')); $minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]", get_string('calcmax', 'qtype_calculated')); $mform->addGroup($minmaxgrp, 'minmaxgrp', get_string('minmax', 'qtype_calculated'), ' - ', false); $precisionoptions = range(0, 10); $mform->addElement('select', "calclength[{$idx}]", get_string('calclength', 'qtype_calculated'), $precisionoptions); $distriboptions = array('uniform' => get_string('uniform', 'qtype_calculated'), 'loguniform' => get_string('loguniform', 'qtype_calculated')); $mform->addElement('hidden', "calcdistribution[{$idx}]", 'uniform'); $mform->setType("calcdistribution[{$idx}]", PARAM_INT); } public function comment_header($answers) { $strheader = ""; $delimiter = ''; foreach ($answers as $key => $answer) { $ans = shorten_text($answer->answer, 17, true); $strheader .= $delimiter.$ans; $delimiter = '


    '; } return $strheader; } public function tolerance_types() { return array( '1' => get_string('relative', 'qtype_numerical'), '2' => get_string('nominal', 'qtype_numerical'), ); } public function dataset_options($form, $name, $mandatory = true, $renameabledatasets = false) { // Takes datasets from the parent implementation but // filters options that are currently not accepted by calculated. // It also determines a default selection // $renameabledatasets not implemented anywhere. list($options, $selected) = $this->dataset_options_from_database( $form, $name, '', 'qtype_calculated'); foreach ($options as $key => $whatever) { if (!preg_match('~^1-~', $key) && $key != '0') { unset($options[$key]); } } if (!$selected) { if ($mandatory) { $selected = "1-0-{$name}"; // Default. } else { $selected = "0"; // Default. } } return array($options, $selected); } } PK\7type/randomsamatch/renderer.phpnu[. /** * Matching question renderer class. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/match/renderer.php'); /** * Generates the output for randomsamatch questions. * * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_renderer extends qtype_match_renderer { public function format_stem_text($qa, $stemid) { $question = $qa->get_question(); return $question->format_text( $question->stems[$stemid], $question->stemformat[$stemid], $qa, 'question', 'questiontext', $stemid); } } PK\O||!type/randomsamatch/db/upgrade.phpnu[. /** * Matching question type upgrade code. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the random short answer matching question type. * @param int $oldversion the version we are upgrading from. */ function xmldb_qtype_randomsamatch_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\#3!type/randomsamatch/db/install.xmlnu[
    PK\ KK$K$$type/randomsamatch/db/upgradelib.phpnu[. /** * Upgrade library code for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Class for converting attempt data for randomsamatch questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_qe2_attempt_updater extends question_qtype_attempt_updater { /** @var array of question stems. */ protected $stems; /** @var array of question stems format. */ protected $stemformat; /** @var array of choices that can be matched to each stem. */ protected $choices; /** @var array index of the right choice for each stem. */ protected $right; /** @var array id of the right answer for each stem (used by {@link lookup_choice}). */ protected $rightanswerid; /** @var array shuffled stem indexes. */ protected $stemorder; /** @var array shuffled choice indexes. */ protected $choiceorder; /** @var array flipped version of the choiceorder array. */ protected $flippedchoiceorder; /** @var array of right answer for each stem. */ protected $rightanswer; public function question_summary() { return ''; // Done later, after we know which shortanswer questions are used. } public function right_answer() { return ''; // Done later, after we know which shortanswer questions are used. } /** * Explode the answer saved as a string in state * * @param string $answer comma separated list of dash separated pairs * @return array */ protected function explode_answer($answer) { if (!$answer) { return array(); } $bits = explode(',', $answer); $selections = array(); foreach ($bits as $bit) { list($stem, $choice) = explode('-', $bit); $selections[$stem] = $choice; } return $selections; } protected function make_summary($pairs) { $bits = array(); foreach ($pairs as $stem => $answer) { $bits[] = $stem . ' -> ' . $answer; } return implode('; ', $bits); } /** * Find the index corresponding to a choice * * @param integer $choice * @return integer */ protected function lookup_choice($choice) { if (array_key_exists($choice, $this->choices)) { // Easy case: choice is a key in the choices array. return $choice; } else { // But choice can also be the id of a shortanser correct answer // without been a key of the choices array, in that case we need // to first find the shortanswer id, then find the choices index // associated to it. $questionid = array_search($choice, $this->rightanswerid); if ($questionid) { return $this->right[$questionid]; } } return null; } public function response_summary($state) { $choices = $this->explode_answer($state->answer); if (empty($choices)) { return null; } $pairs = array(); foreach ($choices as $stemid => $choicekey) { if (array_key_exists($stemid, $this->stems) && $choices[$stemid]) { $choiceid = $this->lookup_choice($choicekey); if ($choiceid) { $pairs[$this->stems[$stemid]] = $this->choices[$choiceid]; } else { $this->logger->log_assumption("Dealing with a place where the student selected a choice that was later deleted for randomsamatch question {$this->question->id}"); $pairs[$this->stems[$stemid]] = '[CHOICE THAT WAS LATER DELETED]'; } } } if ($pairs) { return $this->make_summary($pairs); } else { return ''; } } public function was_answered($state) { $choices = $this->explode_answer($state->answer); foreach ($choices as $choice) { if ($choice) { return true; } } return false; } public function set_first_step_data_elements($state, &$data) { $this->stems = array(); $this->stemformat = array(); $this->choices = array(); $this->right = array(); $this->rightanswer = array(); $choices = $this->explode_answer($state->answer); $this->stemorder = array(); foreach ($choices as $key => $notused) { $this->stemorder[] = $key; } $wrappedquestions = array(); // TODO test what happen when some questions are missing. foreach ($this->stemorder as $questionid) { $wrappedquestions[] = $this->load_question($questionid); } foreach ($wrappedquestions as $wrappedquestion) { // We only take into account the first correct answer. $foundcorrect = false; foreach ($wrappedquestion->options->answers as $answer) { if ($foundcorrect || $answer->fraction != 1.0) { unset($wrappedquestion->options->answers[$answer->id]); } else if (!$foundcorrect) { $foundcorrect = true; // Store right answer id, so we can use it later in lookup_choice. $this->rightanswerid[$wrappedquestion->id] = $answer->id; $key = array_search($answer->answer, $this->choices); if ($key === false) { $key = $answer->id; $this->choices[$key] = $answer->answer; $data['_choice_' . $key] = $answer->answer; } $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext; $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat; $this->right[$wrappedquestion->id] = $key; $this->rightanswer[$wrappedquestion->id] = $answer->answer; $data['_stem_' . $wrappedquestion->id] = $wrappedquestion->questiontext; $data['_stemformat_' . $wrappedquestion->id] = $wrappedquestion->questiontextformat; $data['_right_' . $wrappedquestion->id] = $key; } } } $this->choiceorder = array_keys($this->choices); // We don't shuffle the choices as that seems unnecessary for old upgraded attempts. $this->flippedchoiceorder = array_combine( array_values($this->choiceorder), array_keys($this->choiceorder)); $data['_stemorder'] = implode(',', $this->stemorder); $data['_choiceorder'] = implode(',', $this->choiceorder); $this->updater->qa->questionsummary = $this->to_text($this->question->questiontext) . ' {' . implode('; ', $this->stems) . '} -> {' . implode('; ', $this->choices) . '}'; $answer = array(); foreach ($this->stems as $key => $stem) { $answer[$stem] = $this->choices[$this->right[$key]]; } $this->updater->qa->rightanswer = $this->make_summary($answer); } public function supply_missing_first_step_data(&$data) { throw new coding_exception('qtype_randomsamatch_updater::supply_missing_first_step_data ' . 'not tested'); $data['_stemorder'] = array(); $data['_choiceorder'] = array(); } public function set_data_elements_for_step($state, &$data) { $choices = $this->explode_answer($state->answer); foreach ($this->stemorder as $i => $key) { if (empty($choices[$key])) { $data['sub' . $i] = 0; continue; } $choice = $this->lookup_choice($choices[$key]); if (array_key_exists($choice, $this->flippedchoiceorder)) { $data['sub' . $i] = $this->flippedchoiceorder[$choice] + 1; } else { $data['sub' . $i] = 0; } } } public function load_question($questionid) { return $this->qeupdater->load_question($questionid); } } PK\h b\ \ 2type/randomsamatch/lang/en/qtype_randomsamatch.phpnu[. /** * Strings for component 'qtype_randomsamatch', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage randomsamatch * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['insufficientoptions'] = 'Insufficient selection options are available for this question, therefore it is not available in this quiz. Please inform your teacher.'; $string['nosaincategory'] = 'There are no short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some questions in this category.'; $string['notenoughsaincategory'] = 'There is/are only {$a->nosaquestions} short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some more questions in this category or reduce the amount of questions you\'ve selected.'; $string['pluginname'] = 'Random short-answer matching'; $string['pluginname_help'] = 'From the student perspective, this looks just like a matching question. The difference is that the list of names or statements (questions) for matching are drawn randomly from the short answer questions in the current category. There should be sufficient unused short answer questions in the category, otherwise an error message will be displayed.'; $string['pluginname_link'] = 'question/type/randomsamatch'; $string['pluginnameadding'] = 'Adding a Random short-answer matching question'; $string['pluginnameediting'] = 'Editing a Random short-answer matching question'; $string['pluginnamesummary'] = 'Like a Matching question, but created randomly from the short answer questions in a particular category.'; $string['privacy:metadata'] = 'The Random short-answer matching question type plugin does not store any personal data.'; $string['randomsamatchnumber'] = 'Number of questions to select'; $string['randomsamatch'] = 'Random short-answer matching'; $string['randomsamatchintro'] = 'For each of the following questions, select the matching answer from the menu.'; $string['subcats'] = 'Include subcategories'; $string['subcats_help'] = 'If checked, questions will be choosen from subcategories too.'; PK\Y@ Mtype/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup randomsamatch questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_randomsamatch_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(null, '../../qtype', 'randomsamatch'); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // Now create the qtype own structures. $randomsamatch = new backup_nested_element('randomsamatch', array('id'), array( 'choose', 'subcats', 'correctfeedback', 'correctfeedbackformat', 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', 'incorrectfeedback', 'incorrectfeedbackformat', 'shownumcorrect')); // Now the own qtype tree. $pluginwrapper->add_child($randomsamatch); // Set source to populate the data. $randomsamatch->set_source_table('qtype_randomsamatch_options', array('questionid' => backup::VAR_PARENTID)); return $plugin; } } PK\CRNtype/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * restore plugin class that provides the necessary information * needed to restore one randomsamatch qtype plugin * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin { /** * Returns the paths to be handled by the plugin at question level */ protected function define_question_plugin_structure() { $paths = array(); // Add own qtype stuff. $elename = 'randomsamatch'; $elepath = $this->get_pathfor('/randomsamatch'); $paths[] = new restore_path_element($elename, $elepath); return $paths; // And we return the interesting paths. } /** * Process the qtype/randomsamatch element */ public function process_randomsamatch($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its // qtype_randomsamatch_options too. if ($questioncreated) { // Fill in some field that were added in 2.1, and so which may be missing // from backups made in older versions of Moodle. if (!isset($data->subcats)) { $data->subcats = 1; } if (!isset($data->correctfeedback)) { $data->correctfeedback = ''; $data->correctfeedbackformat = FORMAT_HTML; } if (!isset($data->partiallycorrectfeedback)) { $data->partiallycorrectfeedback = ''; $data->partiallycorrectfeedbackformat = FORMAT_HTML; } if (!isset($data->incorrectfeedback)) { $data->incorrectfeedback = ''; $data->incorrectfeedbackformat = FORMAT_HTML; } if (!isset($data->shownumcorrect)) { $data->shownumcorrect = 0; } $data->questionid = $newquestionid; // It is possible for old backup files to contain unique key violations. // We need to check to avoid that. if (!$DB->record_exists('qtype_randomsamatch_options', array('questionid' => $data->questionid))) { $newitemid = $DB->insert_record('qtype_randomsamatch_options', $data); $this->set_mapping('qtype_randomsamatch_options', $oldid, $newitemid); } } } /** * Given one question_states record, return the answer * recoded pointing to all the restored stuff for randomsamatch questions * * answer is one comma separated list of hypen separated pairs * containing question->id and question_answers->id */ public function recode_legacy_state_answer($state) { $answer = $state->answer; $resultarr = array(); foreach (explode(',', $answer) as $pair) { $pairarr = explode('-', $pair); $questionid = $pairarr[0]; $answerid = $pairarr[1]; $newquestionid = $questionid ? $this->get_mappingid('question', $questionid) : 0; $newanswerid = $answerid ? $this->get_mappingid('question_answer', $answerid) : 0; $resultarr[] = implode('-', array($newquestionid, $newanswerid)); } return implode(',', $resultarr); } /** * Return the contents of this qtype to be processed by the links decoder. */ public static function define_decode_contents() { $contents = array(); $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); $contents[] = new restore_decode_content('qtype_randomsamatch_options', $fields, 'qtype_randomsamatch_options'); return $contents; } } PK\' )type/randomsamatch/backup/moodle1/lib.phpnu[. /** * Serve question type files * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Random shortanswer matching question type conversion handler. * * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ class moodle1_qtype_randomsamatch_handler extends moodle1_qtype_handler { /** * Returns the list of paths within one that this qtype needs to have included * in the grouped question structure * * @return array of strings */ public function get_question_subpaths() { return array( 'RANDOMSAMATCH', ); } /** * Appends the randomsamatch specific information to the question. * * @param array $data grouped question data * @param array $raw grouped raw QUESTION data */ public function process_question(array $data, array $raw) { // Convert match options. if (isset($data['randomsamatch'])) { $randomsamatch = $data['randomsamatch'][0]; } else { $randomsamatch = array('choose' => 4); } $randomsamatch['id'] = $this->converter->get_nextid(); $randomsamatch['subcats'] = 1; $randomsamatch['correctfeedback'] = ''; $randomsamatch['correctfeedbackformat'] = FORMAT_HTML; $randomsamatch['partiallycorrectfeedback'] = ''; $randomsamatch['partiallycorrectfeedbackformat'] = FORMAT_HTML; $randomsamatch['incorrectfeedback'] = ''; $randomsamatch['incorrectfeedbackformat'] = FORMAT_HTML; $this->write_xml('randomsamatch', $randomsamatch, array('/randomsamatch/id')); } } PK\^type/randomsamatch/pix/icon.gifnu[GIF89a ttt:::666111'''"""! ,EIk+& p (B U]WVLKF`'EjFQdL #J(W'ILD;PK\0dCtype/randomsamatch/pix/icon.svgnu[PK\`.type/randomsamatch/edit_randomsamatch_form.phpnu[. /** * Defines the editing form for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ defined('MOODLE_INTERNAL') || die(); /** * randomsamatch editing form definition. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ class qtype_randomsamatch_edit_form extends question_edit_form { protected function definition_inner($mform) { $questionstoselect = array(); for ($i = 2; $i <= qtype_randomsamatch::MAX_SUBQUESTIONS; $i++) { $questionstoselect[$i] = $i; } $mform->addElement('select', 'choose', get_string('randomsamatchnumber', 'qtype_randomsamatch'), $questionstoselect); $mform->setType('feedback', PARAM_RAW); $mform->addElement('advcheckbox', 'subcats', get_string('subcats', 'qtype_randomsamatch'), null, null, array(0, 1)); $mform->addHelpButton('subcats', 'subcats', 'qtype_randomsamatch'); $mform->setDefault('subcats', 1); $mform->addElement('hidden', 'fraction', 0); $mform->setType('fraction', PARAM_RAW); $this->add_combined_feedback_fields(true); $this->add_interactive_settings(true, true); } protected function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, true, true); if (!empty($question->options)) { $question->choose = $question->options->choose; $question->subcats = $question->options->subcats; } if (empty($question->name)) { $question->name = get_string('randomsamatch', 'qtype_randomsamatch'); } if (empty($question->questiontext)) { $question->questiontext = get_string('randomsamatchintro', 'qtype_randomsamatch'); } return $question; } public function qtype() { return 'randomsamatch'; } public function validation($data, $files) { global $DB; $errors = parent::validation($data, $files); if (isset($data->categorymoveto)) { list($category) = explode(',', $data['categorymoveto']); } else { list($category) = explode(',', $data['category']); } $saquestions = question_bank::get_qtype('randomsamatch')->get_available_saquestions_from_category( $category, $data['subcats']); $numberavailable = count($saquestions); if ($saquestions === false) { $a = new stdClass(); $a->catname = $DB->get_field('question_categories', 'name', array('id' => $category)); $errors['choose'] = get_string('nosaincategory', 'qtype_randomsamatch', $a); } else if ($numberavailable < $data['choose']) { $a = new stdClass(); $a->catname = $DB->get_field('question_categories', 'name', array('id' => $category)); $a->nosaquestions = $numberavailable; $errors['choose'] = get_string('notenoughsaincategory', 'qtype_randomsamatch', $a); } return $errors; } } PK\type/randomsamatch/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for random short answer matching questions. * @package qtype_randomsamatch * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_randomsamatch_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $DB, $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_randomsamatch', $filearea, $args, $forcedownload, $options); } PK\u-type/randomsamatch/version.phpnu[. /** * Version information for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->component = 'qtype_randomsamatch'; $plugin->dependencies = [ 'qtype_match' => 2025040800, 'qtype_shortanswer' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\!_?type/randomsamatch/question.phpnu[. /** * Matching question definition class. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/match/question.php'); /** * Represents a randomsamatch question. * * @copyright 22013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_question extends qtype_match_question { /** @var qtype_randomsamatch_question_loader helper for loading the shortanswer questions. */ public $questionsloader; /** @var int the number of subquestions to randomly create. */ public $choose; /** @var bool whether to include questions from subactegories when making the random selection. */ public $subcats; public function start_attempt(question_attempt_step $step, $variant) { $saquestions = $this->questionsloader->load_questions(); foreach ($saquestions as $wrappedquestion) { // Store and save stem text and format. $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext; $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat; $step->set_qt_var('_stem_' . $wrappedquestion->id, $this->stems[$wrappedquestion->id]); $step->set_qt_var('_stemformat_' . $wrappedquestion->id, $this->stemformat[$wrappedquestion->id]); // Find, store and save right choice id. $key = $this->find_right_answer($wrappedquestion); $this->right[$wrappedquestion->id] = $key; $step->set_qt_var('_right_' . $wrappedquestion->id, $key); // No need to save saquestions, it will be saved by parent class in _stemorder. } // Save all the choices. foreach ($this->choices as $key => $answer) { $step->set_qt_var('_choice_' . $key, $answer); } parent::start_attempt($step, $variant); } /** * Find the corresponding choice id of the first correct answer of a shortanswer question. * choice is added to the randomsamatch question if it doesn't already exist. * @param object $wrappedquestion short answer question. * @return int correct choice id. */ public function find_right_answer($wrappedquestion) { // We only take into account *one* (the first) correct answer. while ($answer = array_shift($wrappedquestion->answers)) { if (!question_state::graded_state_for_fraction( $answer->fraction)->is_incorrect()) { // Store this answer as a choice, only if this is a new one. $key = array_search($answer->answer, $this->choices); if ($key === false) { $key = $answer->id; $this->choices[$key] = $answer->answer; } return $key; } } // We should never get there. throw new coding_exception('shortanswerquestionwithoutrightanswer', $wrappedquestion->id); } public function apply_attempt_state(question_attempt_step $step) { $saquestions = explode(',', $step->get_qt_var('_stemorder')); foreach ($saquestions as $questionid) { $this->stems[$questionid] = $step->get_qt_var('_stem_' . $questionid); $this->stemformat[$questionid] = $step->get_qt_var('_stemformat_' . $questionid); $key = $step->get_qt_var('_right_' . $questionid); $this->right[$questionid] = $key; $this->choices[$key] = $step->get_qt_var('_choice_' . $key); } parent::apply_attempt_state($step); } } /** * This class is responsible for loading the questions that a question needs from the database. * * @copyright 2013 Jean-Michel vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_question_loader { /** @var array hold available shortanswers questionid to choose from. */ protected $availablequestions; /** @var int how many questions to load. */ protected $choose; /** * Constructor * @param array $availablequestions array of available question ids. * @param int $choose how many questions to load. */ public function __construct($availablequestions, $choose) { $this->availablequestions = $availablequestions; $this->choose = $choose; } /** * Choose and load the desired number of questions. * @return array of short answer questions. */ public function load_questions() { if ($this->choose > count($this->availablequestions)) { throw new coding_exception('notenoughtshortanswerquestions'); } $questionids = draw_rand_array($this->availablequestions, $this->choose); $questions = array(); foreach ($questionids as $questionid) { $questions[] = question_bank::load_question($questionid); } return $questions; } } PK\wJ/type/randomsamatch/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_randomsamatch. * * @package qtype_randomsamatch * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_randomsamatch\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_randomsamatch implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\\\*type/randomsamatch/tests/question_test.phpnu[. namespace qtype_randomsamatch; use question_attempt_step; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the random shortanswer matching question definition class. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \advanced_testcase { public function test_get_expected_data(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('sub0' => PARAM_INT, 'sub1' => PARAM_INT, 'sub2' => PARAM_INT, 'sub3' => PARAM_INT), $question->get_expected_data()); } public function test_is_complete_response(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $this->assertFalse($question->is_complete_response(array())); $this->assertFalse($question->is_complete_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '0'))); $this->assertFalse($question->is_complete_response(array('sub1' => '1'))); $this->assertTrue($question->is_complete_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '1'))); } public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $this->assertFalse($question->is_gradable_response(array())); $this->assertFalse($question->is_gradable_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_gradable_response( array('sub0' => '1', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_gradable_response(array('sub1' => '1'))); $this->assertTrue($question->is_gradable_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '3', 'sub3' => '1'))); } public function test_is_same_response(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $this->assertTrue($question->is_same_response( array(), array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_same_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertFalse($question->is_same_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); $this->assertTrue($question->is_same_response( array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); $this->assertFalse($question->is_same_response( array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); } public function test_grading(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $choiceorder = $question->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $this->assertEquals(array(1, question_state::$gradedright), $question->grade_response(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13]))); $this->assertEquals(array(0.25, question_state::$gradedpartial), $question->grade_response(array('sub0' => $orderforchoice[13]))); $this->assertEquals(array(0, question_state::$gradedwrong), $question->grade_response(array('sub0' => $orderforchoice[16], 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13], 'sub3' => $orderforchoice[16]))); } public function test_get_correct_response(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $choiceorder = $question->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $this->assertEquals(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13]), $question->get_correct_response()); } public function test_get_question_summary(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->start_attempt(new question_attempt_step(), 1); $qsummary = $question->get_question_summary(); $this->assertMatchesRegularExpression('/' . preg_quote($question->questiontext, '/') . '/', $qsummary); foreach ($question->stems as $stem) { $this->assertMatchesRegularExpression('/' . preg_quote($stem, '/') . '/', $qsummary); } foreach ($question->choices as $choice) { $this->assertMatchesRegularExpression('/' . preg_quote($choice, '/') . '/', $qsummary); } } public function test_summarise_response(): void { $question = \test_question_maker::make_question('randomsamatch'); $question->shufflestems = false; $question->start_attempt(new question_attempt_step(), 1); $summary = $question->summarise_response(array('sub0' => 2, 'sub1' => 1)); $this->assertMatchesRegularExpression('/Dog -> \w+; Frog -> \w+/', $summary); } } PK\ŎYY-type/randomsamatch/tests/walkthrough_test.phpnu[. namespace qtype_randomsamatch; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_deferred_feedback_unanswered(): void { // Create a randomsamatch question. $m = \test_question_maker::make_question('randomsamatch'); $this->start_attempt_at_question($m, 'deferredfeedback', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); $this->check_step_count(1); // Save a blank response. $this->process_submission(array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); $this->check_step_count(1); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, false), $this->get_contains_select_expectation('sub1', $choices, null, false), $this->get_contains_select_expectation('sub2', $choices, null, false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_deferred_feedback_partial_answer(): void { // Create a randomsamatching question. $m = \test_question_maker::make_question('randomsamatch'); $m->shufflestems = false; $this->start_attempt_at_question($m, 'deferredfeedback', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save a partial response. $this->process_submission(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => '0', 'sub3' => '0')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], true), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_partcorrect_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub2', $choices, null, false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_interactive_correct_no_submit(): void { // Create a randomsamatching question. $m = \test_question_maker::make_question('randomsamatch'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save the right answer. $this->process_submission(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13])); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(4); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false)); } public function test_interactive_partial_no_submit(): void { // Create a randomsamatching question. $m = \test_question_maker::make_question('randomsamatch'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save the right answer. $this->process_submission(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[13], 'sub3' => '0')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_partcorrect_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_interactive_with_invalid(): void { // Create a randomsamatching question. $m = \test_question_maker::make_question('randomsamatch'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Try to submit an invalid answer. $this->process_submission(array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0', '-submit' => '1')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_invalid_answer_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Now submit the right answer. $this->process_submission(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13], '-submit' => '1')); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(4); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false)); } public function test_randomsamatch_clear_wrong(): void { // Create a randomsamatching question. $m = \test_question_maker::make_question('randomsamatch'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, true), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = []; foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Submit a completely wrong response. $this->process_submission(array('sub0' => $orderforchoice[16], 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13], 'sub3' => $orderforchoice[16], '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub3', '0'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint.')); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[16], false)); // Try again. $this->process_submission(array('sub0' => 0, 'sub1' => 0, 'sub2' => 0, 'sub3' => 0, '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Submit a partially wrong response. $this->process_submission(array('sub0' => $orderforchoice[16], 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13], '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub2', $orderforchoice[16]), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[13]), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the second hint.')); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false)); // Try again. $this->process_submission(array('sub0' => 0, 'sub1' => 0, 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13], '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(1), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], true), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], true)); } } PK\뵡#type/randomsamatch/tests/helper.phpnu[. /** * Test helpers for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/randomsamatch/question.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Test helper class for the randomsamatch question type. * * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_test_helper extends question_test_helper { public function get_test_questions() { return array('animals'); } /** * Makes a randomsamatch question similar to the match question returned * by {@link make_a_matching_question}, but with no 'insect' distractor. * @return qtype_randomsamatch_question */ public function make_randomsamatch_question_animals() { question_bank::load_question_definition_classes('randomsamatch'); $q = new qtype_randomsamatch_question(); test_question_maker::initialise_a_question($q); $q->name = 'Random shortanswer matching question'; $q->questiontext = 'Classify the animals.'; $q->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.'; $q->qtype = question_bank::get_qtype('randomsamatch'); test_question_maker::set_standard_combined_feedback_fields($q); $q->shufflestems = false; $q->stems = array(); $q->choices = array(); $q->right = array(); // Now we create 4 shortanswers question, // but we just fill the needed fields. question_bank::load_question_definition_classes('shortanswer'); $sa1 = new qtype_shortanswer_question(); test_question_maker::initialise_a_question($sa1); $sa1->id = 25; $sa1->questiontext = 'Dog'; $sa1->answers = array( 13 => new question_answer(13, 'Mammal', 1.0, 'Correct.', FORMAT_HTML), 14 => new question_answer(14, 'Animal', 0.5, 'There is a betterresponse.', FORMAT_HTML), 15 => new question_answer(15, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), ); $sa1->qtype = question_bank::get_qtype('shortanswer'); $sa2 = new qtype_shortanswer_question(); test_question_maker::initialise_a_question($sa2); $sa2->id = 26; $sa2->questiontext = 'Frog'; $sa2->answers = array( 16 => new question_answer(16, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML), 17 => new question_answer(17, 'A Prince', 1.0, 'Maybe.', FORMAT_HTML), 18 => new question_answer(18, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), ); $sa2->qtype = question_bank::get_qtype('shortanswer'); $sa3 = new qtype_shortanswer_question(); test_question_maker::initialise_a_question($sa3); $sa3->id = 27; $sa3->questiontext = 'Toad'; $sa3->answers = array( 19 => new question_answer(19, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML), 20 => new question_answer(20, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), ); $sa3->qtype = question_bank::get_qtype('shortanswer'); $sa4 = new qtype_shortanswer_question(); test_question_maker::initialise_a_question($sa4); $sa4->id = 28; $sa4->questiontext = 'Cat'; $sa4->answers = array( 21 => new question_answer(21, 'Mammal', 1.0, 'Correct.', FORMAT_HTML), ); $sa4->qtype = question_bank::get_qtype('shortanswer'); $q->questionsloader = new qtype_randomsamatch_test_question_loader(array(), 4, array($sa1, $sa2, $sa3, $sa4)); return $q; } } /** * Test implementation of {@link qtype_randomsamatch_question_loader}. Gets the questions * from an array passed to the constructor, rather than querying the database. * * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch_test_question_loader extends qtype_randomsamatch_question_loader { /** @var array hold available shortanswers questions to choose from. */ protected $questions; /** * Constructor * @param array $availablequestions not used for tests. * @param int $choose how many questions to load (not used here). * @param array $questions array of questions to use. */ public function __construct($availablequestions, $choose, $questions) { parent::__construct($availablequestions, $choose); $this->questions = $questions; } /** * Just return the shortanswers questions passed to the constructor. * @return array of short answer questions. */ public function load_questions() { return $this->questions; } } PK\Tn:n::type/randomsamatch/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_randomsamatch; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of randomsamatch question attempts. * * @package qtype_randomsamatch * @copyright 2013 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_randomsamatch_deferredfeedback_qsession1(): void { $quiz = (object) array( 'id' => '1', 'course' => '2', 'name' => 'random short answer matching deferred quiz', 'intro' => '

    To test random shortanswer matching questions.

    ', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'review' => '4459503', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'questions' => '5,0', 'sumgrades' => '1.00000', 'grade' => '100.00000', 'timecreated' => '0', 'timemodified' => '1368446711', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'deferredfeedback', ); $attempt = (object) array( 'id' => '1', 'uniqueid' => '1', 'quiz' => '1', 'userid' => '3', 'attempt' => '1', 'sumgrades' => '0.66667', 'timestart' => '1368446755', 'timefinish' => '1368446789', 'timemodified' => '1368446789', 'layout' => '5,0', 'preview' => '0', 'needsupgradetonewqe' => 1, ); $question = (object) array( 'id' => '5', 'category' => '1', 'parent' => '0', 'name' => 'Random shortanswer matching question animals', 'questiontext' => 'For each of the following questions, select the matching answer from the menu.', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'penalty' => '0.1000000', 'qtype' => 'randomsamatch', 'length' => '1', 'stamp' => 'localhost+130513115611+72Efbk', 'version' => 'localhost+130513115611+0REXHW', 'hidden' => '0', 'timecreated' => '1368446171', 'timemodified' => '1368446171', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '1', 'question' => '5', 'choose' => '3', 'subcats' => 1, ), 'defaultmark' => '1.0000000', ); $qsession = (object) array( 'id' => '1', 'attemptid' => '1', 'questionid' => '5', 'newest' => '3', 'newgraded' => '3', 'sumpenalty' => '0.1000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 1 => (object) array( 'id' => '1', 'attempt' => '1', 'question' => '5', 'seq_number' => '0', 'answer' => '2-0,3-0,6-0', 'timestamp' => '1368446755', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 2 => (object) array( 'id' => '2', 'attempt' => '1', 'question' => '5', 'seq_number' => '1', 'answer' => '2-3,3-5,6-3', 'timestamp' => '1368446783', 'event' => '2', 'grade' => '0.0000000', 'raw_grade' => '0.6666667', 'penalty' => '0.1000000', ), 3 => (object) array( 'id' => '3', 'attempt' => '1', 'question' => '5', 'seq_number' => '2', 'answer' => '2-3,3-5,6-3', 'timestamp' => '1368446783', 'event' => '6', 'grade' => '0.6666667', 'raw_grade' => '0.6666667', 'penalty' => '0.1000000', ), ); $sa1 = (object) array( 'id' => '2', 'category' => '1', 'parent' => '0', 'name' => 'animal 1', 'questiontext' => 'Dog', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0.1', 'qtype' => 'shortanswer', 'length' => '1', 'stamp' => 'localhost+090227173002+mbdE0X', 'version' => 'localhost+090304190917+xAB5Nf', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '1235755802', 'timemodified' => '1236193757', 'createdby' => '25299', 'modifiedby' => '25299', 'unlimited' => '0', 'options' => (object) array( 'id' => '15211', 'question' => '2', 'layout' => '0', 'answers' => array( 7 => (object) array( 'question' => '2', 'answer' => 'Amphibian', 'fraction' => '0', 'feedback' => '', 'id' => 7, ), 3 => (object) array( 'question' => '2', 'answer' => 'Mammal', 'fraction' => '1', 'feedback' => '', 'id' => 3, ), 22 => (object) array( 'question' => '2', 'answer' => '*', 'fraction' => '0', 'feedback' => '', 'id' => 22, ), ), 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => 'Your answer is correct. Well done.', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', 'answernumbering' => 'abc', ), ); $sa2 = (object) array( 'id' => '3', 'category' => '1', 'parent' => '0', 'name' => 'animal 2', 'questiontext' => 'Frog', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0.1', 'qtype' => 'shortanswer', 'length' => '1', 'stamp' => 'localhost+090227173002+mbdE0X', 'version' => 'localhost+090304190917+xAB5Nf', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '1235755802', 'timemodified' => '1236193757', 'createdby' => '25299', 'modifiedby' => '25299', 'unlimited' => '0', 'options' => (object) array( 'id' => '15214', 'question' => '3', 'layout' => '0', 'answers' => array( 5 => (object) array( 'question' => '3', 'answer' => 'Amphibian', 'fraction' => '1', 'feedback' => '', 'id' => 5, ), 11 => (object) array( 'question' => '3', 'answer' => 'Mammal', 'fraction' => '0', 'feedback' => '', 'id' => 11, ), 27 => (object) array( 'question' => '3', 'answer' => '*', 'fraction' => '0', 'feedback' => '', 'id' => 27, ), ), 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => 'Your answer is correct. Well done.', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', 'answernumbering' => 'abc', ), ); $sa3 = (object) array( 'id' => '6', 'category' => '1', 'parent' => '0', 'name' => 'animal 3', 'questiontext' => 'Toad', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0.1', 'qtype' => 'shortanswer', 'length' => '1', 'stamp' => 'localhost+090227173002+mbdE0X', 'version' => 'localhost+090304190917+xAB5Nf', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '1235755802', 'timemodified' => '1236193757', 'createdby' => '25299', 'modifiedby' => '25299', 'unlimited' => '0', 'options' => (object) array( 'id' => '4578', 'question' => '6', 'layout' => '0', 'answers' => array( 9 => (object) array( 'question' => '6', 'answer' => 'Amphibian', 'fraction' => '1', 'feedback' => '', 'id' => 9, ), 18 => (object) array( 'question' => '6', 'answer' => 'Mammal', 'fraction' => '0', 'feedback' => '', 'id' => 18, ), 32 => (object) array( 'question' => '6', 'answer' => '*', 'fraction' => '0', 'feedback' => '', 'id' => 32, ), ), 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => 'Your answer is correct. Well done.', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', 'answernumbering' => 'abc', ), ); $this->loader->put_question_in_cache($sa2); $this->loader->put_question_in_cache($sa1); $this->loader->put_question_in_cache($sa3); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 5, 'variant' => 1, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'For each of the following questions, select the matching answer from the menu.{Dog;Frog;Toad}->{Mammal;Amphibian}', 'rightanswer' => 'Dog -> Mammal; Frog -> Amphibian; Toad -> Amphibian', 'responsesummary' => 'Dog->Mammal;Frog->Amphibian;Toad->Mammal', 'timemodified' => 1368446783, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1368446755, 'userid' => 3, 'data' => array( '_choice_3' => 'Mammal', '_stem_2' => 'Dog', '_stemformat_2' => '1', '_right_2' => 3, '_choice_5' => 'Amphibian', '_stem_3' => 'Frog', '_stemformat_3' => '1', '_right_3' => 5, '_stem_6' => 'Toad', '_stemformat_6' => '1', '_right_6' => 5, '_stemorder' => '2,3,6', '_choiceorder' => '3,5', ), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1368446783, 'userid' => 3, 'data' => array( 'sub0' => 1, 'sub1' => 2, 'sub2' => 1, ), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedpartial', 'fraction' => 0.6666667, 'timecreated' => 1368446783, 'userid' => 3, 'data' => array( 'sub0' => 1, 'sub1' => 2, 'sub2' => 1, '-finish' => 1, ), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\J*type/randomsamatch/tests/behat/add.featurenu[@qtype @qtype_randomsamatch Feature: Test creating a Random short-answer matching question As a teacher In order to test my students I need to be able to create a Random short-answer matching question Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Category 1 | And the following "questions" exist: | questioncategory | qtype | name | template | | Category 1 | shortanswer | Short answer question A version 1 | frogtoad | | Category 1 | shortanswer | Short answer question B version 1 | frogtoad | | Category 1 | shortanswer | Short answer question C version 1 | frogtoad | Scenario: Create a too large size of options Random short-answer matching question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Random short-answer matching" question filling the form with: | Category | Category 1 | | Question name | Random short-answer matching | | Question text | Random short-answer matching | | Default mark | 1 | | Number of questions to select | 4 | Then I should see "There is/are only 3 short answer questions in the category" Scenario: Create a Random short-answer matching question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher # Edit the first Short answer question so a version 2 is created. And I am on the "Short answer question A version 1" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Short answer question A version 2 | | Question text | Short answer question A version 2 | | id_answer_0 | Short answer Question A Version 2 Answer 1 | | id_answer_1 | Short answer Question A Version 2 Answer 2 | | id_answer_2 | Short answer Question A Version 2 Answer 3 | And I press "id_submitbutton" And I should see "Short answer question A version 2" # Edit the second Short answer question so a version 2 is created. And I am on the "Short answer question B version 1" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Short answer question B version 2 | | Question text | Short answer question B version 2 | | id_answer_0 | Short answer Question B Version 2 Answer 1 | | id_answer_1 | Short answer Question B Version 2 Answer 2 | | id_answer_2 | Short answer Question B Version 2 Answer 3 | And I press "id_submitbutton" And I should see "Short answer question B version 2" # Edit the third Short answer question so a version 2 is created. And I am on the "Short answer question C version 1" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Short answer question C version 2 | | Question text | Short answer question C version 2 | | id_answer_0 | Short answer Question C Version 2 Answer 1 | | id_answer_1 | Short answer Question C Version 2 Answer 2 | | id_answer_2 | Short answer Question C Version 2 Answer 3 | And I press "id_submitbutton" And I should see "Short answer question C version 2" # Create the Random short-answer question. And I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Random short-answer matching" question filling the form with: | Category | Category 1 | | Question name | Random short-answer matching | | Question text | Random short-answer matching | | Default mark | 1 | | Number of questions to select | 3 | And I should see "Random short-answer matching" And I am on the "Random short-answer matching" "core_question > preview" page logged in as teacher Then I should not see "Name an amphibian:" And I should see "Version 2" PK\"댉>$>$#type/randomsamatch/questiontype.phpnu[. /** * Question type class for the randomsamatch question type. * * @package qtype_randomsamatch * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questiontypebase.php'); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/numerical/question.php'); /** * The randomsamatch question type class. * * TODO: Make sure short answer questions chosen by a randomsamatch question * can not also be used by a random question * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch extends question_type { /** * Cache of available shortanswer question ids from a particular category. * @var array two-dimensional array. The first key is a category id, the * second key is wether subcategories should be included. */ private $availablesaquestionsbycategory = array(); const MAX_SUBQUESTIONS = 10; public function is_usable_by_random() { return false; } public function get_question_options($question) { global $DB; parent::get_question_options($question); $question->options = $DB->get_record('qtype_randomsamatch_options', array('questionid' => $question->id)); return true; } public function save_question_options($question) { global $DB; if (2 > $question->choose) { $result = new stdClass(); $result->error = "At least two shortanswer questions need to be chosen!"; return $result; } $context = $question->context; // Save the question options. $options = $DB->get_record('qtype_randomsamatch_options', array('questionid' => $question->id)); if (!$options) { $options = new stdClass(); $options->questionid = $question->id; $options->correctfeedback = ''; $options->partiallycorrectfeedback = ''; $options->incorrectfeedback = ''; $options->id = $DB->insert_record('qtype_randomsamatch_options', $options); } $options->choose = $question->choose; $options->subcats = $question->subcats; $options = $this->save_combined_feedback_helper($options, $question, $context, true); $DB->update_record('qtype_randomsamatch_options', $options); $this->save_hints($question, true); return true; } protected function make_hint($hint) { return question_hint_with_parts::load_from_record($hint); } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('qtype_randomsamatch_options', array('questionid' => $questionid)); parent::delete_question($questionid, $contextid); } public function move_files($questionid, $oldcontextid, $newcontextid) { parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } protected function delete_files($questionid, $contextid) { parent::delete_files($questionid, $contextid); $this->delete_files_in_combined_feedback($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $availablesaquestions = $this->get_available_saquestions_from_category( $question->category, $questiondata->options->subcats); $question->shufflestems = false; $question->stems = array(); $question->choices = array(); $question->right = array(); $this->initialise_combined_feedback($question, $questiondata); $question->questionsloader = new qtype_randomsamatch_question_loader( $availablesaquestions, $questiondata->options->choose); } public function can_analyse_responses() { return false; } /** * Get all the usable shortanswer questions from a particular question category. * * @param integer $categoryid the id of a question category. * @param bool $subcategories whether to include questions from subcategories. * @return array of question records. */ public function get_available_saquestions_from_category($categoryid, $subcategories) { if (isset($this->availablesaquestionsbycategory[$categoryid][$subcategories])) { return $this->availablesaquestionsbycategory[$categoryid][$subcategories]; } if ($subcategories) { $categoryids = question_categorylist($categoryid); } else { $categoryids = array($categoryid); } $questionids = question_bank::get_finder()->get_questions_from_categories( $categoryids, "qtype = 'shortanswer'"); $this->availablesaquestionsbycategory[$categoryid][$subcategories] = $questionids; return $questionids; } /** * @param object $question * @return mixed either a integer score out of 1 that the average random * guess by a student might give or an empty string which means will not * calculate. */ public function get_random_guess_score($question) { return 1/$question->options->choose; } /** * Defines the table which extends the question table. This allows the base questiontype * to automatically save, backup and restore the extra fields. * * @return an array with the table name (first) and then the column names (apart from id and questionid) */ public function extra_question_fields() { return array('qtype_randomsamatch_options', 'choose', // Number of shortanswer questions to choose. 'subcats', // Questions can be choosen from subcategories. ); } /** * Imports the question from Moodle XML format. * * @param array $xml structure containing the XML data * @param object $fromform question object to fill: ignored by this function (assumed to be null) * @param qformat_xml $format format class exporting the question * @param object $extra extra information (not required for importing this question in this format) * @return object question object */ public function import_from_xml($xml, $fromform, qformat_xml $format, $extra=null) { // Return if data type is not our own one. if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) { return false; } // Import the common question headers and set the corresponding field. $fromform = $format->import_headers($xml); $fromform->qtype = $this->name(); $format->import_combined_feedback($fromform, $xml, true); $format->import_hints($fromform, $xml, true); $extras = $this->extra_question_fields(); array_shift($extras); foreach ($extras as $extra) { $fromform->$extra = $format->getpath($xml, array('#', $extra, 0, '#'), '', true); } return $fromform; } /** * Exports the question to Moodle XML format. * * @param object $question question to be exported into XML format * @param qformat_xml $format format class exporting the question * @param object $extra extra information (not required for exporting this question in this format) * @return string containing the question data in XML format */ public function export_to_xml($question, qformat_xml $format, $extra=null) { $expout = ''; $expout .= $format->write_combined_feedback($question->options, $question->id, $question->contextid); $extraquestionfields = $this->extra_question_fields(); array_shift($extraquestionfields); foreach ($extraquestionfields as $extra) { $expout .= " <{$extra}>" . $question->options->$extra . "\n"; } return $expout; } } PK\2ytype/ordering/styles.cssnu[/* Styles for the initial layout of the question. */ /* Ensure container covers the draggable items. */ .que.ordering div.answer.ordering { overflow: auto; } .que.ordering .sortablelist { float: left; list-style-type: none; margin: 0 0 0 8px; } .que.ordering .sortablelist.active { border: 1px dotted #333; border-radius: 4px; } .que.ordering .sortablelist li { background-color: #fff; border: 1px solid #000; border-radius: 4px; list-style-type: none; margin: 4px; } .que.ordering .sortablelist li .grip { opacity: 0.45; } .que.ordering .sortablelist li.sortableitem { position: relative; cursor: move; margin-left: 26px; /* The margin is needed for the list-style-type in numberingxxx classes */ } .que.ordering .sortablelist li.sortableitem:first-of-type [data-action="move-backward"], .que.ordering .sortablelist li.sortableitem:last-of-type [data-action="move-forward"] { visibility: hidden; } .que.ordering .sortableitem .btn.btn-icon { height: 32px; width: 32px; border-radius: 0.5rem; } .que.ordering .sortablelist.numbering123 li, .que.ordering .sortablelist.numberingabc li, .que.ordering .sortablelist.numberingABCD li, .que.ordering .sortablelist.numberingiii li, .que.ordering .sortablelist.numberingIIII li { margin-left: 26px; /* The margin is needed for the list-style-type in numberingxxx classes */ } .que.ordering .sortablelist.numberingnone li { list-style-type: none; margin-left: 0; } .que.ordering .sortablelist.numbering123 li { list-style-type: decimal; } .que.ordering .sortablelist.numberingabc li { list-style-type: lower-alpha; } .que.ordering .sortablelist.numberingABCD li { list-style-type: upper-alpha; } .que.ordering .sortablelist.numberingiii li { list-style-type: lower-roman; } .que.ordering .sortablelist.numberingIIII li { list-style-type: upper-roman; } .que.ordering .sortablelist.horizontal { display: flex; flex-wrap: wrap; } /* Better define 'row' of item for horizontal list. */ .que.ordering .sortablelist.horizontal { display: flex; flex-wrap: wrap; align-items: flex-start; } .que.ordering .sortablelist.vertical li { min-height: 18px; } /* Styles for when things are being dragged. */ .que.ordering.dragproxy { margin: 0; padding: 0; border: 0 none; } .que.ordering.dragproxy .sortablelist { margin: 0; padding: 0; float: none; } .que.ordering.dragproxy .sortablelist li { margin: 0; padding: 6px 0 6px 12px; width: 100%; } .que.ordering.dragproxy .sortablelist li.horizontal { float: none; } .item-moving { box-shadow: 3px 3px 4px #000; } .current-drop { visibility: hidden; } /* Styles for feedback. */ .que.ordering .sortablelist.notactive li.correct { background-color: #dff4d8; /* light green */ border-color: #9f6; /* gentle green */ } .que.ordering .sortablelist.notactive li.partial66 { background-color: #dff4d8; /* light green */ border-color: #f90; /* dark orange */ } .que.ordering .sortablelist.notactive li.partial33 { background-color: #ffebcc; /* light orange */ border-color: #f90; /* dark orange */ } .que.ordering .sortablelist.notactive li.partial00 { background-color: #fdd; /* light red */ border-color: #f90; /* dark orange */ } .que.ordering .sortablelist.notactive li.incorrect { background-color: #fdd; /* light red */ border-color: #ff7373; /* gentle red */ } /* Force containing DIV to cover the floating LI elements Note: if you add "overflow:auto; to "ol.correctorder" then the numbers for the
  • elements disappear !! */ .que.ordering div.rightanswer { overflow: auto; } .que.ordering div.rightanswer ol.correctorder { padding-inline-start: 16px; } .que.ordering div.rightanswer ol.correctorder.horizontal { display: flex; flex-wrap: wrap; align-items: baseline; } .que.ordering div.rightanswer ol.correctorder li.horizontal { margin-left: 24px; margin-right: 24px; } .que.ordering div.rightanswer ol.correctorder li.vertical { margin-left: 24px; } /* the width restriction can be limited to editors for draggable items by inserting "fieldset:nth-child(n+4)" before "div.feditor" */ #page-question-type-ordering form.mform fieldset:nth-child(n+4) div.feditor { max-width: 480px; } PK\ػZ type/ordering/renderer.phpnu[. use qtype_ordering\output\correct_response; use qtype_ordering\output\feedback; use qtype_ordering\output\formulation_and_controls; use qtype_ordering\output\num_parts_correct; use qtype_ordering\output\specific_grade_detail_feedback; /** * Ordering question renderer class. * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordonbateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { public function formulation_and_controls(question_attempt $qa, question_display_options $options): string { $formulationandcontrols = new formulation_and_controls($qa, $options); return $this->output->render_from_template('qtype_ordering/formulation_and_controls', $formulationandcontrols->export_for_template($this->output)); } public function feedback(question_attempt $qa, question_display_options $options): string { $feedback = new feedback($qa, $options); return $this->output->render_from_template('qtype_ordering/feedback', $feedback->export_for_template($this->output)); } /** * Display the grade detail of the response. * * @param question_attempt $qa The question attempt to display. * @return string Output grade detail of the response. * @throws moodle_exception */ public function specific_grade_detail_feedback(question_attempt $qa): string { $specificgradedetailfeedback = new specific_grade_detail_feedback($qa); return $this->output->render_from_template('qtype_ordering/specific_grade_detail_feedback', $specificgradedetailfeedback->export_for_template($this->output)); } public function specific_feedback(question_attempt $qa): string { return $this->combined_feedback($qa); } public function correct_response(question_attempt $qa): string { $correctresponse = new correct_response($qa); return $this->output->render_from_template('qtype_ordering/correct_response', $correctresponse->export_for_template($this->output)); } protected function num_parts_correct(question_attempt $qa): string { $numpartscorrect = new num_parts_correct($qa); return $this->output->render_from_template('qtype_ordering/num_parts_correct', $numpartscorrect->export_for_template($this->output)); } public function feedback_image($fraction, $selected = true): string { return parent::feedback_image($fraction); } } PK\5I5Itype/ordering/db/upgrade.phpnu[. /** * Ordering question type db upgrade script * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the ordering question type. * * @param int $oldversion the version we are upgrading from. */ function xmldb_qtype_ordering_upgrade($oldversion) { global $CFG, $DB; $dbman = $DB->get_manager(); if ($oldversion < 2013062800) { $select = 'qn.*, qo.id AS questionorderingid'; $from = '{question} qn LEFT JOIN {question_ordering} qo ON qn.id = qo.question'; $where = 'qn.qtype = ? AND qo.id IS NULL'; $params = ['ordering']; if ($questions = $DB->get_records_sql("SELECT $select FROM $from WHERE $where", $params)) { foreach ($questions as $question) { if ($answers = $DB->get_records('question_answers', ['question' => $question->id])) { // Add "options" for this ordering question. $questionordering = (object) [ 'question' => $question->id, 'logical' => 1, 'studentsee' => min(6, count($answers)), 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', ]; $questionordering->id = $DB->insert_record('question_ordering', $questionordering); } else { // This is a faulty ordering question - remove it. $DB->delete_records('question', ['id' => $question->id]); if ($dbman->table_exists('quiz_question_instances')) { $DB->delete_records('quiz_question_instances', ['question' => $question->id]); } if ($dbman->table_exists('reader_question_instances')) { $DB->delete_records('reader_question_instances', ['question' => $question->id]); } } } } upgrade_plugin_savepoint(true, 2013062800, 'qtype', 'ordering'); } if ($oldversion < 2015011915) { // Rename "ordering" table for Moodle >= 2.5. $oldname = 'question_ordering'; $newname = 'qtype_ordering_options'; if ($dbman->table_exists($oldname)) { $oldtable = new xmldb_table($oldname); if ($dbman->table_exists($newname)) { $dbman->drop_table($oldtable); } else { $dbman->rename_table($oldtable, $newname); } } // Remove index on question(id) field (because we want to modify the field). $table = new xmldb_table('qtype_ordering_options'); $fields = ['question', 'questionid']; foreach ($fields as $field) { if ($dbman->field_exists($table, $field)) { $index = new xmldb_index('qtypordeopti_que_uix', XMLDB_INDEX_UNIQUE, [$field]); if ($dbman->index_exists($table, $index)) { $dbman->drop_index($table, $index); } } } // Rename "question" -> "questionid". // Rename "logical" -> "selecttype". // Rename "studentsee" -> "selectcount". // Add "(xxx)feedbackformat" fields. $table = new xmldb_table('qtype_ordering_options'); $fields = [ 'questionid' => new xmldb_field('question', XMLDB_TYPE_INTEGER, '11', null, XMLDB_NOTNULL, null, '0', 'id'), 'selecttype' => new xmldb_field('logical', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0', 'questionid'), 'selectcount' => new xmldb_field('studentsee', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0', 'selecttype'), 'correctfeedbackformat' => new xmldb_field('correctfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'correctfeedback'), 'incorrectfeedbackformat' => new xmldb_field('incorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'incorrectfeedback'), 'partiallycorrectfeedbackformat' => new xmldb_field('partiallycorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'partiallycorrectfeedback'), ]; foreach ($fields as $newname => $field) { $oldexists = $dbman->field_exists($table, $field); $newexists = $dbman->field_exists($table, $newname); if ($field->getName() != $newname && $oldexists) { if ($newexists) { $dbman->drop_field($table, $field); } else { $dbman->rename_field($table, $field, $newname); $newexists = true; } $oldexists = false; } $field->setName($newname); if ($newexists) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); } } // Make sure there are no duplicate "questionid" fields in "qtype_ordering_options" table. $select = 'questionid, COUNT(*) AS countduplicates, MAX(id) AS maxid'; $from = '{qtype_ordering_options}'; $group = 'questionid'; $having = 'countduplicates > ?'; $params = [1]; if ($records = $DB->get_records_sql("SELECT $select FROM $from GROUP BY $group HAVING $having", $params)) { foreach ($records as $record) { $select = 'id <> ? AND questionid = ?'; $params = [$record->maxid, $record->questionid]; $DB->delete_records_select('qtype_ordering_options', $select, $params); } } // Restore index on questionid field. $table = new xmldb_table('qtype_ordering_options'); $index = new xmldb_index('qtypordeopti_que_uix', XMLDB_INDEX_UNIQUE, ['questionid']); if (! $dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } upgrade_plugin_savepoint(true, 2015011915, 'qtype', 'ordering'); } if ($oldversion < 2015110725) { $table = new xmldb_table('qtype_ordering_options'); $fields = [ new xmldb_field('layouttype', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, 0, 'questionid'), new xmldb_field('selecttype', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, 0, 'layouttype'), ]; foreach ($fields as $field) { if ($dbman->field_exists($table, $field)) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); } } upgrade_plugin_savepoint(true, 2015110725, 'qtype', 'ordering'); } if ($oldversion < 2015121734) { $table = new xmldb_table('qtype_ordering_options'); $fields = [ new xmldb_field('gradingtype', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, 0, 'selectcount'), ]; foreach ($fields as $field) { if ($dbman->field_exists($table, $field)) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); // When adding this field to existing records, // the gradingtype is set to whatever the selecttype is. $DB->execute('UPDATE {qtype_ordering_options} SET gradingtype = selecttype', []); } } upgrade_plugin_savepoint(true, 2015121734, 'qtype', 'ordering'); } if ($oldversion < 2016032949) { if ($dbman->table_exists('reader_question_instances')) { $select = 'rqi.question, COUNT(*) AS countquestion'; $from = '{reader_question_instances} rqi '. 'LEFT JOIN {question} q ON rqi.question = q.id'; $where = 'q.qtype = ?'; $group = 'rqi.question'; $params = ['ordering']; if ($questions = $DB->get_records_sql("SELECT $select FROM $from WHERE $where GROUP BY $group", $params)) { $questions = array_keys($questions); list($select, $params) = $DB->get_in_or_equal($questions); $select = "questionid $select"; $table = 'qtype_ordering_options'; $DB->set_field_select($table, 'layouttype', 0, $select, $params); // VERTICAL. $DB->set_field_select($table, 'selecttype', 1, $select, $params); // RANDOM. $DB->set_field_select($table, 'gradingtype', 1, $select, $params); // RELATIVE. // For selectcount, we only fix the value, if it is zero (=ALL) // because Ordering questions for some low level books use 4. $select .= ' AND selectcount = ?'; $params[] = 0; $DB->set_field_select($table, 'selectcount', 6, $select, $params); // Six. } } upgrade_plugin_savepoint(true, 2016032949, 'qtype', 'ordering'); } if ($oldversion < 2016081655) { $table = new xmldb_table('qtype_ordering_options'); $field = new xmldb_field('showgrading', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, 1, 'gradingtype'); if ($dbman->field_exists($table, $field)) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); } upgrade_plugin_savepoint(true, 2016081655, 'qtype', 'ordering'); } if ($oldversion < 2019071191) { // Add field "numberingstyle" to table "qtype_ordering_options". // This field was briefly called "answernumbering". $table = new xmldb_table('qtype_ordering_options'); $field = new xmldb_field('answernumbering', XMLDB_TYPE_CHAR, '10', null, XMLDB_NOTNULL, null, 'none', 'showgrading'); $newname = 'numberingstyle'; $oldexists = $dbman->field_exists($table, $field); $newexists = $dbman->field_exists($table, $newname); if ($oldexists) { if ($newexists) { $dbman->drop_field($table, $field); } else { $dbman->rename_field($table, $field, $newname); $newexists = true; } $oldexists = false; } $field->setName($newname); if ($newexists) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); } upgrade_plugin_savepoint(true, 2019071191, 'qtype', 'ordering'); } if ($oldversion < 2019073193) { $table = 'qtype_ordering_options'; $field = 'numberingstyle'; $select = "$field = ? OR $field = ?"; $params = ['III', 'ABC']; if ($options = $DB->get_records_select($table, $select, $params, $field, "id,$field")) { foreach ($options as $option) { switch ($option->numberingstyle) { case 'ABC': $DB->set_field($table, $field, 'ABCD', ['id' => $option->id]); break; case 'III': $DB->set_field($table, $field, 'IIII', ['id' => $option->id]); break; // phpcs:ignore Squiz.PHP.CommentedOutCode.Found // Ignore "abc", "iii", and anything else. } } } upgrade_plugin_savepoint(true, 2019073193, 'qtype', 'ordering'); } if ($oldversion < 2022092000) { $table = new xmldb_table('qtype_ordering_options'); $field = new xmldb_field('shownumcorrect', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, 0); if ($dbman->field_exists($table, $field)) { $dbman->change_field_type($table, $field); } else { $dbman->add_field($table, $field); } upgrade_plugin_savepoint(true, 2022092000, 'qtype', 'ordering'); } if ($oldversion < 2023092911) { // The option to set "All" for the subset size ('selectcount') setting is no longer supported, therefore we need // to update all data that references this option. // The 'selectcount' column from the 'qtype_ordering_options' table currently defines "0" as its default value. // This value ("0") used to represent the removed "All" option for the 'selectcount' setting, therefore we need // to update this to a new default of "2" which is the minimum number of items required to create a subset. $table = new xmldb_table('qtype_ordering_options'); $field = new xmldb_field('selectcount'); $field->set_attributes(XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, 2); $dbman->change_field_default($table, $field); // We need to find all ordering question configurations that currently store the unsupported "0" (all) option // for the 'selectcount' setting and the total number of answers that are related to each of these ordering // questions. $sql = "SELECT qoo.id, COUNT(DISTINCT(qa.id)) AS answerscount FROM {qtype_ordering_options} qoo JOIN {question_answers} qa ON qa.question = qoo.questionid WHERE selectcount = :selectcount GROUP BY qoo.id"; $questionoptions = $DB->get_recordset_sql($sql, ['selectcount' => 0]); foreach ($questionoptions as $questionoption) { // Update the value of the 'selectcount' configuration option for the current ordering question and set it // to the total number of answers related to this question. This way, we are making sure that the original // behavior is preserved and all existing items (answers) related to the question will be included in the // subset. $DB->set_field('qtype_ordering_options', 'selectcount', $questionoption->answerscount, ['id' => $questionoption->id]); } $questionoptions->close(); // Currently, a 'qtype_ordering_selectcount' user preference is set (or updated, if it already exists) each time // a new ordering question is created. If there are user preferences that store the removed "0" (all) option, they // need to be updated. In this case, replace it with "2" (minimum number of items required to create a subset). $wheresql = 'name = ? AND ' . $DB->sql_compare_text('value') . ' = ?'; $params = ['qtype_ordering_selectcount', '0']; $DB->set_field_select('user_preferences', 'value', 2, $wheresql, $params); upgrade_plugin_savepoint(true, 2023092911, 'qtype', 'ordering'); } if ($oldversion < 2024040401) { // The option to set "All" for the subset size ('selectcount') setting is no longer supported, therefore we need // to update all data that references this option. // The 'selectcount' column from the 'qtype_ordering_options' table currently defines "0" as its default value. // This value ("0") used to represent the removed "All" option for the 'selectcount' setting, therefore we need // to update this to a new default of "2" which is the minimum number of items required to create a subset. $table = new xmldb_table('qtype_ordering_options'); $field = new xmldb_field('selectcount'); $field->set_attributes(XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, 2); $dbman->change_field_default($table, $field); // We need to find all ordering question configurations that currently store the unsupported "0" (all) option // for the 'selectcount' setting and the total number of answers that are related to each of these ordering // questions. $sql = "SELECT qoo.id, COUNT(DISTINCT(qa.id)) AS answerscount FROM {qtype_ordering_options} qoo JOIN {question_answers} qa ON qa.question = qoo.questionid WHERE selectcount = :selectcount GROUP BY qoo.id"; $questionoptions = $DB->get_recordset_sql($sql, ['selectcount' => 0]); foreach ($questionoptions as $questionoption) { // Update the value of the 'selectcount' configuration option for the current ordering question and set it // to the total number of answers related to this question. This way, we are making sure that the original // behavior is preserved and all existing items (answers) related to the question will be included in the // subset. $DB->set_field('qtype_ordering_options', 'selectcount', $questionoption->answerscount, ['id' => $questionoption->id]); } $questionoptions->close(); // Currently, a 'qtype_ordering_selectcount' user preference is set (or updated, if it already exists) each time // a new ordering question is created. If there are user preferences that store the removed "0" (all) option, they // need to be updated. In this case, replace it with "2" (minimum number of items required to create a subset). $wheresql = 'name = ? AND ' . $DB->sql_compare_text('value') . ' = ?'; $params = ['qtype_ordering_selectcount', '0']; $DB->set_field_select('user_preferences', 'value', 2, $wheresql, $params); upgrade_plugin_savepoint(true, 2024040401, 'qtype', 'ordering'); } // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\U type/ordering/db/install.xmlnu[
    PK\G##(type/ordering/lang/en/qtype_ordering.phpnu[. /** * Ordering question type language srings * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['absoluteposition'] = 'Absolute position'; $string['addmultipleanswers'] = 'Add {$a} more items'; $string['addsingleanswer'] = 'Add one more item'; $string['allornothing'] = 'All or nothing'; $string['answer'] = 'Item text'; $string['correctitemsnumber'] = 'Correct items: {$a}'; $string['correctorder'] = 'The correct order for these items is as follows:'; $string['defaultanswerformat'] = 'Default answer format'; $string['defaultquestionname'] = 'Drag the following items into the correct order.'; $string['draggableitemno'] = 'Draggable item {no}'; $string['draggableitems'] = 'Draggable items'; $string['duplicatesnotallowed'] = 'Duplication of draggable items is not allowed. The string "{$a->text}" is already used in {$a->item}.'; $string['editingordering'] = 'Editing ordering question'; $string['gradedetails'] = 'Grade details'; $string['gradingtype'] = 'Grading type'; $string['gradingtype_help'] = '**All or nothing**   If all items are in the correct position, then full marks are awarded. Otherwise, the score is zero. **Absolute position**   An item is considered correct if it is in the same position as in the correct answer. The highest possible score for the question is **the same as** the number of items displayed to the student. **Relative to correct position**   An item is considered correct if it is in the same position as in the correct answer. Correct items receive a score equal to the number of items displayed minus one. Incorrect items receive a score equal to the number of items displayed minus one and minus the distance of the item from its correct position. Thus, if ***n*** items are displayed to the student, the number of marks available for each item is ***(n - 1)***, and the highest mark available for the question is ***n x (n - 1)***, which is the same as ***(n² - n)***. **Relative to the next item (excluding last)**   An item is considered correct if it is followed by the same item as it is in the correct answer. The item in the last position is not checked. Thus, the highest possible score for the question is **one less than** the number of items displayed to the student. **Relative to the next item (including last)**   An item is considered correct if it is followed by the same item as it is in the correct answer. This includes the last item which must have no item following it. Thus, the highest possible score for the question is **the same as** the number of items displayed to the student. **Relative to both the previous and next items**   An item is considered correct if both the previous and next items are the same as they are in the correct answer. The first item should have no previous item, and the last item should have no next item. Thus, there are two possible points for each item, and the highest possible score for the question is **twice** the number of items displayed to the student. **Relative to ALL previous and next items**   An item is considered correct if it is preceded by all the same items as it is in the correct answer, and it is followed by all the same items as it is in the correct answer. The order of the previous items does not matter, and nor does the order of the following items. Thus, if ***n*** items are displayed to the student, the number of marks available for each item is ***(n - 1)***, and the highest mark available for the question is ***n x (n - 1)***, which is the same as ***(n² - n)***. **Longest ordered subset**   The grade is the number of items in the longest ordered subset of items. The highest possible grade is the same as the number of items displayed. A subset must have at least two items. Subsets do not need to start at the first item (but they can) and they do not need to be contiguous (but they can be). Where there are multiple subsets of equal length, items in the subset that is found first, when searching from left to right, will be displayed as correct. Other items will be marked as incorrect. **Longest contiguous subset**   The grade is the number of items in the longest contiguous subset of items. The highest possible grade is the same as the number of items displayed. A subset must have at least two items. Subsets do not need to start at the first item (but they can) and they MUST BE CONTIGUOUS. Where there are multiple subsets of equal length, items in the subset that is found first, when searching from left to right, will be displayed as correct. Other items will be marked as incorrect.'; $string['highlightresponse'] = 'Highlight response as correct or incorrect'; $string['horizontal'] = 'Horizontal'; $string['incorrectitemsnumber'] = 'Incorrect items: {$a}'; $string['layouttype'] = 'Layout of items'; $string['layouttype_help'] = 'Choose whether to display the items vertically or horizontally.'; $string['longestcontiguoussubset'] = 'Longest contiguous subset'; $string['longestorderedsubset'] = 'Longest ordered subset'; $string['moved'] = '{$a->item} moved. New position: {$a->position} of {$a->total}.'; $string['moveleft'] = 'Move left'; $string['moveright'] = 'Move right'; $string['noresponsedetails'] = 'Sorry, no details of the response to this question are available.'; $string['noscore'] = 'No score'; $string['notenoughanswers'] = 'Ordering questions must have more than {$a} answers.'; $string['notenoughsubsetitems'] = 'A subset must have at least {$a} items.'; $string['numberingstyle'] = 'Number the choices?'; $string['numberingstyle123'] = '1., 2., 3., ...'; $string['numberingstyleABCD'] = 'A., B., C., ...'; $string['numberingstyleIIII'] = 'I., II., III., ...'; $string['numberingstyle_desc'] = 'The default numbering style.'; $string['numberingstyle_help'] = 'Choose the numbering style for draggable items in this question.'; $string['numberingstyleabc'] = 'a., b., c., ...'; $string['numberingstyleiii'] = 'i., ii., iii., ...'; $string['numberingstylenone'] = 'No numbering'; $string['partialitemsnumber'] = 'Partially correct items: {$a}'; $string['pluginname'] = 'Ordering'; $string['pluginname_help'] = 'Several items are displayed in a jumbled order. The items can be dragged into a meaningful order.'; $string['pluginname_link'] = 'question/type/ordering'; $string['pluginnameadding'] = 'Adding an Ordering question'; $string['pluginnameediting'] = 'Editing an Ordering question'; $string['pluginnamesummary'] = 'Put jumbled items into a meaningful order.'; $string['positionx'] = 'Position {$a}'; $string['privacy:preference:gradingtype'] = 'The grading type.'; $string['privacy:preference:layouttype'] = 'The layout of items.'; $string['privacy:preference:numberingstyle'] = 'The numbering style of the choices.'; $string['privacy:preference:selectcount'] = 'The select count.'; $string['privacy:preference:selecttype'] = 'The item selection type.'; $string['privacy:preference:showgrading'] = 'Whether to show grading details.'; $string['regradeissuenumitemschanged'] = 'The number of draggable items has changed.'; $string['relativeallpreviousandnext'] = 'Relative to ALL the previous and next items'; $string['relativenextexcludelast'] = 'Relative to the next item (excluding last)'; $string['relativenextincludelast'] = 'Relative to the next item (including last)'; $string['relativeonepreviousandnext'] = 'Relative to both the previous and next items'; $string['relativetocorrect'] = 'Relative to correct position'; $string['removeeditor'] = 'Remove HTML editor'; $string['removeitem'] = 'Remove draggable item'; $string['scoredetails'] = 'Here are the scores for each item in this response:'; $string['selectall'] = 'Select all items'; $string['selectcontiguous'] = 'Select a contiguous subset of items'; $string['selectcount'] = 'Size of subset'; $string['selectcount_help'] = 'The number of items that will be displayed when the question is appears in a quiz.'; $string['selectrandom'] = 'Select a random subset of items'; $string['selecttype'] = 'Item selection type'; $string['selecttype_help'] = 'Choose whether to display all the items or a subset of the items.'; $string['showgrading'] = 'Grading details'; $string['showgrading_help'] = 'Choose whether to show or hide details of the score calculation when a student reviews a response to this Ordering question.'; $string['vertical'] = 'Vertical'; PK\8? Dtype/ordering/backup/moodle2/restore_qtype_ordering_plugin.class.phpnu[. /** * Ordering question type restore handler * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Restore plugin class that provides the necessary information needed to restore one ordering qtype plugin * * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_ordering_plugin extends restore_qtype_plugin { /** * Returns the paths to be handled by the plugin at question level * * @return restore_path_element[] */ protected function define_question_plugin_structure(): array { $paths = []; // This qtype uses question_answers, add them. $this->add_question_question_answers($paths); // Add own qtype stuff. $elename = 'ordering'; $elepath = $this->get_pathfor('/ordering'); // We used get_recommended_name() so this works. $paths[] = new restore_path_element($elename, $elepath); return $paths; // And we return the interesting paths. } /** * Process the qtype/ordering element * * @param array $data */ public function process_ordering(array $data): void { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped // "question" is the XML tag name, not the DB field name. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); // If the question has been created by restore, // we need to create a "qtype_ordering_options" record // and create a mapping from the $oldid to the $newid. if ($this->get_mappingid('question_created', $oldquestionid)) { $data->questionid = $newquestionid; if (!isset($data->shownumcorrect)) { $data->shownumcorrect = 1; } $newid = $DB->insert_record('qtype_ordering_options', $data); $this->set_mapping('qtype_ordering_options', $oldid, $newid); } } /** * Given one question_states record, return the answer * recoded pointing to all the restored stuff for ordering questions. * If not empty, answer is one question_answers->id. * * @param object $state * @return string|false * @codeCoverageIgnore Restoring from 2.0 is risky business and hopefully not needed. */ public function recode_legacy_state_answer($state): string|false { $answer = $state->answer; $result = ''; if ($answer) { $result = $this->get_mappingid('question_answer', $answer); } return $result; } } PK\[A Ctype/ordering/backup/moodle2/backup_qtype_ordering_plugin.class.phpnu[. /** * Ordering question type backup handler * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Provides the information to backup ordering questions * * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_ordering_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element * * @return backup_plugin_element */ protected function define_question_plugin_structure(): backup_plugin_element { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(null, '../../qtype', 'ordering'); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // This qtype uses standard question_answers, add them here // to the tree before any other information that will use them. $this->add_question_question_answers($pluginwrapper); // Now create the qtype own structures. $fields = ['layouttype', 'selecttype', 'selectcount', 'gradingtype', 'showgrading', 'numberingstyle', 'correctfeedback', 'correctfeedbackformat', 'incorrectfeedback', 'incorrectfeedbackformat', 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', 'shownumcorrect', ]; $ordering = new backup_nested_element('ordering', ['id'], $fields); // Now the own qtype tree. $pluginwrapper->add_child($ordering); // Set source to populate the data. $params = ['questionid' => backup::VAR_PARENTID]; $ordering->set_source_table('qtype_ordering_options', $params); // Don't need to annotate ids nor files. return $plugin; } } PK\.$type/ordering/backup/moodle1/lib.phpnu[. /** * Ordering question type conversion handler * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Ordering question type conversion handler class * * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore For old Moodle sites in which upgrades are always risky. */ class moodle1_qtype_ordering_handler extends moodle1_qtype_handler { /** * Returns the list of paths within one that this qtype needs to have included * in the grouped question structure * * @return string[] */ public function get_question_subpaths(): array { return [ 'ANSWERS/ANSWER', 'ORDERING', ]; } /** * Gives the qtype handler a chance to write converted data into questions.xml * * @param array $data grouped question data * @param array $raw grouped raw QUESTION data */ public function process_question(array $data, array $raw): void { // Convert and write the answers first. if (isset($data['answers'])) { $this->write_answers($data['answers'], $this->pluginname); } // Convert and write the ordering extra fields. foreach ($data['ordering'] as $ordering) { $ordering['id'] = $this->converter->get_nextid(); $this->write_xml('ordering', $ordering, ['/ordering/id']); } } } PK\_^type/ordering/pix/icon.gifnu[GIF89a1᫫ttt:::666111((('''"""!,iH ,@0hP¿4(P P "p Adp@'*8@C%O=O~ I e>pA@Ǜӧˡ]j%;PK\d=type/ordering/pix/icon.svgnu[PK\type/ordering/lib.phpnu[. /** * Ordering question type general functions * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Checks file access for ordering questions. * * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @codeCoverageIgnore * This code can not be tested currently as pluginfile() is not testable. */ function qtype_ordering_pluginfile(stdClass $course, stdClass $cm, stdClass $context, string $filearea, array $args, bool $forcedownload, array $options = []) { global $CFG; require_once($CFG->dirroot . '/lib/questionlib.php'); question_pluginfile($course, $context, 'qtype_ordering', $filearea, $args, $forcedownload); } PK\q椁type/ordering/version.phpnu[. /** * Version information for the ordering question type * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordonbateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // Prevent direct access to this script. defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_ordering'; $plugin->requires = 2025040800; $plugin->version = 2025041400; // The current plugin version (Date: YYYYMMDDXX). PK\K_type/ordering/question.phpnu[. /** * Represents an ordering question. * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ordering_question extends question_graded_automatically { /** Select all answers */ const SELECT_ALL = 0; /** Select random set of answers */ const SELECT_RANDOM = 1; /** Select contiguous subset of answers */ const SELECT_CONTIGUOUS = 2; /** Show answers in vertical list */ const LAYOUT_VERTICAL = 0; /** Show answers in one horizontal line */ const LAYOUT_HORIZONTAL = 1; /** The minimum number of items to create a subset */ const MIN_SUBSET_ITEMS = 2; /** Default value for numberingstyle */ const NUMBERING_STYLE_DEFAULT = 'none'; /** @var int Zero grade on any error */ const GRADING_ALL_OR_NOTHING = -1; /** @var int Counts items, placed into right absolute place */ const GRADING_ABSOLUTE_POSITION = 0; /** @var int Every sequential pair in right order is graded (last pair is excluded) */ const GRADING_RELATIVE_NEXT_EXCLUDE_LAST = 1; /** @var int Every sequential pair in right order is graded (last pair is included) */ const GRADING_RELATIVE_NEXT_INCLUDE_LAST = 2; /** @var int Single answers that are placed before and after each answer is graded if in right order*/ const GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT = 3; /** @var int All answers that are placed before and after each answer is graded if in right order*/ const GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT = 4; /** @var int Only longest ordered subset is graded */ const GRADING_LONGEST_ORDERED_SUBSET = 5; /** @var int Only longest ordered and contiguous subset is graded */ const GRADING_LONGEST_CONTIGUOUS_SUBSET = 6; /** @var int Items are graded relative to their position in the correct answer */ const GRADING_RELATIVE_TO_CORRECT = 7; /** @var int {@see LAYOUT_VERTICAL} or {@see LAYOUT_HORIZONTAL}. */ public $layouttype; /** @var int {@see SELECT_ALL}, {@see SELECT_RANDOM} or {@see SELECT_CONTIGUOUS}. */ public $selecttype; /** @var int if {@see $selecttype} is not SELECT_ALL, then the number to select. */ public $selectcount; /** @var int Which grading strategy to use. One of the GRADING_... constants. */ public $gradingtype; /** @var bool Should details of the grading calculation be shown to students. */ public $showgrading; /** @var string How to number the items. A key from the array returned by {@see get_numbering_styles()}. */ public $numberingstyle; // Fields from "qtype_ordering_options" table. /** @var string */ public $correctfeedback; /** @var int */ public $correctfeedbackformat; /** @var string */ public $incorrectfeedback; /** @var int */ public $incorrectfeedbackformat; /** @var string */ public $partiallycorrectfeedback; /** @var int */ public $partiallycorrectfeedbackformat; /** @var array Records from "question_answers" table */ public $answers; /** @var array of answerids in correct order */ public $correctresponse; /** @var array contatining current order of answerids */ public $currentresponse; /** @var array of scored for every item */ protected $itemscores = []; public function start_attempt(question_attempt_step $step, $variant) { $countanswers = count($this->answers); // Sanitize "selecttype". $selecttype = $this->selecttype; $selecttype = max(0, $selecttype); $selecttype = min(2, $selecttype); // Sanitize "selectcount". $selectcount = $this->selectcount; $selectcount = max(self::MIN_SUBSET_ITEMS, $selectcount); $selectcount = min($countanswers, $selectcount); // Ensure consistency between "selecttype" and "selectcount". switch (true) { case ($selecttype == self::SELECT_ALL): $selectcount = $countanswers; break; case ($selectcount == $countanswers): $selecttype = self::SELECT_ALL; break; } // Extract answer ids. switch ($selecttype) { case self::SELECT_ALL: $answerids = array_keys($this->answers); break; case self::SELECT_RANDOM: $answerids = array_rand($this->answers, $selectcount); break; case self::SELECT_CONTIGUOUS: $answerids = array_keys($this->answers); $offset = mt_rand(0, $countanswers - $selectcount); $answerids = array_slice($answerids, $offset, $selectcount); break; } $this->correctresponse = $answerids; $step->set_qt_var('_correctresponse', implode(',', $this->correctresponse)); shuffle($answerids); $this->currentresponse = $answerids; $step->set_qt_var('_currentresponse', implode(',', $this->currentresponse)); } public function apply_attempt_state(question_attempt_step $step) { $this->currentresponse = array_filter(explode(',', $step->get_qt_var('_currentresponse'))); $this->correctresponse = array_filter(explode(',', $step->get_qt_var('_correctresponse'))); } public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string { $basemessage = parent::validate_can_regrade_with_other_version($otherversion); if ($basemessage) { return $basemessage; } if (count($this->answers) != count($otherversion->answers)) { return get_string('regradeissuenumitemschanged', 'qtype_ordering'); } return null; } public function update_attempt_state_data_for_new_version( question_attempt_step $oldstep, question_definition $oldquestion) { parent::update_attempt_state_data_for_new_version($oldstep, $oldquestion); $mapping = array_combine(array_keys($oldquestion->answers), array_keys($this->answers)); $oldorder = explode(',', $oldstep->get_qt_var('_currentresponse')); $neworder = []; foreach ($oldorder as $oldid) { $neworder[] = $mapping[$oldid] ?? $oldid; } $oldcorrect = explode(',', $oldstep->get_qt_var('_correctresponse')); $newcorrect = []; foreach ($oldcorrect as $oldid) { $newcorrect[] = $mapping[$oldid] ?? $oldid; } return [ '_currentresponse' => implode(',', $neworder), '_correctresponse' => implode(',', $newcorrect), ]; } public function get_expected_data() { $name = $this->get_response_fieldname(); return [$name => PARAM_TEXT]; } public function get_correct_response() { $correctresponse = $this->correctresponse; foreach ($correctresponse as $position => $answerid) { $answer = $this->answers[$answerid]; $correctresponse[$position] = $answer->md5key; } $name = $this->get_response_fieldname(); return [$name => implode(',', $correctresponse)]; } public function summarise_response(array $response) { $name = $this->get_response_fieldname(); $items = []; if (array_key_exists($name, $response)) { $items = explode(',', $response[$name]); } $answerids = []; foreach ($this->answers as $answer) { $answerids[$answer->md5key] = $answer->id; } foreach ($items as $i => $item) { if (array_key_exists($item, $answerids)) { $item = $this->answers[$answerids[$item]]; $item = $this->html_to_text($item->answer, $item->answerformat); $item = shorten_text($item, 10, true); // Force truncate at 10 chars. $items[$i] = $item; } else { $items[$i] = ''; // Shouldn't happen! } } return implode('; ', array_filter($items)); } public function classify_response(array $response) { $this->update_current_response($response); $fraction = 1 / count($this->correctresponse); $classifiedresponse = []; foreach ($this->correctresponse as $position => $answerid) { if (in_array($answerid, $this->currentresponse)) { $currentposition = array_search($answerid, $this->currentresponse); } $answer = $this->answers[$answerid]; $subqid = question_utils::to_plain_text($answer->answer, $answer->answerformat); // Truncate responses longer than 100 bytes because they cannot be stored in the database. // CAUTION: This will mess up answers which are not unique within the first 100 chars! $maxbytes = 100; if (strlen($subqid) > $maxbytes) { // If the truncation point is in the middle of a multi-byte unicode char, // we remove the incomplete part with a preg_match() that is unicode aware. $subqid = substr($subqid, 0, $maxbytes); if (preg_match('/^(.|\n)*/u', '', $subqid, $match)) { $subqid = $match[0]; } } $classifiedresponse[$subqid] = new question_classified_response( $currentposition + 1, get_string('positionx', 'qtype_ordering', $currentposition + 1), ($currentposition == $position) * $fraction ); } return $classifiedresponse; } public function is_complete_response(array $response) { return true; } public function is_gradable_response(array $response) { return true; } public function get_validation_error(array $response) { return ''; } public function is_same_response(array $old, array $new) { $name = $this->get_response_fieldname(); return (isset($old[$name]) && isset($new[$name]) && $old[$name] == $new[$name]); } public function grade_response(array $response) { $this->update_current_response($response); $countcorrect = 0; $countanswers = 0; $gradingtype = $this->gradingtype; switch ($gradingtype) { case self::GRADING_ALL_OR_NOTHING: case self::GRADING_ABSOLUTE_POSITION: $correctresponse = $this->correctresponse; $currentresponse = $this->currentresponse; foreach ($correctresponse as $position => $answerid) { if (array_key_exists($position, $currentresponse)) { if ($currentresponse[$position] == $answerid) { $countcorrect++; } } $countanswers++; } if ($gradingtype == self::GRADING_ALL_OR_NOTHING && $countcorrect < $countanswers) { $countcorrect = 0; } break; case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST: case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST: $lastitem = ($gradingtype == self::GRADING_RELATIVE_NEXT_INCLUDE_LAST); $currentresponse = $this->get_next_answerids($this->currentresponse, $lastitem); $correctresponse = $this->get_next_answerids($this->correctresponse, $lastitem); foreach ($correctresponse as $thisanswerid => $nextanswerid) { if (array_key_exists($thisanswerid, $currentresponse)) { if ($currentresponse[$thisanswerid] == $nextanswerid) { $countcorrect++; } } $countanswers++; } break; case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT: case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT: $all = ($gradingtype == self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT); $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse, $all); $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse, $all); foreach ($correctresponse as $thisanswerid => $answerids) { if (array_key_exists($thisanswerid, $currentresponse)) { $prev = $currentresponse[$thisanswerid]->prev; $prev = array_intersect($prev, $answerids->prev); $countcorrect += count($prev); $next = $currentresponse[$thisanswerid]->next; $next = array_intersect($next, $answerids->next); $countcorrect += count($next); } $countanswers += count($answerids->prev); $countanswers += count($answerids->next); } break; case self::GRADING_LONGEST_ORDERED_SUBSET: case self::GRADING_LONGEST_CONTIGUOUS_SUBSET: $contiguous = ($gradingtype == self::GRADING_LONGEST_CONTIGUOUS_SUBSET); $subset = $this->get_ordered_subset($contiguous); $countcorrect = count($subset); $countanswers = count($this->currentresponse); break; case self::GRADING_RELATIVE_TO_CORRECT: $correctresponse = $this->correctresponse; $currentresponse = $this->currentresponse; $count = (count($correctresponse) - 1); foreach ($correctresponse as $position => $answerid) { if (in_array($answerid, $currentresponse)) { $currentposition = array_search($answerid, $currentresponse); $currentscore = ($count - abs($position - $currentposition)); if ($currentscore > 0) { $countcorrect += $currentscore; } } $countanswers += $count; } break; } if ($countanswers == 0) { $fraction = 0; } else { $fraction = ($countcorrect / $countanswers); } return [ $fraction, question_state::graded_state_for_fraction($fraction), ]; } public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($component == 'question') { if ($filearea == 'answer') { $answerid = reset($args); // Value of "itemid" is answer id. return array_key_exists($answerid, $this->answers); } if (in_array($filearea, ['correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'])) { return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args); } if ($filearea == 'hint') { return $this->check_hint_file_access($qa, $options, $args); } } return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } protected function check_combined_feedback_file_access($qa, $options, $filearea, $args = null) { $state = $qa->get_state(); if (! $state->is_finished()) { $response = $qa->get_last_qt_data(); if (! $this->is_gradable_response($response)) { return false; } list($fraction, $state) = $this->grade_response($response); } if ($state->get_feedback_class().'feedback' == $filearea) { return ($this->id == reset($args)); } else { return false; } } // Custom methods. /** * Returns response mform field name * * @return string */ public function get_response_fieldname(): string { return 'response_' . $this->id; } /** * Unpack the students' response into an array which updates the question currentresponse. * * @param array $response Form data */ public function update_current_response(array $response) { $name = $this->get_response_fieldname(); if (array_key_exists($name, $response)) { $ids = explode(',', $response[$name]); foreach ($ids as $i => $id) { foreach ($this->answers as $answer) { if ($id == $answer->md5key) { $ids[$i] = $answer->id; break; } } } // Note: TH mentions that this is a bit of a hack. $this->currentresponse = $ids; } } /** * Returns layoutclass * * @return string */ public function get_ordering_layoutclass(): string { switch ($this->layouttype) { case self::LAYOUT_VERTICAL: return 'vertical'; case self::LAYOUT_HORIZONTAL: return 'horizontal'; default: return ''; // Shouldn't happen! } } /** * Returns array of next answers * * @param array $answerids array of answers id * @param bool $lastitem Include last item? * @return array of id of next answer */ public function get_next_answerids(array $answerids, bool $lastitem = false): array { $nextanswerids = []; $imax = count($answerids); $imax--; if ($lastitem) { $nextanswerid = 0; } else { $nextanswerid = $answerids[$imax]; $imax--; } for ($i = $imax; $i >= 0; $i--) { $thisanswerid = $answerids[$i]; $nextanswerids[$thisanswerid] = $nextanswerid; $nextanswerid = $thisanswerid; } return $nextanswerids; } /** * Returns prev and next answers array * * @param array $answerids array of answers id * @param bool $all include all answers * @return array of array('prev' => previd, 'next' => nextid) */ public function get_previous_and_next_answerids(array $answerids, bool $all = false): array { $prevnextanswerids = []; $next = $answerids; $prev = []; while ($answerid = array_shift($next)) { if ($all) { $prevnextanswerids[$answerid] = (object) [ 'prev' => $prev, 'next' => $next, ]; } else { $prevnextanswerids[$answerid] = (object) [ 'prev' => [empty($prev) ? 0 : $prev[0]], 'next' => [empty($next) ? 0 : $next[0]], ]; } array_unshift($prev, $answerid); } return $prevnextanswerids; } /** * Search for best ordered subset * * @param bool $contiguous A flag indicating whether only contiguous values should be considered for inclusion in the subset. * @return array */ public function get_ordered_subset(bool $contiguous): array { $positions = $this->get_ordered_positions($this->correctresponse, $this->currentresponse); $subsets = $this->get_ordered_subsets($positions, $contiguous); // The best subset (longest and leftmost). $bestsubset = []; // The length of the best subset // initializing this to 1 means // we ignore single item subsets. $bestcount = 1; foreach ($subsets as $subset) { $count = count($subset); if ($count > $bestcount) { $bestcount = $count; $bestsubset = $subset; } } return $bestsubset; } /** * Get array of right answer positions for current response * * @param array $correctresponse * @param array $currentresponse * @return array */ public function get_ordered_positions(array $correctresponse, array $currentresponse): array { $positions = []; foreach ($currentresponse as $answerid) { $positions[] = array_search($answerid, $correctresponse); } return $positions; } /** * Get all ordered subsets in the positions array * * @param array $positions maps an item's current position to its correct position * @param bool $contiguous TRUE if searching only for contiguous subsets; otherwise FALSE * @return array of ordered subsets from within the $positions array */ public function get_ordered_subsets(array $positions, bool $contiguous): array { // Var $subsets is the collection of all subsets within $positions. $subsets = []; // Loop through the values at each position. foreach ($positions as $p => $value) { // Is $value a "new" value that cannot be added to any $subsets found so far? $isnew = true; // An array of new and saved subsets to be added to $subsets. $new = []; // Append the current value to any subsets to which it belongs // i.e. any subset whose end value is less than the current value. foreach ($subsets as $s => $subset) { // Get value at end of $subset. $end = $positions[end($subset)]; switch (true) { case ($value == ($end + 1)): // For a contiguous value, we simply append $p to the subset. $isnew = false; $subsets[$s][] = $p; break; case $contiguous: // If the $contiguous flag is set, we ignore non-contiguous values. break; case ($value > $end): // For a non-contiguous value, we save the subset so far, // because a value between $end and $value may be found later, // and then append $p to the subset. $isnew = false; $new[] = $subset; $subsets[$s][] = $p; break; } } // If this is a "new" value, add it as a new subset. if ($isnew) { $new[] = [$p]; } // Append any "new" subsets that were found during this iteration. if (count($new)) { $subsets = array_merge($subsets, $new); } } return $subsets; } /** * Helper function for get_select_types, get_layout_types, get_grading_types * * @param array $types * @param int $type * @return array|string array if $type is not specified and single string if $type is specified * @throws coding_exception * @codeCoverageIgnore */ public static function get_types(array $types, $type): array|string { if ($type === null) { return $types; // Return all $types. } if (array_key_exists($type, $types)) { return $types[$type]; // One $type. } throw new coding_exception('Invalid type: ' . $type); } /** * Returns available values and descriptions for field "selecttype" * * @param int|null $type * @return array|string array if $type is not specified and single string if $type is specified * @codeCoverageIgnore */ public static function get_select_types(?int $type = null): array|string { $plugin = 'qtype_ordering'; $types = [ self::SELECT_ALL => get_string('selectall', $plugin), self::SELECT_RANDOM => get_string('selectrandom', $plugin), self::SELECT_CONTIGUOUS => get_string('selectcontiguous', $plugin), ]; return self::get_types($types, $type); } /** * Returns available values and descriptions for field "layouttype" * * @param int|null $type * @return array|string array if $type is not specified and single string if $type is specified * @codeCoverageIgnore */ public static function get_layout_types(?int $type = null): array|string { $plugin = 'qtype_ordering'; $types = [ self::LAYOUT_VERTICAL => get_string('vertical', $plugin), self::LAYOUT_HORIZONTAL => get_string('horizontal', $plugin), ]; return self::get_types($types, $type); } /** * Returns available values and descriptions for field "gradingtype" * * @param int|null $type * @return array|string array if $type is not specified and single string if $type is specified * @codeCoverageIgnore */ public static function get_grading_types(?int $type = null): array|string { $plugin = 'qtype_ordering'; $types = [ self::GRADING_ALL_OR_NOTHING => get_string('allornothing', $plugin), self::GRADING_ABSOLUTE_POSITION => get_string('absoluteposition', $plugin), self::GRADING_RELATIVE_TO_CORRECT => get_string('relativetocorrect', $plugin), self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST => get_string('relativenextexcludelast', $plugin), self::GRADING_RELATIVE_NEXT_INCLUDE_LAST => get_string('relativenextincludelast', $plugin), self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT => get_string('relativeonepreviousandnext', $plugin), self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT => get_string('relativeallpreviousandnext', $plugin), self::GRADING_LONGEST_ORDERED_SUBSET => get_string('longestorderedsubset', $plugin), self::GRADING_LONGEST_CONTIGUOUS_SUBSET => get_string('longestcontiguoussubset', $plugin), ]; return self::get_types($types, $type); } /** * Get the numbering styles supported. * * For each style, there should be a corresponding lang string 'numberingstylexxx' in the qtype_ordering language file, * a case in the switch statement in number_in_style, and it should be listed in the definition of this column in install.xml. * * @param string|null $style The specific numbering style to retrieve. * @return array|string Numbering style(s). * The keys are style identifiers, and the values are the corresponding language strings. * @codeCoverageIgnore */ public static function get_numbering_styles(?string $style = null): array|string { $plugin = 'qtype_ordering'; $styles = [ 'none' => get_string('numberingstylenone', $plugin), 'abc' => get_string('numberingstyleabc', $plugin), 'ABCD' => get_string('numberingstyleABCD', $plugin), '123' => get_string('numberingstyle123', $plugin), 'iii' => get_string('numberingstyleiii', $plugin), 'IIII' => get_string('numberingstyleIIII', $plugin), ]; return self::get_types($styles, $style); } /** * Return the number of subparts of this response that are correct|partial|incorrect. * * @param array $response A response. * @return array Array of three elements: the number of correct subparts, * the number of partial correct subparts and the number of incorrect subparts. */ public function get_num_parts_right(array $response): array { $this->update_current_response($response); $gradingtype = $this->gradingtype; $numright = 0; $numpartial = 0; $numincorrect = 0; list($correctresponse, $currentresponse) = $this->get_response_depend_on_grading_type($gradingtype); foreach ($this->currentresponse as $position => $answerid) { [$fraction, $score, $maxscore] = $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse); if (is_null($fraction)) { continue; } if ($fraction > 0.999999) { $numright++; } else if ($fraction < 0.000001) { $numincorrect++; } else { $numpartial++; } } return [$numright, $numpartial, $numincorrect]; } /** * Returns the grade for one item, base on the fraction scale. * * @param int $position The position of the current response. * @param int $answerid The answerid of the current response. * @param array $correctresponse The correct response list base on grading type. * @param array $currentresponse The current response list base on grading type. * @return array. */ protected function get_fraction_maxscore_score_of_item( int $position, int $answerid, array $correctresponse, array $currentresponse ): array { $gradingtype = $this->gradingtype; $score = 0; $maxscore = null; switch ($gradingtype) { case self::GRADING_ALL_OR_NOTHING: case self::GRADING_ABSOLUTE_POSITION: if (isset($correctresponse[$position])) { if ($correctresponse[$position] == $answerid) { $score = 1; } $maxscore = 1; } break; case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST: case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST: if (isset($correctresponse[$answerid])) { if (isset($currentresponse[$answerid]) && $currentresponse[$answerid] == $correctresponse[$answerid]) { $score = 1; } $maxscore = 1; } break; case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT: case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT: if (isset($correctresponse[$answerid])) { $maxscore = 0; $prev = $correctresponse[$answerid]->prev; $maxscore += count($prev); $prev = array_intersect($prev, $currentresponse[$answerid]->prev); $score += count($prev); $next = $correctresponse[$answerid]->next; $maxscore += count($next); $next = array_intersect($next, $currentresponse[$answerid]->next); $score += count($next); } break; case self::GRADING_LONGEST_ORDERED_SUBSET: case self::GRADING_LONGEST_CONTIGUOUS_SUBSET: if (isset($correctresponse[$position])) { if (isset($currentresponse[$position])) { $score = $currentresponse[$position]; } $maxscore = 1; } break; case self::GRADING_RELATIVE_TO_CORRECT: if (isset($correctresponse[$position])) { $maxscore = (count($correctresponse) - 1); $answerid = $currentresponse[$position]; $correctposition = array_search($answerid, $correctresponse); $score = ($maxscore - abs($correctposition - $position)); if ($score < 0) { $score = 0; } } break; } $fraction = $maxscore ? $score / $maxscore : $maxscore; return [$fraction, $score, $maxscore]; } /** * Get correcresponse and currentinfo depending on grading type. * * @param string $gradingtype The kind of grading. * @return array Correctresponse and currentresponsescore in one array. */ protected function get_response_depend_on_grading_type(string $gradingtype): array { $correctresponse = []; $currentresponse = []; switch ($gradingtype) { case self::GRADING_ALL_OR_NOTHING: case self::GRADING_ABSOLUTE_POSITION: case self::GRADING_RELATIVE_TO_CORRECT: $correctresponse = $this->correctresponse; $currentresponse = $this->currentresponse; break; case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST: case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST: $lastitem = ($gradingtype == self::GRADING_RELATIVE_NEXT_INCLUDE_LAST); $correctresponse = $this->get_next_answerids($this->correctresponse, $lastitem); $currentresponse = $this->get_next_answerids($this->currentresponse, $lastitem); break; case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT: case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT: $all = ($gradingtype == self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT); $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse, $all); $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse, $all); break; case self::GRADING_LONGEST_ORDERED_SUBSET: case self::GRADING_LONGEST_CONTIGUOUS_SUBSET: $correctresponse = $this->correctresponse; $currentresponse = $this->currentresponse; $contiguous = ($gradingtype == self::GRADING_LONGEST_CONTIGUOUS_SUBSET); $subset = $this->get_ordered_subset($contiguous); foreach ($currentresponse as $position => $answerid) { if (array_search($position, $subset) === false) { $currentresponse[$position] = 0; } else { $currentresponse[$position] = 1; } } break; } return [$correctresponse, $currentresponse]; } /** * Returns score for one item depending on correctness and question settings. * * @param question_definition $question question definition object * @param int $position The position of the current response. * @param int $answerid The answerid of the current response. * @return array (score, maxscore, fraction, percent, class) */ public function get_ordering_item_score(question_definition $question, int $position, int $answerid): array { if (!isset($this->itemscores[$position])) { [$correctresponse, $currentresponse] = $this->get_response_depend_on_grading_type($this->gradingtype); $percent = 0; // 100 * $fraction. [$fraction, $score, $maxscore] = $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse); if ($maxscore === null) { // An unscored item is either an illegal item // or last item of RELATIVE_NEXT_EXCLUDE_LAST // or an item in an incorrect ALL_OR_NOTHING // or an item from an unrecognized grading type. $class = 'unscored'; } else { if ($maxscore > 0) { $percent = round(100 * $fraction, 0); } $class = match (true) { $fraction > 0.999999 => 'correct', $fraction < 0.000001 => 'incorrect', $fraction >= 0.66 => 'partial66', $fraction >= 0.33 => 'partial33', default => 'partial00', }; } $itemscores = [ 'score' => $score, 'maxscore' => $maxscore, 'fraction' => $fraction, 'percent' => $percent, 'class' => $class, ]; $this->itemscores[$position] = $itemscores; } return $this->itemscores[$position]; } } PK\M --*type/ordering/classes/privacy/provider.phpnu[. namespace qtype_ordering\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\writer; /** * Privacy Subsystem for qtype_numerical implementing null_provider. * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @author rdebleu@eWallah.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns metadata about this question. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this question. */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qtype_ordering_layouttype', 'privacy:preference:layouttype'); $collection->add_user_preference('qtype_ordering_selecttype', 'privacy:preference:selecttype'); $collection->add_user_preference('qtype_ordering_selectcount', 'privacy:preference:selectcount'); $collection->add_user_preference('qtype_ordering_gradingtype', 'privacy:preference:gradingtype'); $collection->add_user_preference('qtype_ordering_showgrading', 'privacy:preference:showgrading'); $collection->add_user_preference('qtype_ordering_numberingstyle', 'privacy:preference:numberingstyle'); return $collection; } /** * Export all user preferences for the question. * * @param int $userid The userid of the user whose data is to be exported. * @return void */ public static function export_user_preferences(int $userid): void { $preference = get_user_preferences('qtype_ordering_layouttype', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'layouttype', $preference, get_string('privacy:preference:layouttype', 'qtype_ordering') ); } $preference = get_user_preferences('qtype_ordering_selecttype', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'selecttype', $preference, get_string('privacy:preference:selecttype', 'qtype_ordering') ); } $preference = get_user_preferences('qtype_ordering_selectcount', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'selectcount', $preference, get_string('privacy:preference:selectcount', 'qtype_ordering') ); } $preference = get_user_preferences('qtype_ordering_gradingtype', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'gradingtype', $preference, get_string('privacy:preference:gradingtype', 'qtype_ordering') ); } $preference = get_user_preferences('qtype_ordering_showgrading', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'showgrading', $preference, get_string('privacy:preference:showgrading', 'qtype_ordering') ); } $preference = get_user_preferences('qtype_ordering_numberingstyle', null, $userid); if (null !== $preference) { writer::export_user_preference( 'qtype_ordering', 'numberingstyle', $preference, get_string('privacy:preference:numberingstyle', 'qtype_ordering') ); } } } PK\Pr. . 1type/ordering/classes/output/correct_response.phpnu[. namespace qtype_ordering\output; /** * Create an array for the correct response based on the question and current step state. * * @package qtype_ordering * @copyright 2023 Mihail Geshoski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class correct_response extends renderable_base { public function export_for_template(\renderer_base $output): array { $data = []; $question = $this->qa->get_question(); $correctresponse = $question->correctresponse; $data['hascorrectresponse'] = !empty($correctresponse); // Early return if a correct response does not exist. if (!$data['hascorrectresponse']) { return $data; } $step = $this->qa->get_last_step(); // The correct response should be displayed only for partially correct or incorrect answers. $data['showcorrect'] = $step->get_state() == 'gradedpartial' || $step->get_state() == 'gradedwrong'; // Early return if the correct response should not be displayed. if (!$data['showcorrect']) { return $data; } $data['orderinglayoutclass'] = $question->get_ordering_layoutclass(); $data['correctanswers'] = []; foreach ($correctresponse as $answerid) { $answer = $question->answers[$answerid]; $answertext = $question->format_text($answer->answer, $answer->answerformat, $this->qa, 'question', 'answer', $answerid); $data['correctanswers'][] = [ 'answertext' => $answertext, ]; } return $data; } } PK\ fqq0type/ordering/classes/output/renderable_base.phpnu[. namespace qtype_ordering\output; use templatable; use renderable; use question_attempt; /** * The base class for the renderables that are used to output the components of the ordering question. * * @package qtype_ordering * @copyright 2023 Mihail Geshoski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class renderable_base implements templatable, renderable { /** * The class constructor. * * @param question_attempt $qa The question attempt object. */ public function __construct( /** @var question_attempt The question attempt object. */ protected question_attempt $qa, ) { } } PK\J;9type/ordering/classes/output/formulation_and_controls.phpnu[. namespace qtype_ordering\output; use question_attempt; use question_display_options; /** * Create the question formulation, controls ready for output. * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class formulation_and_controls extends renderable_base { /** * Construct the rendarable as we also need to pass the question options. * * @param question_attempt $qa The question attempt object. * @param question_display_options $options The question options. */ public function __construct( question_attempt $qa, /** @var question_display_options The question display options. */ protected question_display_options $options ) { parent::__construct($qa); } public function export_for_template(\renderer_base $output): array { global $PAGE; $data = []; $question = $this->qa->get_question(); $response = $this->qa->get_last_qt_data(); $question->update_current_response($response); $currentresponse = $question->currentresponse; $correctresponse = $question->correctresponse; // If we are running behat, force the question into a consistently known state for the sake of avoiding DnD funkyness. if (defined('BEHAT_SITE_RUNNING')) { $currentresponse = array_reverse($correctresponse); } // Generate fieldnames and ids. $responsefieldname = $question->get_response_fieldname(); $responsename = $this->qa->get_qt_field_name($responsefieldname); $data['questiontext'] = $question->format_questiontext($this->qa); $data['ablockid'] = 'id_ablock_' . $question->id; $data['sortableid'] = 'id_sortable_' . $question->id; $data['responsename'] = $responsename; $data['responseid'] = 'id_' . preg_replace('/[^a-zA-Z0-9]+/', '_', $responsename); // Set CSS classes for sortable list. if ($class = $question->get_ordering_layoutclass()) { $data['layoutclass'] = $class; } if ($numberingstyle = $question->numberingstyle) { $data['numberingstyle'] = $numberingstyle; } $data['horizontallayout'] = $question->layouttype == \qtype_ordering_question::LAYOUT_HORIZONTAL; // In the multi-tries, the highlight response base on the hint highlight option. if ( (isset($this->options->highlightresponse) && $this->options->highlightresponse) || !$this->qa->get_state()->is_active() ) { $data['active'] = false; } else if ($this->qa->get_state()->is_active()) { $data['active'] = true; } $data['readonly'] = $this->options->readonly; if (count($currentresponse)) { // Initialize the cache for the answers' md5keys // this represents the initial position of the items. $md5keys = []; // Generate ordering items. foreach ($currentresponse as $position => $answerid) { if (!array_key_exists($answerid, $question->answers) || !array_key_exists($position, $correctresponse)) { // @codeCoverageIgnoreStart continue; // This shouldn't happen. // @codeCoverageIgnoreEnd } // Format the answer text. $answer = $question->answers[$answerid]; $answertext = $question->format_text($answer->answer, $answer->answerformat, $this->qa, 'question', 'answer', $answerid); // The original "id" revealed the correct order of the answers // because $answer->fraction holds the correct order number. // Therefore, we use the $answer's md5key for the "id". $answerdata = [ 'answertext' => $answertext, 'id' => $answer->md5key, ]; if ($this->options->correctness === question_display_options::VISIBLE || !empty($this->options->highlightresponse)) { $score = $question->get_ordering_item_score($question, $position, $answerid); if (isset($score['maxscore'])) { $renderer = $PAGE->get_renderer('qtype_ordering'); $answerdata['feedbackimage'] = $renderer->feedback_image($score['fraction']); } $answerdata['scoreclass'] = $score['class']; } $data['answers'][] = $answerdata; // Cache this answer key. $md5keys[] = $answer->md5key; } } $data['value'] = implode(',', $md5keys); return $data; } } PK\h2type/ordering/classes/output/num_parts_correct.phpnu[. namespace qtype_ordering\output; /** * Generate the number of correct, partial, and incorrect parts of the question ready for output * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class num_parts_correct extends renderable_base { public function export_for_template(\renderer_base $output): array { list($numright, $numpartial, $numincorrect) = $this->qa->get_question()->get_num_parts_right( $this->qa->get_last_qt_data()); return [ 'numcorrect' => $numright, 'numpartial' => $numpartial, 'numincorrect' => $numincorrect, ]; } } PK\f*%%?type/ordering/classes/output/specific_grade_detail_feedback.phpnu[. namespace qtype_ordering\output; use qtype_ordering_question; /** * Generate the grade feedback when the grading should be shown. * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class specific_grade_detail_feedback extends renderable_base { public function export_for_template(\renderer_base $output): array { $data = []; $question = $this->qa->get_question(); // Decide if we should show grade explanation for "partial" or "wrong" states. // This should detect "^graded(partial|wrong)$" and possibly others. $showpartialwrong = false; if ($step = $this->qa->get_last_step()) { $showpartialwrong = preg_match('/(partial|wrong)$/', $step->get_state()); } $data['showpartialwrong'] = $showpartialwrong; if (!$showpartialwrong) { return $data; } $plugin = 'qtype_ordering'; // Show grading details if they are required. if ($question->showgrading) { // Fetch grading type. $gradingtype = $question->gradingtype; $gradingtype = qtype_ordering_question::get_grading_types($gradingtype); // Format grading type, e.g. Grading type: Relative to next item, excluding last item. if ($gradingtype) { $data['gradingtype'] = get_string('gradingtype', $plugin) . ': ' . $gradingtype; } // Fetch grade details and score details. if ($currentresponse = $question->currentresponse) { $totalscore = 0; $totalmaxscore = 0; $data['orderinglayoutclass'] = $question->get_ordering_layoutclass(); // Format scoredetails, e.g. 1 /2 = 50%, for each item. foreach ($currentresponse as $position => $answerid) { if (array_key_exists($answerid, $question->answers)) { $score = $question->get_ordering_item_score($question, $position, $answerid); if (!isset($score['maxscore'])) { $score['score'] = get_string('noscore', $plugin); } else { $totalscore += $score['score']; $totalmaxscore += $score['maxscore']; } $data['scoredetails'][] = [ 'score' => $score['score'], 'maxscore' => $score['maxscore'], 'percent' => $score['percent'], ]; } } if ($question->gradingtype === qtype_ordering_question::GRADING_ALL_OR_NOTHING || $totalmaxscore == 0) { unset($data['scoredetails']); // All or nothing. } else { // Format gradedetails, e.g. 4/6 = 67%. if ($totalscore == 0) { $data['gradedetails'] = 0; } else { $data['gradedetails'] = round(100 * $totalscore / $totalmaxscore, 0); } $data['totalscore'] = $totalscore; $data['totalmaxscore'] = $totalmaxscore; } } } return $data; } } PK\j|B )type/ordering/classes/output/feedback.phpnu[. namespace qtype_ordering\output; use renderer_base; use question_attempt; use question_display_options; /** * Collate various sections of displayable feedback for render. * * @package qtype_ordering * @copyright 2023 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class feedback extends renderable_base { /** * Define the feedback with options for display. * * @param question_attempt $qa The question attempt object. * @param question_display_options $options Controls what should and should not be displayed * via question_display_options but unit tests are fickle. */ public function __construct( question_attempt $qa, /** @var question_display_options The question display options. */ protected question_display_options $options ) { parent::__construct($qa); } public function export_for_template(renderer_base $output): array { global $PAGE; $data = []; $question = $this->qa->get_question(); $qtyperenderer = $PAGE->get_renderer('qtype_ordering'); if ($this->options->feedback) { // Literal render out but we trust the teacher. $data['specificfeedback'] = $qtyperenderer->specific_feedback($this->qa); $specificgradedetailfeedback = new specific_grade_detail_feedback($this->qa); $data['specificgradedetailfeedback'] = $specificgradedetailfeedback->export_for_template($output); if ($hint = $this->qa->get_applicable_hint()) { $data['hint'] = $question->format_hint($hint, $this->qa); } } if ($this->options->numpartscorrect) { $numpartscorrect = new num_parts_correct($this->qa); $data['numpartscorrect'] = $numpartscorrect->export_for_template($output); } if ($this->options->generalfeedback) { // Literal render out but we trust the teacher. $data['generalfeedback'] = $question->format_generalfeedback($this->qa); } if ($this->options->rightanswer) { $correctresponse = new correct_response($this->qa); $data['rightanswer'] = $correctresponse->export_for_template($output); } return $data; } } PK\=ʃ! ! 0type/ordering/classes/question_hint_ordering.phpnu[. namespace qtype_ordering; use question_display_options; use question_hint_with_parts; /** * Question hint for ordering. * * An extension of {@see question_hint} for questions like match and multiple * choice with multiple answers, where there are options for whether to show the * number of parts right at each stage, and to reset the wrong parts. * * @package qtype_ordering * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_hint_ordering extends question_hint_with_parts { /** @var bool Highlight response in the hint options. */ public bool $highlightresponse; /** * Constructor. * * @param int $id The hint id from the database. * @param string $hint The hint text. * @param int $hintformat The corresponding text FORMAT_... type. * @param bool $shownumcorrect Whether the number of right parts should be shown. * @param bool $clearwrong Whether the wrong parts should be reset. * @param bool $highlightresponse Whether to highlight response. */ public function __construct(int $id, string $hint, int $hintformat, bool $shownumcorrect, bool $clearwrong, bool $highlightresponse) { parent::__construct($id, $hint, $hintformat, $shownumcorrect, $clearwrong); $this->highlightresponse = $highlightresponse; } public static function load_from_record($row): question_hint_ordering { global $DB; // Initialize with the old questions. if (is_null($row->options) || is_null($row->shownumcorrect) || is_null($row->clearwrong)) { if (is_null($row->options)) { $row->options = 1; } if (is_null($row->shownumcorrect)) { $row->shownumcorrect = 1; } if (is_null($row->clearwrong)) { $row->clearwrong = 1; } $DB->update_record('question_hints', $row); } return new question_hint_ordering($row->id, $row->hint, $row->hintformat, $row->shownumcorrect, $row->clearwrong, $row->options); } } PK\Զ222type/ordering/templates/num_parts_correct.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qtype_ordering/num_parts_correct Renders a statement of how many sub-parts of the question the student got correct|partial|incorrect. Context variables required for this template: * numcorrect - The number of correct answers. * numpartial - The number of partially correct answers. * numincorrect - The number of incorrect answers. Example context (json): { "numcorrect": 1, "numpartial": 3, "numincorrect": 0 } }} {{#numcorrect}}
    {{#str}} correctitemsnumber, qtype_ordering, {{numcorrect}} {{/str}}
    {{/numcorrect}} {{#numpartial}}
    {{#str}} partialitemsnumber, qtype_ordering, {{numpartial}} {{/str}}
    {{/numpartial}} {{#numincorrect}}
    {{#str}} incorrectitemsnumber, qtype_ordering, {{numincorrect}} {{/str}}
    {{/numincorrect}} PK\ g?type/ordering/templates/specific_grade_detail_feedback.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qtype_ordering/specific_grade_detail_feedback Renders the grade detail of the response. Context variables required for this template: * showpartialwrong - Whether to show grade details. * gradingtype - Grading type. * gradedetails - Total score (percent). * orderinglayoutclass - The ordering layout CSS class. * scoredetails - An array containing the score details. Example context (json): { "showpartialwrong": true, "gradingtype": "Grading type: Absolute position", "gradedetails": "93", "orderinglayoutclass": "vertical", "scoredetails": [ { "score": "1", "maxscore": "1", "percent": "100" }, { "score": "0", "maxscore": "1", "percent": "0" } ] } }} {{#showpartialwrong}} {{#gradingtype}}

    {{gradingtype}}

    {{/gradingtype}} {{#gradedetails}}

    {{#str}} gradedetails, qtype_ordering {{/str}}: {{totalscore}} / {{totalmaxscore}} = {{gradedetails}}%

    {{/gradedetails}} {{#str}} scoredetails, qtype_ordering {{/str}}
      {{#scoredetails}}
    1. {{score}} / {{maxscore}} = {{percent}}%
    2. {{/scoredetails}}
    {{/showpartialwrong}} PK\56C% % )type/ordering/templates/feedback.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qtype_ordering/feedback Renders the feedback for the question. Context variables required for this template: * specificfeedback - Any specific feedback for the question. * numpartscorrect - The number of parts correct. * specificgradedetailfeedback - The specific grade detail feedback. * hint - The hint for the question. * generalfeedback - The general feedback for the question. * rightanswer - The right answer for the question. Example context (json): { "specificfeedback": "Maybe you should try again.", "numpartscorrect": { "numcorrect": 1, "numpartial": 3, "numincorrect": 0 }, "specificgradedetailfeedback": { "showpartialwrong": true, "gradingtype": "Grading type: Absolute position", "gradedetails": "93", "orderinglayoutclass": "vertical", "scoredetails": [ { "score": "1", "maxscore": "1", "percent": "100" }, { "score": "0", "maxscore": "1", "percent": "0" } ] }, "hint": "The cake is a lie.", "generalfeedback": "Here is some general feedback.", "rightanswer": { "hascorrectresponse": true, "showcorrect": "true", "orderinglayoutclass": "vertical", "correctanswers": [ { "answertext": "Correct answer 1" }, { "answertext": "Correct answer 2" } ] } } }} {{#specificfeedback}}
    {{{.}}}
    {{/specificfeedback}} {{#numpartscorrect}}
    {{>qtype_ordering/num_parts_correct}}
    {{/numpartscorrect}} {{#specificgradedetailfeedback}} {{>qtype_ordering/specific_grade_detail_feedback}} {{/specificgradedetailfeedback}} {{#hint}}
    {{{.}}}
    {{/hint}} {{#generalfeedback}}
    {{{.}}}
    {{/generalfeedback}} {{#rightanswer}}
    {{>qtype_ordering/correct_response}}
    {{/rightanswer}} PK\p#''*type/ordering/templates/proxyhtml.mustachenu[{{! This file is part of Moodle - https://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template question_ordering/proxyhtml The user actually drags a proxy object, which is constructed from this template. The proxy node is then added directly as a child of . Your CSS also needs to ensure that this proxy has position: absolute. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * listClassName The token is replaced with the class attribute of the list being dragged. * itemClassName The token is replaced with the class attribute of the item being dragged. Because of this, the styling of the contents of your list item needs to work for the proxy, as well as for items in place in the context of the list * itemHtml The token is replaced with the innerHtml of the item being dragged. * proxyStyles Passed in styles detailing the size of the proxy, and its position relative to the mouse. Example context (json): { "listClassName": "osep-list", "itemClassName": "osep-item osep-itemmoving", "itemHtml": "Item 1", "proxyStyles": "left: 0px; top: 0px; width: 100px; height: 100px;" } }}
    • {{{itemHtml}}}
    PK\q1type/ordering/templates/correct_response.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qtype_ordering/correct_response Renders the description of the correct response to a given question attempt. Context variables required for this template: * hascorrectresponse - Whether correct response exist. * showcorrect - Whether to show the correct response. * orderinglayoutclass - The ordering layout CSS class. * correctanswers - An array containing the correct answers. * answertext - The formatted answer text. Example context (json): { "hascorrectresponse": true, "showcorrect": "true", "orderinglayoutclass": "vertical", "correctanswers": [ { "answertext": "Correct answer 1" }, { "answertext": "Correct answer 2" } ] } }} {{#hascorrectresponse}} {{#showcorrect}}

    {{#str}} correctorder, qtype_ordering {{/str}}

      {{#correctanswers}}
    1. {{{answertext}}}
    2. {{/correctanswers}}
    {{/showcorrect}} {{/hascorrectresponse}} {{^hascorrectresponse}}

    {{#str}} noresponsedetails, qtype_ordering {{/str}}

    {{/hascorrectresponse}} PK\aI!I!9type/ordering/templates/formulation_and_controls.mustachenu[{{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see . }} {{! @template qtype_ordering/formulation_and_controls Renders the question formulation and controls. Context variables required for this template: * questiontext - Question text. * responsename - Response name for this question attempt. * responseid - Response name for this question attempt. * value - Comma separated md5keys of the answer. * ablockid - Block id. * layoutclass - Layout class. * numberingstyle - Numbering style. * active - if qa is active. * sortableid - Sortable id. * answers - An array containing the score details. * readonly - Whether the question is readonly or not. Example context (json): { "questiontext": "Order this", "responsename": "q13:1_response_2", "responseid": "id_q13_1_response_2", "value": "ordering_item_497031794414a552435f90151ac3b54b,ordering_item_5a35edab0f2bf86dfa3901baa8c235dc", "ablockid": "id_ablock_2", "layoutclass": "vertical", "numberingstyle": "numberingnone", "active": true, "sortableid": "id_sortable_2", "readonly": true, "answers": [ { "id": "ordering_item_497031794414a552435f90151ac3b54b", "scoreclass": "correct", "answertext": "Oriented", "feedbackimage": "" }, { "id": "ordering_item_5a35edab0f2bf86dfa3901baa8c235dc", "scoreclass": "correct", "answertext": "Object", "feedbackimage": "" } ] } }}
    {{{questiontext}}} {{#responsename}}
      {{#answers}}
    • {{^readonly}}
      {{/readonly}}
      {{{feedbackimage}}}{{{answertext}}}
      {{^readonly}}
      {{#horizontallayout}} {{/horizontallayout}} {{^horizontallayout}} {{/horizontallayout}}
      {{/readonly}}
    • {{/answers}}
    {{/responsename}}
    {{^readonly}} {{#js}} require(['qtype_ordering/drag_reorder'], function(drag_reorder) { drag_reorder.init('{{sortableid}}', '{{responseid}}'); }); {{/js}} {{/readonly}} PK\Xo>H>H$type/ordering/edit_ordering_form.phpnu[. // Prevent direct access to this script. defined('MOODLE_INTERNAL') || die(); // Include required files. require_once($CFG->dirroot.'/question/type/ordering/question.php'); /** * Ordering editing form definition * * @package qtype_ordering * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ordering_edit_form extends question_edit_form { /** Rows count in answer field */ const TEXTFIELD_ROWS = 2; /** Cols count in answer field */ const TEXTFIELD_COLS = 60; /** Number of answers in question by default */ const NUM_ITEMS_DEFAULT = 6; /** Minimum number of answers to show */ const NUM_ITEMS_MIN = 3; /** Number of answers to add on demand */ const NUM_ITEMS_ADD = 1; public function qtype(): string { return 'ordering'; } /** * Plugin name is class name without trailing "_edit_form" * * @return string */ public function plugin_name(): string { return 'qtype_ordering'; } public function definition_inner($mform): void { // Field for layouttype. $options = qtype_ordering_question::get_layout_types(); $mform->addElement('select', 'layouttype', get_string('layouttype', 'qtype_ordering'), $options); $mform->addHelpButton('layouttype', 'layouttype', 'qtype_ordering'); $mform->setDefault('layouttype', $this->get_default_value('layouttype', qtype_ordering_question::LAYOUT_VERTICAL)); // Field for selecttype. $options = qtype_ordering_question::get_select_types(); $mform->addElement('select', 'selecttype', get_string('selecttype', 'qtype_ordering'), $options); $mform->addHelpButton('selecttype', 'selecttype', 'qtype_ordering'); $mform->setDefault('selecttype', $this->get_default_value('selecttype', qtype_ordering_question::SELECT_ALL)); // Field for selectcount. $mform->addElement('text', 'selectcount', get_string('selectcount', 'qtype_ordering'), ['size' => 2]); $mform->setDefault('selectcount', qtype_ordering_question::MIN_SUBSET_ITEMS); $mform->setType('selectcount', PARAM_INT); // Hide the field if 'Item selection type' is set to select all items. $mform->hideIf('selectcount', 'selecttype', 'eq', qtype_ordering_question::SELECT_ALL); $mform->addHelpButton('selectcount', 'selectcount', 'qtype_ordering'); $mform->addRule('selectcount', null, 'numeric', null, 'client'); // Field for gradingtype. $options = qtype_ordering_question::get_grading_types(); $mform->addElement('select', 'gradingtype', get_string('gradingtype', 'qtype_ordering'), $options); $mform->addHelpButton('gradingtype', 'gradingtype', 'qtype_ordering'); $mform->setDefault( 'gradingtype', $this->get_default_value('gradingtype', qtype_ordering_question::GRADING_ABSOLUTE_POSITION) ); // Field for showgrading. $options = [0 => get_string('hide'), 1 => get_string('show')]; $mform->addElement('select', 'showgrading', get_string('showgrading', 'qtype_ordering'), $options); $mform->addHelpButton('showgrading', 'showgrading', 'qtype_ordering'); $mform->setDefault('showgrading', $this->get_default_value('showgrading', 1)); // Field for numberingstyle. $options = qtype_ordering_question::get_numbering_styles(); $mform->addElement('select', 'numberingstyle', get_string('numberingstyle', 'qtype_ordering'), $options); $mform->addHelpButton('numberingstyle', 'numberingstyle', 'qtype_ordering'); $mform->setDefault( 'numberingstyle', $this->get_default_value('numberingstyle', qtype_ordering_question::NUMBERING_STYLE_DEFAULT) ); $mform->addElement('header', 'answersheader', get_string('draggableitems', 'qtype_ordering')); $mform->setExpanded('answersheader', true); // Field for the answers. $elements = []; $options = []; $elements[] = $mform->createElement('editor', 'answer', get_string('draggableitemno', 'qtype_ordering'), $this->get_editor_attributes(), $this->get_editor_options()); $elements[] = $mform->createElement('submit', 'answer' . 'removeeditor', get_string('removeeditor', 'qtype_ordering'), ['onclick' => 'skipClientValidation = true;']); $options['answer'] = ['type' => PARAM_RAW]; $this->add_repeat_elements($mform, 'answer', $elements, $options); // Adjust HTML editor and removal buttons. $this->adjust_html_editors($mform, 'answer'); // Adding feedback fields (=Combined feedback). $this->add_combined_feedback_fields(true); // Adding interactive settings (=Multiple tries). $this->add_interactive_settings(false, true); } /** * Returns answer repeats count * * @param stdClass $question * @return int */ protected function get_answer_repeats(stdClass $question): int { if (isset($question->id)) { $repeats = count($question->options->answers); } else { $repeats = self::NUM_ITEMS_DEFAULT; } if ($repeats < self::NUM_ITEMS_MIN) { $repeats = self::NUM_ITEMS_MIN; } return $repeats; } /** * Returns editor attributes * * @return array */ protected function get_editor_attributes(): array { return [ 'rows' => self::TEXTFIELD_ROWS, 'cols' => self::TEXTFIELD_COLS, ]; } /** * Returns editor options * * @return array */ protected function get_editor_options(): array { return [ 'context' => $this->context, 'maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, ]; } /** * Resets editor format to specified * * @param MoodleQuickForm_editor $editor * @param int|string $format * @return int */ protected function reset_editor_format(MoodleQuickForm_editor $editor, int|string $format = FORMAT_MOODLE): int { $value = $editor->getValue(); $value['format'] = $format; $editor->setValue($value); return $format; } /** * Adjust HTML editor and removal buttons. * * @param MoodleQuickForm $mform * @param string $name */ protected function adjust_html_editors(MoodleQuickForm $mform, string $name): void { // Cache the number of formats supported // by the preferred editor for each format. $count = []; if (isset($this->question->options->answers)) { $ids = array_keys($this->question->options->answers); } else { $ids = []; } $defaultanswerformat = get_config('qtype_ordering', 'defaultanswerformat'); $repeats = 'count'.$name.'s'; // E.g. countanswers. if ($mform->elementExists($repeats)) { // Use mform element to get number of repeats. $repeats = $mform->getElement($repeats)->getValue(); } else { // Determine number of repeats by object sniffing. $repeats = 0; while ($mform->elementExists($name."[$repeats]")) { $repeats++; } } for ($i = 0; $i < $repeats; $i++) { $editor = $mform->getElement($name."[$i]"); $id = $ids[$i] ?? 0; // The old/new name of the button to remove the HTML editor // old : the name of the button when added by repeat_elements // new : the simplified name of the button to satisfy "no_submit_button_pressed()" in lib/formslib.php. $oldname = $name.'removeeditor['.$i.']'; $newname = $name.'removeeditor_'.$i; // Remove HTML editor, if necessary. if (optional_param($newname, 0, PARAM_RAW)) { $format = $this->reset_editor_format($editor); $_POST['answer'][$i]['format'] = $format; // Overwrite incoming data. } else if ($id) { $format = $this->question->options->answers[$id]->answerformat; } else { $format = $this->reset_editor_format($editor, $defaultanswerformat); } // Check we have a submit button - it should always be there !! if ($mform->elementExists($oldname)) { if (!isset($count[$format])) { $editor = editors_get_preferred_editor($format); $count[$format] = $editor->get_supported_formats(); $count[$format] = count($count[$format]); } if ($count[$format] > 1) { $mform->removeElement($oldname); } else { $submit = $mform->getElement($oldname); $submit->setName($newname); } $mform->registerNoSubmitButton($newname); } } } protected function get_hint_fields($withclearwrong = false, $withshownumpartscorrect = false): array { $mform = $this->_form; $repeated = []; $repeated[] = $mform->createElement('editor', 'hint', get_string('hintn', 'question'), ['rows' => 5], $this->editoroptions); $repeatedoptions['hint']['type'] = PARAM_RAW; $optionelements = []; if ($withshownumpartscorrect) { $optionelements[] = $mform->createElement('advcheckbox', 'hintshownumcorrect', '', get_string('shownumpartscorrect', 'question')); } $optionelements[] = $mform->createElement('advcheckbox', 'hintoptions', '', get_string('highlightresponse', 'qtype_ordering')); if (count($optionelements)) { $repeated[] = $mform->createElement('group', 'hintoptions', get_string('hintnoptions', 'question'), $optionelements, null, false); } return [$repeated, $repeatedoptions]; } public function data_preprocessing($question): stdClass { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_answers($question, true); // Preprocess feedback. $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, false, true); // Preprocess answers and fractions. $question->answer = []; $question->fraction = []; if (empty($question->options->answers)) { $answerids = []; } else { $answerids = array_keys($question->options->answers); } $defaultanswerformat = get_config('qtype_ordering', 'defaultanswerformat'); $repeats = $this->get_answer_repeats($question); for ($i = 0; $i < $repeats; $i++) { if ($answerid = array_shift($answerids)) { $answer = $question->options->answers[$answerid]; } else { $answer = (object) ['answer' => '', 'answerformat' => $defaultanswerformat]; $answerid = 0; } if (empty($question->id)) { $question->answer[$i] = $answer->answer; } else { $itemid = file_get_submitted_draft_itemid("answer[$i]"); $format = $answer->answerformat; $text = file_prepare_draft_area($itemid, $this->context->id, 'question', 'answer', $answerid, $this->editoroptions, $answer->answer); $question->answer[$i] = [ 'text' => $text, 'format' => $format, 'itemid' => $itemid, ]; } $question->fraction[$i] = ($i + 1); } // Defining default values. $names = [ 'layouttype' => qtype_ordering_question::LAYOUT_VERTICAL, 'selecttype' => qtype_ordering_question::SELECT_ALL, 'selectcount' => qtype_ordering_question::MIN_SUBSET_ITEMS, 'gradingtype' => qtype_ordering_question::GRADING_ABSOLUTE_POSITION, 'showgrading' => 1, // 1 means SHOW. 'numberingstyle' => qtype_ordering_question::NUMBERING_STYLE_DEFAULT, ]; foreach ($names as $name => $default) { $question->$name = $question->options->$name ?? $this->get_default_value($name, $default); } return $question; } protected function data_preprocessing_hints($question, $withclearwrong = false, $withshownumpartscorrect = false): stdClass { if (empty($question->hints)) { return $question; } parent::data_preprocessing_hints($question, $withclearwrong, $withshownumpartscorrect); $question->hintoptions = []; foreach ($question->hints as $hint) { $question->hintoptions[] = $hint->options; } return $question; } public function validation($data, $files): array { $errors = []; $minsubsetitems = qtype_ordering_question::MIN_SUBSET_ITEMS; // Make sure the entered size of the subset is no less than the defined minimum. if ($data['selecttype'] != qtype_ordering_question::SELECT_ALL && $data['selectcount'] < $minsubsetitems) { $errors['selectcount'] = get_string('notenoughsubsetitems', 'qtype_ordering', $minsubsetitems); } // Identify duplicates and report as an error. $answers = []; $answercount = 0; foreach ($data['answer'] as $answer) { if (is_array($answer)) { $answer = $answer['text']; } if ($answer = trim($answer)) { if (in_array($answer, $answers)) { $i = array_search($answer, $answers); $item = get_string('draggableitemno', 'qtype_ordering'); $item = str_replace('{no}', $i + 1, $item); $item = html_writer::link("#id_answer_$i", $item); $a = (object) ['text' => $answer, 'item' => $item]; $errors["answer[$answercount]"] = get_string('duplicatesnotallowed', 'qtype_ordering', $a); } else { $answers[] = $answer; } $answercount++; } } // If there are no answers provided, show error message under first 2 answer boxes // If only 1 answer provided, show error message under second answer box. if ($answercount < 2) { $errors['answer[1]'] = get_string('notenoughanswers', 'qtype_ordering', 2); if ($answercount == 0) { $errors['answer[0]'] = get_string('notenoughanswers', 'qtype_ordering', 2); } } // If adding a new ordering question, update defaults. if (empty($errors) && empty($data['id'])) { $fields = [ 'layouttype', 'selecttype', 'selectcount', 'gradingtype', 'showgrading', 'numberingstyle', ]; foreach ($fields as $field) { if (array_key_exists($field, $data)) { question_bank::get_qtype($this->qtype())->set_default_value($field, $data[$field]); } } } return $errors; } /** * Get array of countable item types * * @param string $type * @param int $max * @return array (type => description) */ protected function get_addcount_options(string $type, int $max = 10): array { // Generate options. $options = []; for ($i = 1; $i <= $max; $i++) { if ($i == 1) { $options[$i] = get_string('addsingle'.$type, 'qtype_ordering'); } else { $options[$i] = get_string('addmultiple'.$type.'s', 'qtype_ordering', $i); } } return $options; } /** * Add repeated elements with a button allowing a selectable number of new elements * * @param MoodleQuickForm $mform the Moodle form object * @param string $type * @param array $elements * @param array $options * @return void, but will update $mform */ protected function add_repeat_elements(MoodleQuickForm $mform, string $type, array $elements, array $options): void { // Cache element names. $types = $type.'s'; $addtypes = 'add'.$types; $counttypes = 'count'.$types; $addtypescount = $addtypes.'count'; $addtypesgroup = $addtypes.'group'; $repeats = $this->get_answer_repeats($this->question); $count = optional_param($addtypescount, self::NUM_ITEMS_ADD, PARAM_INT); $label = ($count == 1 ? 'addsingle'.$type : 'addmultiple'.$types); $label = get_string($label, 'qtype_ordering', $count); $this->repeat_elements($elements, $repeats, $options, $counttypes, $addtypes, $count, $label, true); // Remove the original "Add xxx" button ... $mform->removeElement($addtypes); // ... and replace it with "Add" button + select group. $options = $this->get_addcount_options($type); $mform->addGroup([ $mform->createElement('submit', $addtypes, get_string('add')), $mform->createElement('select', $addtypescount, '', $options), ], $addtypesgroup, '', ' ', false); // Set default value and type of select element. $mform->setDefault($addtypescount, $count); $mform->setType($addtypescount, PARAM_INT); } } PK\;::%type/ordering/amd/src/drag_reorder.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /* * To make a set of things draggable, create a new instance of this object passing the * necessary config, as explained in the comment on the constructor. * * @package qtype_ordering\drag_reorder * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ 'use strict'; import $ from 'jquery'; import drag from 'core/dragdrop'; import Templates from 'core/templates'; import Notification from 'core/notification'; import {getString} from 'core/str'; import {prefetchString} from 'core/prefetch'; export default class DragReorder { // Class variables handling state. config = {reorderStart: undefined, reorderEnd: undefined}; // Config object with some basic definitions. dragStart = null; // Information about when and where the drag started. originalOrder = null; // Array of ids that's used to compare the state after the drag event finishes. // DOM Nodes and jQuery representations. orderList = null; // Order list (HTMLElement). itemDragging = null; // Item being moved by dragging (jQuery object). proxy = null; // Drag proxy (jQuery object). /** * Constructor. * * To make a list draggable, create a new instance of this object, passing the necessary config. * For example: * { * // Selector for the list (or lists) to be reordered. * list: 'ul.my-list', * * // Selector, relative to the list selector, for the items that can be moved. * item: '> li', * * // While the proxy is being dragged, this class is added to the item being moved. * // You can probably use "osep-itemmoving" here. * itemMovingClass: "osep-itemmoving", * * // This is a callback which, when called with the DOM node for an item, * // returns the string that uniquely identifies each item. * // Therefore, the result of the drag action will be represented by the array * // obtained by calling this method on each item in the list in order. * idGetter: function(item) { return node.id; }, * * // Function that will be called when a re-order starts (optional, can be not set). * // Useful if you need to save information about the initial state. * // This function should have two parameters. The first will be a * // jQuery object for the list that was reordered, the second will * // be the jQuery object for the item moved - which will not yet have been moved. * // Note, it is quite possible for reorderStart to be called with no * // subsequent call to reorderDone. * reorderStart: function($list, $item) { ... } * * // Function that will be called when a drag has finished, and the list * // has been reordered. This function should have three parameters. The first will be * // a jQuery object for the list that was reordered, the second will be the jQuery * // object for the item moved, and the third will be the new order, which is * // an array of ids obtained by calling idGetter on each item in the list in order. * // This callback will only be called in the new order is actually different from the old order. * reorderDone: function($list, $item, newOrder) { ... } * * // Function that is always called when a re-order ends (optional, can be not set) * // whether the order has changed. Useful if you need to undo changes made * // in reorderStart, since reorderDone is only called if the new order is different * // from the original order. * reorderEnd: function($list, $item) { ... } * } * * There is a subtlety, If you have items in your list that do not have a drag handle, * they are considered to be placeholders in otherwise empty containers. * * @param {Object} config As above. */ constructor(config) { // Bring in the config to our state. this.config = config; // Get the list we'll be working with this time. this.orderList = document.querySelector(this.config.list); this.startListeners(); } /** * Start the listeners for the list. */ startListeners() { /** * Handle mousedown or touchstart events on the list. * * @param {Event} e The event. */ const pointerHandle = e => { if (e.target.closest(this.config.item) && !e.target.closest(this.config.actionButton)) { this.itemDragging = $(e.target.closest(this.config.item)); const details = drag.prepare(e); if (details.start) { this.startDrag(e, details); } } }; // Set up the list listeners for moving list items around. this.orderList.addEventListener('mousedown', pointerHandle); this.orderList.addEventListener('touchstart', pointerHandle); this.orderList.addEventListener('click', this.itemMovedByClick.bind(this)); } /** * Start dragging. * * @param {Event} e The event which is either mousedown or touchstart. * @param {Object} details Object with start (boolean flag) and x, y (only if flag true) values */ startDrag(e, details) { this.dragStart = { time: new Date().getTime(), x: details.x, y: details.y }; if (typeof this.config.reorderStart !== 'undefined') { this.config.reorderStart(this.itemDragging.closest(this.config.list), this.itemDragging); } this.originalOrder = this.getCurrentOrder(); Templates.renderForPromise('qtype_ordering/proxyhtml', { itemHtml: this.itemDragging.html(), itemClassName: this.itemDragging.attr('class'), listClassName: this.orderList.classList.toString(), proxyStyles: [ `width: ${this.itemDragging.outerWidth()}px;`, `height: ${this.itemDragging.outerHeight()}px;`, ].join(' '), }).then(({html, js}) => { this.proxy = $(Templates.appendNodeContents(document.body, html, js)[0]); this.proxy.css(this.itemDragging.offset()); this.itemDragging.addClass(this.config.itemMovingClass); this.updateProxy(); // Start drag. drag.start(e, this.proxy, this.dragMove.bind(this), this.dragEnd.bind(this)); }).catch(Notification.exception); } /** * Move the proxy to the current mouse position. */ dragMove() { let closestItem = null; let closestDistance = null; this.orderList.querySelectorAll(this.config.item).forEach(element => { const distance = this.distanceBetweenElements(element); if (closestItem === null || distance < closestDistance) { closestItem = $(element); closestDistance = distance; } }); if (closestItem[0] === this.itemDragging[0]) { return; } // Set offset depending on if item is being dragged downwards/upwards. const offsetValue = this.midY(this.proxy) < this.midY(closestItem) ? 20 : -20; if (this.midY(this.proxy) + offsetValue < this.midY(closestItem)) { this.itemDragging.insertBefore(closestItem); } else { this.itemDragging.insertAfter(closestItem); } this.updateProxy(); } /** * Update proxy's position. */ updateProxy() { const items = [...this.orderList.querySelectorAll(this.config.item)]; for (let i = 0; i < items.length; ++i) { if (this.itemDragging[0] === items[i]) { this.proxy.find('li').attr('value', i + 1); break; } } } /** * End dragging. */ dragEnd() { if (typeof this.config.reorderEnd !== 'undefined') { this.config.reorderEnd(this.itemDragging.closest(this.config.list), this.itemDragging); } if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) { // Order has changed, call the callback. this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder()); getString('moved', 'qtype_ordering', { item: this.itemDragging.find('[data-itemcontent]').text().trim(), position: this.itemDragging.index() + 1, total: this.orderList.querySelectorAll(this.config.item).length }).then((str) => { this.config.announcementRegion.innerHTML = str; }); } // Clean up after the drag is finished. this.proxy.remove(); this.proxy = null; this.itemDragging.removeClass(this.config.itemMovingClass); this.itemDragging = null; this.dragStart = null; } /** * Handles the movement of an item by click. * * @param {MouseEvent} e The pointer event. */ itemMovedByClick(e) { const actionButton = e.target.closest(this.config.actionButton); if (actionButton) { this.itemDragging = $(e.target.closest(this.config.item)); // Store the current state of the list. this.originalOrder = this.getCurrentOrder(); switch (actionButton.dataset.action) { case 'move-backward': e.preventDefault(); e.stopPropagation(); if (this.itemDragging.prev().length) { this.itemDragging.prev().insertAfter(this.itemDragging); } break; case 'move-forward': e.preventDefault(); e.stopPropagation(); if (this.itemDragging.next().length) { this.itemDragging.next().insertBefore(this.itemDragging); } break; } // After we have potentially moved the item, we need to check if the order has changed. if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) { // Order has changed, call the callback. this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder()); // When moving an item to the first or last position, the button that was clicked will be hidden. // In this case, we need to focus the other button. if (!this.itemDragging.prev().length) { // Focus the 'next' action button. this.itemDragging.find('[data-action="move-forward"]').focus(); } else if (!this.itemDragging.next().length) { // Focus the 'previous' action button. this.itemDragging.find('[data-action="move-backward"]').focus(); } getString('moved', 'qtype_ordering', { item: this.itemDragging.find('[data-itemcontent]').text().trim(), position: this.itemDragging.index() + 1, total: this.orderList.querySelectorAll(this.config.item).length }).then((str) => { this.config.announcementRegion.innerHTML = str; }); } } } /** * Get the x-position of the middle of the DOM node represented by the given jQuery object. * * @param {jQuery} node jQuery wrapping a DOM node. * @returns {number} Number the x-coordinate of the middle (left plus half outerWidth). */ midX(node) { return node.offset().left + node.outerWidth() / 2; } /** * Get the y-position of the middle of the DOM node represented by the given jQuery object. * * @param {jQuery} node jQuery wrapped DOM node. * @returns {number} Number the y-coordinate of the middle (top plus half outerHeight). */ midY(node) { return node.offset().top + node.outerHeight() / 2; } /** * Calculate the distance between the centres of two elements. * * @param {HTMLLIElement} element DOM node of a list item. * @return {number} number the distance in pixels. */ distanceBetweenElements(element) { const [e1, e2] = [$(element), $(this.proxy)]; const [dx, dy] = [this.midX(e1) - this.midX(e2), this.midY(e1) - this.midY(e2)]; return Math.sqrt(dx * dx + dy * dy); } /** * Get the current order of the list containing itemDragging. * * @returns {Array} Array of strings, the id of each element in order. */ getCurrentOrder() { return this.itemDragging.closest(this.config.list).find(this.config.item).map( (index, item) => { return this.config.idGetter(item); }).get(); } /** * Compare two arrays which contain primitive types to see if they are equal. * @param {Array} a1 first array. * @param {Array} a2 second array. * @return {Boolean} boolean true if they both contain the same elements in the same order, else false. */ arrayEquals(a1, a2) { return a1.length === a2.length && a1.every((v, i) => { return v === a2[i]; }); } /** * Initialise one ordering question. * * @param {String} sortableid id of ul for this question. * @param {String} responseid id of hidden field for this question. */ static init(sortableid, responseid) { new DragReorder({ actionButton: '[data-action]', announcementRegion: document.querySelector(`#${sortableid}-announcement`), list: 'ul#' + sortableid, item: 'li.sortableitem', itemMovingClass: "current-drop", idGetter: item => { return item.id; }, reorderDone: (list, item, newOrder) => { $('input#' + responseid)[0].value = newOrder.join(','); } }); prefetchString('qtype_ordering', 'moved'); } } PK\O+type/ordering/amd/build/drag_reorder.min.jsnu[define("qtype_ordering/drag_reorder",["exports","jquery","core/dragdrop","core/templates","core/notification","core/str","core/prefetch"],(function(_exports,_jquery,_dragdrop,_templates,_notification,_str,_prefetch){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_dragdrop=_interopRequireDefault(_dragdrop),_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification);class DragReorder{constructor(config){_defineProperty(this,"config",{reorderStart:void 0,reorderEnd:void 0}),_defineProperty(this,"dragStart",null),_defineProperty(this,"originalOrder",null),_defineProperty(this,"orderList",null),_defineProperty(this,"itemDragging",null),_defineProperty(this,"proxy",null),this.config=config,this.orderList=document.querySelector(this.config.list),this.startListeners()}startListeners(){const pointerHandle=e=>{if(e.target.closest(this.config.item)&&!e.target.closest(this.config.actionButton)){this.itemDragging=(0,_jquery.default)(e.target.closest(this.config.item));const details=_dragdrop.default.prepare(e);details.start&&this.startDrag(e,details)}};this.orderList.addEventListener("mousedown",pointerHandle),this.orderList.addEventListener("touchstart",pointerHandle),this.orderList.addEventListener("click",this.itemMovedByClick.bind(this))}startDrag(e,details){this.dragStart={time:(new Date).getTime(),x:details.x,y:details.y},void 0!==this.config.reorderStart&&this.config.reorderStart(this.itemDragging.closest(this.config.list),this.itemDragging),this.originalOrder=this.getCurrentOrder(),_templates.default.renderForPromise("qtype_ordering/proxyhtml",{itemHtml:this.itemDragging.html(),itemClassName:this.itemDragging.attr("class"),listClassName:this.orderList.classList.toString(),proxyStyles:["width: ".concat(this.itemDragging.outerWidth(),"px;"),"height: ".concat(this.itemDragging.outerHeight(),"px;")].join(" ")}).then((_ref=>{let{html:html,js:js}=_ref;this.proxy=(0,_jquery.default)(_templates.default.appendNodeContents(document.body,html,js)[0]),this.proxy.css(this.itemDragging.offset()),this.itemDragging.addClass(this.config.itemMovingClass),this.updateProxy(),_dragdrop.default.start(e,this.proxy,this.dragMove.bind(this),this.dragEnd.bind(this))})).catch(_notification.default.exception)}dragMove(){let closestItem=null,closestDistance=null;if(this.orderList.querySelectorAll(this.config.item).forEach((element=>{const distance=this.distanceBetweenElements(element);(null===closestItem||distance{this.config.announcementRegion.innerHTML=str}))),this.proxy.remove(),this.proxy=null,this.itemDragging.removeClass(this.config.itemMovingClass),this.itemDragging=null,this.dragStart=null}itemMovedByClick(e){const actionButton=e.target.closest(this.config.actionButton);if(actionButton){switch(this.itemDragging=(0,_jquery.default)(e.target.closest(this.config.item)),this.originalOrder=this.getCurrentOrder(),actionButton.dataset.action){case"move-backward":e.preventDefault(),e.stopPropagation(),this.itemDragging.prev().length&&this.itemDragging.prev().insertAfter(this.itemDragging);break;case"move-forward":e.preventDefault(),e.stopPropagation(),this.itemDragging.next().length&&this.itemDragging.next().insertBefore(this.itemDragging)}this.arrayEquals(this.originalOrder,this.getCurrentOrder())||(this.config.reorderDone(this.itemDragging.closest(this.config.list),this.itemDragging,this.getCurrentOrder()),this.itemDragging.prev().length?this.itemDragging.next().length||this.itemDragging.find('[data-action="move-backward"]').focus():this.itemDragging.find('[data-action="move-forward"]').focus(),(0,_str.getString)("moved","qtype_ordering",{item:this.itemDragging.find("[data-itemcontent]").text().trim(),position:this.itemDragging.index()+1,total:this.orderList.querySelectorAll(this.config.item).length}).then((str=>{this.config.announcementRegion.innerHTML=str})))}}midX(node){return node.offset().left+node.outerWidth()/2}midY(node){return node.offset().top+node.outerHeight()/2}distanceBetweenElements(element){const[e1,e2]=[(0,_jquery.default)(element),(0,_jquery.default)(this.proxy)],[dx,dy]=[this.midX(e1)-this.midX(e2),this.midY(e1)-this.midY(e2)];return Math.sqrt(dx*dx+dy*dy)}getCurrentOrder(){return this.itemDragging.closest(this.config.list).find(this.config.item).map(((index,item)=>this.config.idGetter(item))).get()}arrayEquals(a1,a2){return a1.length===a2.length&&a1.every(((v,i)=>v===a2[i]))}static init(sortableid,responseid){new DragReorder({actionButton:"[data-action]",announcementRegion:document.querySelector("#".concat(sortableid,"-announcement")),list:"ul#"+sortableid,item:"li.sortableitem",itemMovingClass:"current-drop",idGetter:item=>item.id,reorderDone:(list,item,newOrder)=>{(0,_jquery.default)("input#"+responseid)[0].value=newOrder.join(",")}}),(0,_prefetch.prefetchString)("qtype_ordering","moved")}}return _exports.default=DragReorder,_exports.default})); //# sourceMappingURL=drag_reorder.min.js.mapPK\%xPxP/type/ordering/amd/build/drag_reorder.min.js.mapnu[{"version":3,"file":"drag_reorder.min.js","sources":["../src/drag_reorder.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/*\n * To make a set of things draggable, create a new instance of this object passing the\n * necessary config, as explained in the comment on the constructor.\n *\n * @package qtype_ordering\\drag_reorder\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n'use strict';\n\nimport $ from 'jquery';\nimport drag from 'core/dragdrop';\nimport Templates from 'core/templates';\nimport Notification from 'core/notification';\nimport {getString} from 'core/str';\nimport {prefetchString} from 'core/prefetch';\n\nexport default class DragReorder {\n\n // Class variables handling state.\n config = {reorderStart: undefined, reorderEnd: undefined}; // Config object with some basic definitions.\n dragStart = null; // Information about when and where the drag started.\n originalOrder = null; // Array of ids that's used to compare the state after the drag event finishes.\n\n // DOM Nodes and jQuery representations.\n orderList = null; // Order list (HTMLElement).\n itemDragging = null; // Item being moved by dragging (jQuery object).\n proxy = null; // Drag proxy (jQuery object).\n\n /**\n * Constructor.\n *\n * To make a list draggable, create a new instance of this object, passing the necessary config.\n * For example:\n * {\n * // Selector for the list (or lists) to be reordered.\n * list: 'ul.my-list',\n *\n * // Selector, relative to the list selector, for the items that can be moved.\n * item: '> li',\n *\n * // While the proxy is being dragged, this class is added to the item being moved.\n * // You can probably use \"osep-itemmoving\" here.\n * itemMovingClass: \"osep-itemmoving\",\n *\n * // This is a callback which, when called with the DOM node for an item,\n * // returns the string that uniquely identifies each item.\n * // Therefore, the result of the drag action will be represented by the array\n * // obtained by calling this method on each item in the list in order.\n * idGetter: function(item) { return node.id; },\n *\n * // Function that will be called when a re-order starts (optional, can be not set).\n * // Useful if you need to save information about the initial state.\n * // This function should have two parameters. The first will be a\n * // jQuery object for the list that was reordered, the second will\n * // be the jQuery object for the item moved - which will not yet have been moved.\n * // Note, it is quite possible for reorderStart to be called with no\n * // subsequent call to reorderDone.\n * reorderStart: function($list, $item) { ... }\n *\n * // Function that will be called when a drag has finished, and the list\n * // has been reordered. This function should have three parameters. The first will be\n * // a jQuery object for the list that was reordered, the second will be the jQuery\n * // object for the item moved, and the third will be the new order, which is\n * // an array of ids obtained by calling idGetter on each item in the list in order.\n * // This callback will only be called in the new order is actually different from the old order.\n * reorderDone: function($list, $item, newOrder) { ... }\n *\n * // Function that is always called when a re-order ends (optional, can be not set)\n * // whether the order has changed. Useful if you need to undo changes made\n * // in reorderStart, since reorderDone is only called if the new order is different\n * // from the original order.\n * reorderEnd: function($list, $item) { ... }\n * }\n *\n * There is a subtlety, If you have items in your list that do not have a drag handle,\n * they are considered to be placeholders in otherwise empty containers.\n *\n * @param {Object} config As above.\n */\n constructor(config) {\n // Bring in the config to our state.\n this.config = config;\n\n // Get the list we'll be working with this time.\n this.orderList = document.querySelector(this.config.list);\n\n this.startListeners();\n }\n\n /**\n * Start the listeners for the list.\n */\n startListeners() {\n /**\n * Handle mousedown or touchstart events on the list.\n *\n * @param {Event} e The event.\n */\n const pointerHandle = e => {\n if (e.target.closest(this.config.item) && !e.target.closest(this.config.actionButton)) {\n this.itemDragging = $(e.target.closest(this.config.item));\n const details = drag.prepare(e);\n if (details.start) {\n this.startDrag(e, details);\n }\n }\n };\n // Set up the list listeners for moving list items around.\n this.orderList.addEventListener('mousedown', pointerHandle);\n this.orderList.addEventListener('touchstart', pointerHandle);\n this.orderList.addEventListener('click', this.itemMovedByClick.bind(this));\n }\n\n /**\n * Start dragging.\n *\n * @param {Event} e The event which is either mousedown or touchstart.\n * @param {Object} details Object with start (boolean flag) and x, y (only if flag true) values\n */\n startDrag(e, details) {\n this.dragStart = {\n time: new Date().getTime(),\n x: details.x,\n y: details.y\n };\n\n if (typeof this.config.reorderStart !== 'undefined') {\n this.config.reorderStart(this.itemDragging.closest(this.config.list), this.itemDragging);\n }\n\n this.originalOrder = this.getCurrentOrder();\n\n Templates.renderForPromise('qtype_ordering/proxyhtml', {\n itemHtml: this.itemDragging.html(),\n itemClassName: this.itemDragging.attr('class'),\n listClassName: this.orderList.classList.toString(),\n proxyStyles: [\n `width: ${this.itemDragging.outerWidth()}px;`,\n `height: ${this.itemDragging.outerHeight()}px;`,\n ].join(' '),\n }).then(({html, js}) => {\n this.proxy = $(Templates.appendNodeContents(document.body, html, js)[0]);\n this.proxy.css(this.itemDragging.offset());\n\n this.itemDragging.addClass(this.config.itemMovingClass);\n\n this.updateProxy();\n // Start drag.\n drag.start(e, this.proxy, this.dragMove.bind(this), this.dragEnd.bind(this));\n }).catch(Notification.exception);\n }\n\n /**\n * Move the proxy to the current mouse position.\n */\n dragMove() {\n let closestItem = null;\n let closestDistance = null;\n this.orderList.querySelectorAll(this.config.item).forEach(element => {\n const distance = this.distanceBetweenElements(element);\n if (closestItem === null || distance < closestDistance) {\n closestItem = $(element);\n closestDistance = distance;\n }\n });\n\n if (closestItem[0] === this.itemDragging[0]) {\n return;\n }\n\n // Set offset depending on if item is being dragged downwards/upwards.\n const offsetValue = this.midY(this.proxy) < this.midY(closestItem) ? 20 : -20;\n if (this.midY(this.proxy) + offsetValue < this.midY(closestItem)) {\n this.itemDragging.insertBefore(closestItem);\n } else {\n this.itemDragging.insertAfter(closestItem);\n }\n this.updateProxy();\n }\n\n /**\n * Update proxy's position.\n */\n updateProxy() {\n const items = [...this.orderList.querySelectorAll(this.config.item)];\n for (let i = 0; i < items.length; ++i) {\n if (this.itemDragging[0] === items[i]) {\n this.proxy.find('li').attr('value', i + 1);\n break;\n }\n }\n }\n\n /**\n * End dragging.\n */\n dragEnd() {\n if (typeof this.config.reorderEnd !== 'undefined') {\n this.config.reorderEnd(this.itemDragging.closest(this.config.list), this.itemDragging);\n }\n\n if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) {\n // Order has changed, call the callback.\n this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder());\n\n getString('moved', 'qtype_ordering', {\n item: this.itemDragging.find('[data-itemcontent]').text().trim(),\n position: this.itemDragging.index() + 1,\n total: this.orderList.querySelectorAll(this.config.item).length\n }).then((str) => {\n this.config.announcementRegion.innerHTML = str;\n });\n }\n\n // Clean up after the drag is finished.\n this.proxy.remove();\n this.proxy = null;\n this.itemDragging.removeClass(this.config.itemMovingClass);\n this.itemDragging = null;\n this.dragStart = null;\n }\n\n /**\n * Handles the movement of an item by click.\n *\n * @param {MouseEvent} e The pointer event.\n */\n itemMovedByClick(e) {\n const actionButton = e.target.closest(this.config.actionButton);\n if (actionButton) {\n this.itemDragging = $(e.target.closest(this.config.item));\n\n // Store the current state of the list.\n this.originalOrder = this.getCurrentOrder();\n\n switch (actionButton.dataset.action) {\n case 'move-backward':\n e.preventDefault();\n e.stopPropagation();\n if (this.itemDragging.prev().length) {\n this.itemDragging.prev().insertAfter(this.itemDragging);\n }\n break;\n case 'move-forward':\n e.preventDefault();\n e.stopPropagation();\n if (this.itemDragging.next().length) {\n this.itemDragging.next().insertBefore(this.itemDragging);\n }\n break;\n }\n\n // After we have potentially moved the item, we need to check if the order has changed.\n if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) {\n // Order has changed, call the callback.\n this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder());\n\n // When moving an item to the first or last position, the button that was clicked will be hidden.\n // In this case, we need to focus the other button.\n if (!this.itemDragging.prev().length) {\n // Focus the 'next' action button.\n this.itemDragging.find('[data-action=\"move-forward\"]').focus();\n } else if (!this.itemDragging.next().length) {\n // Focus the 'previous' action button.\n this.itemDragging.find('[data-action=\"move-backward\"]').focus();\n }\n\n getString('moved', 'qtype_ordering', {\n item: this.itemDragging.find('[data-itemcontent]').text().trim(),\n position: this.itemDragging.index() + 1,\n total: this.orderList.querySelectorAll(this.config.item).length\n }).then((str) => {\n this.config.announcementRegion.innerHTML = str;\n });\n }\n }\n }\n\n /**\n * Get the x-position of the middle of the DOM node represented by the given jQuery object.\n *\n * @param {jQuery} node jQuery wrapping a DOM node.\n * @returns {number} Number the x-coordinate of the middle (left plus half outerWidth).\n */\n midX(node) {\n return node.offset().left + node.outerWidth() / 2;\n }\n\n /**\n * Get the y-position of the middle of the DOM node represented by the given jQuery object.\n *\n * @param {jQuery} node jQuery wrapped DOM node.\n * @returns {number} Number the y-coordinate of the middle (top plus half outerHeight).\n */\n midY(node) {\n return node.offset().top + node.outerHeight() / 2;\n }\n\n /**\n * Calculate the distance between the centres of two elements.\n *\n * @param {HTMLLIElement} element DOM node of a list item.\n * @return {number} number the distance in pixels.\n */\n distanceBetweenElements(element) {\n const [e1, e2] = [$(element), $(this.proxy)];\n const [dx, dy] = [this.midX(e1) - this.midX(e2), this.midY(e1) - this.midY(e2)];\n return Math.sqrt(dx * dx + dy * dy);\n }\n\n /**\n * Get the current order of the list containing itemDragging.\n *\n * @returns {Array} Array of strings, the id of each element in order.\n */\n getCurrentOrder() {\n return this.itemDragging.closest(this.config.list).find(this.config.item).map(\n (index, item) => {\n return this.config.idGetter(item);\n }).get();\n }\n\n /**\n * Compare two arrays which contain primitive types to see if they are equal.\n * @param {Array} a1 first array.\n * @param {Array} a2 second array.\n * @return {Boolean} boolean true if they both contain the same elements in the same order, else false.\n */\n arrayEquals(a1, a2) {\n return a1.length === a2.length &&\n a1.every((v, i) => {\n return v === a2[i];\n });\n }\n\n /**\n * Initialise one ordering question.\n *\n * @param {String} sortableid id of ul for this question.\n * @param {String} responseid id of hidden field for this question.\n */\n static init(sortableid, responseid) {\n new DragReorder({\n actionButton: '[data-action]',\n announcementRegion: document.querySelector(`#${sortableid}-announcement`),\n list: 'ul#' + sortableid,\n item: 'li.sortableitem',\n itemMovingClass: \"current-drop\",\n idGetter: item => {\n return item.id;\n },\n reorderDone: (list, item, newOrder) => {\n $('input#' + responseid)[0].value = newOrder.join(',');\n }\n });\n\n prefetchString('qtype_ordering', 'moved');\n }\n}\n"],"names":["DragReorder","constructor","config","reorderStart","undefined","reorderEnd","orderList","document","querySelector","this","list","startListeners","pointerHandle","e","target","closest","item","actionButton","itemDragging","details","drag","prepare","start","startDrag","addEventListener","itemMovedByClick","bind","dragStart","time","Date","getTime","x","y","originalOrder","getCurrentOrder","renderForPromise","itemHtml","html","itemClassName","attr","listClassName","classList","toString","proxyStyles","outerWidth","outerHeight","join","then","_ref","js","proxy","Templates","appendNodeContents","body","css","offset","addClass","itemMovingClass","updateProxy","dragMove","dragEnd","catch","Notification","exception","closestItem","closestDistance","querySelectorAll","forEach","element","distance","distanceBetweenElements","offsetValue","midY","insertBefore","insertAfter","items","i","length","find","arrayEquals","reorderDone","text","trim","position","index","total","str","announcementRegion","innerHTML","remove","removeClass","dataset","action","preventDefault","stopPropagation","prev","next","focus","midX","node","left","top","e1","e2","dx","dy","Math","sqrt","map","idGetter","get","a1","a2","every","v","sortableid","responseid","id","newOrder","value"],"mappings":"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"}PK\-0type/ordering/tests/fixtures/testexport.gift.txtnu[// question: 123 name: Moodle // [id:myid] ::Moodle::[html]Put these words in order.{>2 ALL HORIZONTAL RELATIVE_ALL_PREVIOUS_AND_NEXT SHOW none Modular Object Oriented Dynamic Learning Environment } PK\e8I0type/ordering/tests/fixtures/testimport.gift.txtnu[// question: 25 name: Moodle // [id:myid] ::Moodle::[html]Put these words in order.{>4 CONTIGUOUS VERT ABSOLUTE TRUE ABCD Modular Object Oriented Dynamic Learning Environment #### The correct answer is "Modular Object Oriented Dynamic Learning Environment". } PK\ Sqq7type/ordering/tests/fixtures/testoldquestion.moodle.xmlnu[ $course$/Test questions dd-ordering old question Put these words in order. The correct answer is "Modular Object Oriented Dynamic Learning Environment".

    ]]>
    1.0000000 0.3333333 0 dd1 HORIZONTAL ALL 2 ABSOLUTE_POSITION SHOW Your answer is correct. Your answer is partially correct. Your answer is incorrect. Modular Object Oriented Dynamic Learning Environment Hint 1

    ]]>
    Hint 2

    ]]>
    PK\#?L L 2type/ordering/tests/fixtures/testimport.moodle.xmlnu[ Moodle Put these words in order. 1 0.3333333 0 myid none Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    Modular Object Oriented Dynamic Learning Environment Hint 1 Hint 2
    PK\|6type/ordering/tests/fixtures/testimportlong.moodle.xmlnu[ Moodle Moodle Moodle Moodle Moodle Moodle Moodle Put these words in order. 1 0.3333333 0 myid none Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    Modular Object Oriented Dynamic Learning Environment
    PK\n4type/ordering/tests/fixtures/testquestion.moodle.xmlnu[ $course$/Test questions dd-ordering 1 Put these words in order. The correct answer is "Modular Object Oriented Dynamic Learning Environment".

    ]]>
    1.0000000 0.3333333 0 dd1 HORIZONTAL ALL 2 ABSOLUTE_POSITION SHOW Your answer is correct. Your answer is partially correct. Your answer is incorrect. 1 Modular Object Oriented Dynamic Learning Environment 1
    PK\$¤2type/ordering/tests/fixtures/testexport.moodle.xmlnu[ Moodle Put these words in order. 1 0.3333333 0 myid HORIZONTAL ALL 2 RELATIVE_ALL_PREVIOUS_AND_NEXT SHOW none Well done! Parts, but only parts, of your response are correct. That is not right at all. 1 Modular Modular is correct. Object Object is correct. Oriented Oriented is correct. Dynamic Dynamic is correct. Learning Learning is correct. Environment Environment is correct. Hint 1 Hint 2 PK\$M;;%type/ordering/tests/question_test.phpnu[. namespace qtype_ordering; use test_question_maker; use question_attempt_pending_step; use question_state; use qtype_ordering_question; use question_attempt_step; use question_classified_response; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the ordering question definition class. * * @package qtype_ordering * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering * @covers \qtype_ordering_question */ final class question_test extends \advanced_testcase { /** * Array of draggable items in correct order. */ const CORRECTORDER = ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment']; /** * Array of draggable items in reverse order (incorrect). */ const REVERSEORDER = ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular']; public function test_grading_all_or_nothing(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Zero grade on any error (no partial score at all, it is either 1 or 0). $question->gradingtype = qtype_ordering_question::GRADING_ALL_OR_NOTHING; $question->start_attempt(new question_attempt_pending_step(), 1); $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); $this->assertEquals( [0, question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_absolute_position(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Counts items, placed into right absolute place. $question->gradingtype = qtype_ordering_question::GRADING_ABSOLUTE_POSITION; $question->start_attempt(new question_attempt_pending_step(), 1); // Every item is in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // 4 out of 6 items are in the correct position. $this->assertLessThan( [0.67, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Modular'] ) ) ); $this->assertGreaterThan( [0.66, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Modular'] ) ) ); // 1 out of 6 item is in the correct position. $this->assertLessThan( [0.17, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertGreaterThan( [0.16, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_relative_next_exclude_last(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Every sequential pair in right order is graded (last pair is excluded). $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST; $question->start_attempt(new question_attempt_pending_step(), 1); // Every item is in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position and there is not relative next. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // 4 out of 6 items are in the correct position with relative next. $this->assertEquals( [0.6, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Modular'] ) ) ); // 2 out of 6 item are in the correct position with relative next. $this->assertEquals( [0.4, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_relative_next_include_last(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Every sequential pair in right order is graded (last pair is included). $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_NEXT_INCLUDE_LAST; $question->start_attempt(new question_attempt_pending_step(), 1); // Every item is in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position and there is no relative next. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // 3 out of 6 items are in the correct position with relative next. $this->assertEquals( [0.5, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Modular'] ) ) ); } public function test_grading_relative_one_previous_and_next(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Single answers that are placed before and after each answer is graded if in right order. $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT; $question->start_attempt(new question_attempt_pending_step(), 1); // All items are in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // Partically correct. $this->assertGreaterThan( [0.33, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertLessThan( [0.34, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_relative_all_previous_and_next(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // All answers that are placed before and after each answer is graded if in right order. $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT; $question->start_attempt(new question_attempt_pending_step(), 1); // All items are in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // Partially correct. $this->assertEquals( [0.6, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Oriented', 'Object', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertLessThan( [0.7, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertGreaterThan( [0.6, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_longest_ordered_subset(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Only longest ordered subset is graded. $question->gradingtype = qtype_ordering_question::GRADING_LONGEST_ORDERED_SUBSET; $question->start_attempt(new question_attempt_pending_step(), 1); // All items are in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // 5 items make the longest ordered subset and the result is 5 out of 5 (0.8333333333....) $this->assertLessThan( [0.84, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertGreaterThan( [0.8, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_longest_contiguous_subset(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Only longest ordered and contiguous subset is graded. $question->gradingtype = qtype_ordering_question::GRADING_LONGEST_CONTIGUOUS_SUBSET; $question->start_attempt(new question_attempt_pending_step(), 1); // All items are in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. $this->assertEquals( [0., question_state::$gradedwrong], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); // 5 items make the longest ordered subset and the result is 5 out of 6 (0.8333333333....) $this->assertLessThan( [0.84, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertGreaterThan( [0.8, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_grading_relative_to_correct(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); // Items are graded relative to their position in the correct answer. $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_TO_CORRECT; $question->start_attempt(new question_attempt_pending_step(), 1); // All items are in the correct position. $this->assertEquals( [1, question_state::$gradedright], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ) ); // None of the items are in the correct position. // TODO: This grading method is very generous. It has to be chnaged. $this->assertEquals( [0.4, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Learning', 'Dynamic', 'Oriented', 'Object', 'Modular'] ) ) ); $this->assertLessThan( [0.7, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); $this->assertGreaterThan( [0.6, question_state::$gradedpartial], $question->grade_response( qtype_ordering_test_helper::get_response( $question, ['Object', 'Oriented', 'Dynamic', 'Learning', 'Environment', 'Modular'] ) ) ); } public function test_get_expected_data(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $this->assertArrayHasKey('response_' . $question->id, $question->get_expected_data()); } public function test_get_correct_response(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $question->start_attempt(new question_attempt_pending_step(), 1); // The assertEquals() is used to replace the deprecated assertArraySubset(), because in this one case // they are equals. For more info see https://thephp.cc/articles/migrating-to-phpunit-9 // and https://github.com/rdohms/phpunit-arraysubset-asserts. $this->assertEquals( qtype_ordering_test_helper::get_response($question, self::CORRECTORDER), $question->get_correct_response() ); } public function test_is_same_response(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $question->start_attempt(new question_attempt_pending_step(), 1); $old = qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ); $new = $old; $this->assertTrue($question->is_same_response($old, $new)); $new = qtype_ordering_test_helper::get_response( $question, ['Environment', 'Modular', 'Object', 'Oriented', 'Dynamic', 'Learning'] ); $this->assertFalse($question->is_same_response($old, $new)); } public function test_summarise_response(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $question->start_attempt(new question_attempt_pending_step(), 1); $expected = 'Modular; Object; Oriented; Dynamic; Learning; Environ...'; $actual = $question->summarise_response( qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ) ); $this->assertEquals($expected, $actual); $expected = 'Environ...; Modular; Object; Oriented; Dynamic; Learning'; $actual = $question->summarise_response( qtype_ordering_test_helper::get_response( $question, ['Environment', 'Modular', 'Object', 'Oriented', 'Dynamic', 'Learning'] ) ); $this->assertEquals($expected, $actual); // Confirm that if a passed array contains an item that does not exist in the question, it is ignored. $actual = $question->summarise_response( qtype_ordering_test_helper::get_response( $question, ['notexist'] ) ); $this->assertEquals('', $actual); } public function test_initialise_question_instance(): void { // Create an Ordering question. $questiondata = test_question_maker::get_question_data('ordering'); $question = \question_bank::make_question($questiondata); $this->assertStringContainsString('ordering_item_', reset($question->answers)->md5key); } public function test_get_ordering_layoutclass(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $question->layouttype = 0; $this->assertEquals('vertical', $question->get_ordering_layoutclass()); $question->layouttype = 1; $this->assertEquals('horizontal', $question->get_ordering_layoutclass()); // Confirm that if an invalid layouttype is set, an empty string is returned. $question->layouttype = 3; $error = $question->get_ordering_layoutclass(); $this->assertEquals('', $error); } public function test_get_next_answerids(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $answerids = array_keys($question->answers); // Get next answerids excluding the last. $nextansweidsexcludinglast = $question->get_next_answerids($answerids); $numberofids = count($answerids); foreach ($question->answers as $answerid => $notused) { if ($numberofids > 1) { $this->assertEquals($answerid + 1, $nextansweidsexcludinglast[$answerid]); } $numberofids--; } // Get next answerids including the last. $nextansweidsincludinglast = $question->get_next_answerids($answerids, true); $numberofids = count($answerids); foreach ($question->answers as $answerid => $notused) { if ($numberofids > 1) { $this->assertEquals($answerid + 1, $nextansweidsincludinglast[$answerid]); } else { $this->assertEquals(0, $nextansweidsincludinglast[$answerid]); } $numberofids--; } } public function test_get_previous_and_next_answerids(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $answerids = array_keys($question->answers); // Get immediate prev and next answerid. $prevnextansweids = $question->get_previous_and_next_answerids($answerids); $numberofids = count($answerids); foreach ($question->answers as $answerid => $notused) { if ($numberofids > 1 && $numberofids < count($answerids)) { $this->assertEquals($answerid - 1, $prevnextansweids[$answerid]->prev[0]); $this->assertEquals($answerid + 1, $prevnextansweids[$answerid]->next[0]); } else if ($numberofids === count($answerids)) { $this->assertEquals(0, $prevnextansweids[$answerid]->prev[0]); $this->assertEquals($answerid + 1, $prevnextansweids[$answerid]->next[0]); } else if ($numberofids === 1) { $this->assertEquals($answerid - 1, $prevnextansweids[$answerid]->prev[0]); $this->assertEquals(0, $prevnextansweids[$answerid]->next[0]); } $numberofids--; } } public function test_classify_response_correct(): void { $question = test_question_maker::make_question('ordering'); $question->start_attempt(new question_attempt_step(), 1); $response = qtype_ordering_test_helper::get_response( $question, ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'] ); $classifiedresponse = $question->classify_response($response); $expected = [ 'Modular' => new question_classified_response(1, 'Position 1', 0.1666667), 'Object' => new question_classified_response(2, 'Position 2', 0.1666667), 'Oriented' => new question_classified_response(3, 'Position 3', 0.1666667), 'Dynamic' => new question_classified_response(4, 'Position 4', 0.1666667), 'Learning' => new question_classified_response(5, 'Position 5', 0.1666667), 'Environment' => new question_classified_response(6, 'Position 6', 0.1666667), ]; $this->assertEqualsWithDelta($expected, $classifiedresponse, 0.0000005); } public function test_classify_response_partially_correct(): void { $question = test_question_maker::make_question('ordering'); $question->start_attempt(new question_attempt_step(), 1); $response = qtype_ordering_test_helper::get_response( $question, ['Dynamic', 'Modular', 'Object', 'Oriented', 'Learning', 'Environment'] ); $classifiedresponse = $question->classify_response($response); $expected = [ 'Modular' => new question_classified_response(2, 'Position 2', 0), 'Object' => new question_classified_response(3, 'Position 3', 0), 'Oriented' => new question_classified_response(4, 'Position 4', 0), 'Dynamic' => new question_classified_response(1, 'Position 1', 0), 'Learning' => new question_classified_response(5, 'Position 5', 0.1666667), 'Environment' => new question_classified_response(6, 'Position 6', 0.1666667), ]; $this->assertEqualsWithDelta($expected, $classifiedresponse, 0.0000005); } /** * Test get number of correct|partial|incorrect on response. */ public function test_get_num_parts_right(): void { // Create an Ordering question. $question = test_question_maker::make_question('ordering'); $question->gradingtype = qtype_ordering_question::GRADING_RELATIVE_TO_CORRECT; $question->start_attempt(new question_attempt_pending_step(), 1); $response = qtype_ordering_test_helper::get_response( $question, ['Dynamic', 'Modular', 'Object', 'Oriented', 'Learning', 'Environment'] ); $numparts = $question->get_num_parts_right($response); $this->assertEquals([2, 4, 0], $numparts); } public function test_validate_can_regrade_with_other_version_bad(): void { if (!method_exists('question_definition', 'validate_can_regrade_with_other_version')) { $this->markTestSkipped('This test only applies to Moodle 4.x'); } $question = test_question_maker::make_question('ordering'); $newq = clone($question); $helper = new \qtype_ordering_test_helper(); $newq->answers = [ 23 => $helper->make_answer(23, 'Modular', FORMAT_HTML, 1, true), 24 => $helper->make_answer(24, 'Object', FORMAT_HTML, 2, true), 25 => $helper->make_answer(25, 'Oriented', FORMAT_HTML, 3, true), 26 => $helper->make_answer(26, 'Dynamic', FORMAT_HTML, 4, true), 27 => $helper->make_answer(27, 'Learning', FORMAT_HTML, 5, true), ]; $this->assertEquals( get_string('regradeissuenumitemschanged', 'qtype_ordering'), $newq->validate_can_regrade_with_other_version($question) ); } public function test_validate_can_regrade_with_other_version_ok(): void { if (!method_exists('question_definition', 'validate_can_regrade_with_other_version')) { $this->markTestSkipped('This test only applies to Moodle 4.x'); } $question = test_question_maker::make_question('ordering'); $newq = clone($question); $helper = new \qtype_ordering_test_helper(); $newq->answers = [ 23 => $helper->make_answer(23, 'Modular', FORMAT_HTML, 1, true), 24 => $helper->make_answer(24, 'Object', FORMAT_HTML, 2, true), 25 => $helper->make_answer(25, 'Oriented', FORMAT_HTML, 3, true), 26 => $helper->make_answer(26, 'Dynamic', FORMAT_HTML, 4, true), 27 => $helper->make_answer(27, 'Learning', FORMAT_HTML, 5, true), 28 => $helper->make_answer(28, 'Environment', FORMAT_HTML, 6, true), ]; $this->assertNull($newq->validate_can_regrade_with_other_version($question)); } public function test_update_attempt_state_date_from_old_version_bad(): void { if (!method_exists('question_definition', 'update_attempt_state_data_for_new_version')) { $this->markTestSkipped('This test only applies to Moodle 4.x'); } $question = test_question_maker::make_question('ordering'); $newq = clone($question); $helper = new \qtype_ordering_test_helper(); $newq->answers = [ 23 => $helper->make_answer(23, 'Modular', FORMAT_HTML, 1, true), 24 => $helper->make_answer(24, 'Object', FORMAT_HTML, 2, true), 25 => $helper->make_answer(25, 'Oriented', FORMAT_HTML, 3, true), 26 => $helper->make_answer(26, 'Dynamic', FORMAT_HTML, 4, true), 27 => $helper->make_answer(27, 'Learning', FORMAT_HTML, 5, true), ]; $oldstep = new question_attempt_step(); $oldstep->set_qt_var('_currentresponse', '15,13,17,16,18,14'); $oldstep->set_qt_var('_correctresponse', '13,14,15,16,17,18'); $this->expectExceptionMessage(get_string('regradeissuenumitemschanged', 'qtype_ordering')); $newq->update_attempt_state_data_for_new_version($oldstep, $question); } public function test_update_attempt_state_date_from_old_version_ok(): void { if (!method_exists('question_definition', 'update_attempt_state_data_for_new_version')) { $this->markTestSkipped('This test only applies to Moodle 4.x'); } $question = test_question_maker::make_question('ordering'); $newq = clone($question); $helper = new \qtype_ordering_test_helper(); $newq->answers = [ 23 => $helper->make_answer(23, 'Modular', FORMAT_HTML, 1, true), 24 => $helper->make_answer(24, 'Object', FORMAT_HTML, 2, true), 25 => $helper->make_answer(25, 'Oriented', FORMAT_HTML, 3, true), 26 => $helper->make_answer(26, 'Dynamic', FORMAT_HTML, 4, true), 27 => $helper->make_answer(27, 'Learning', FORMAT_HTML, 5, true), 28 => $helper->make_answer(28, 'Environment', FORMAT_HTML, 6, true), ]; $oldstep = new question_attempt_step(); $oldstep->set_qt_var('_currentresponse', '15,13,17,16,18,14'); $oldstep->set_qt_var('_correctresponse', '13,14,15,16,17,18'); $this->assertEquals( ['_currentresponse' => '25,23,27,26,28,24', '_correctresponse' => '23,24,25,26,27,28'], $newq->update_attempt_state_data_for_new_version($oldstep, $question) ); } public function test_helpers(): void { $question = test_question_maker::make_question('ordering'); $this->assertEquals(true, $question->is_complete_response([])); $this->assertEquals(true, $question->is_gradable_response([])); $this->assertEquals('', $question->get_validation_error([])); } } PK\AXX type/ordering/tests/coverage.phpnu[. defined('MOODLE_INTERNAL') || die(); /** * Coverage information for the qtype_ordering subsystem. * * @package qtype_ordering * @copyright 2023 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ return new class extends phpunit_coverage_info { /** @var array The list of folders relative to the plugin root to include in coverage generation. */ protected $includelistfolders = [ 'backup', ]; /** @var array The list of files relative to the plugin root to include in coverage generation. */ protected $includelistfiles = [ 'question.php', 'questiontype.php', ]; }; PK\m--type/ordering/tests/privacy/provider_test.phpnu[. namespace qtype_ordering\privacy; use core_privacy\local\request\user_preference_provider; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ordering/classes/privacy/provider.php'); /** * Privacy provider tests. * * @package qtype_ordering * @copyright 2024 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\privacy\provider */ final class provider_test extends \core_privacy\tests\provider_testcase { public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_ordering'); $actual = \qtype_ordering\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_ordering_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_ordering'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_ordering_$key", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_ordering'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the ordering question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'layouttype H' => ['layouttype', 0, 0], 'layouttype V' => ['layouttype', 1, 1], 'selecttype ALL' => ['selecttype', 0, 0], 'selecttype RAND' => ['selecttype', 1, 1], 'selecttype CONT' => ['selecttype', 2, 2], 'selectcount 1' => ['selectcount', 1, 1], 'selectcount 3' => ['selectcount', 3, 3], 'selectcount 5' => ['selectcount', 5, 5], 'gradingtype ALL' => ['gradingtype', -1, -1], 'gradingtype ABS' => ['gradingtype', 0, 0], 'gradingtype REL' => ['gradingtype', 7, 7], 'gradingtype REL NEXT EXC' => ['gradingtype', 1, 1], 'gradingtype REL NEXT INC' => ['gradingtype', 2, 2], 'gradingtype REL BOTH' => ['gradingtype', 3, 3], 'gradingtype REL ALL' => ['gradingtype', 4, 4], 'gradingtype LONG ORDER' => ['gradingtype', 5, 5], 'gradingtype LONG CONT' => ['gradingtype', 6, 6], 'showgrading F' => ['showgrading', 0, 0], 'showgrading T' => ['showgrading', 1, 1], 'numberingstyle NONE' => ['numberingstyle', 'none', 'none'], 'numberingstyle abc' => ['numberingstyle', 'abc', 'abc'], 'numberingstyle ABC' => ['numberingstyle', 'ABC', 'ABC'], 'numberingstyle 123' => ['numberingstyle', '123', '123'], 'numberingstyle iii' => ['numberingstyle', 'iii', 'iii'], 'numberingstyle IIII' => ['numberingstyle', 'IIII', 'IIII'], ]; } } PK\!f;2#type/ordering/tests/backup_test.phpnu[. namespace qtype_ordering; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot . '/course/externallib.php'); /** * Tests for the orderinging question type backup and restore logic. * * @package qtype_ordering * @copyright 2020 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. * @covers \backup_qtype_ordering_plugin * @covers \restore_qtype_ordering_plugin */ final class backup_test extends \advanced_testcase { /** * Duplicate quiz with a orderinging question, and check it worked. */ public function test_duplicate_ordering_question(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $coregenerator = $this->getDataGenerator(); $questiongenerator = $coregenerator->get_plugin_generator('core_question'); // Create a course with a page that embeds a question. $course = $coregenerator->create_course(); $quiz = $coregenerator->create_module('quiz', ['course' => $course->id]); $quizcontext = \context_module::instance($quiz->cmid); $cat = $questiongenerator->create_question_category(['contextid' => $quizcontext->id]); $question = $questiongenerator->create_question('ordering', 'moodle', ['category' => $cat->id, 'shownumcorrect' => null]); // Store some counts. $numquizzes = count(get_fast_modinfo($course)->instances['quiz']); $numorderingquestions = $DB->count_records('question', ['qtype' => 'ordering']); // Duplicate the page. duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); // Verify the copied quiz exists. $this->assertCount($numquizzes + 1, get_fast_modinfo($course)->instances['quiz']); // Verify the copied question. $this->assertEquals($numorderingquestions + 1, $DB->count_records('question', ['qtype' => 'ordering'])); $neworderingid = $DB->get_field_sql(" SELECT MAX(id) FROM {question} WHERE qtype = ? ", ['ordering']); // Declare some parts of the question to be compared. $existingorderingdata = question_bank::load_question_data($question->id); $orderingdata = question_bank::load_question_data($neworderingid); $existinganswers = array_values((array) $existingorderingdata->options->answers); $answers = array_values((array) $orderingdata->options->answers); // Verify the copied question has the same values without being too verbose. foreach ((array) $existingorderingdata as $key => $value) { if (in_array($key, ['questiontext', 'generalfeedback', 'partiallycorrectfeedback'])) { $this->assertEquals($value, $orderingdata->$key); } } // Verify some parts of the new question we'll know that will be different. $this->assertNotEquals($existingorderingdata->id, $orderingdata->id); for ($i = 0; $i < count($existinganswers); $i++) { $this->assertEquals($existinganswers[$i]->answer, $answers[$i]->answer); $this->assertEquals($existinganswers[$i]->fraction, $answers[$i]->fraction); $this->assertEquals($existinganswers[$i]->feedback, $answers[$i]->feedback); } } } PK\ FV%%(type/ordering/tests/walkthrough_test.phpnu[. namespace qtype_ordering; use test_question_maker; use question_state; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddwtos/tests/helper.php'); /** * Unit tests for the ordering question type. * * These tests simulate a student's complete interaction with a question. * * @package qtype_ordering * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering * @covers \qtype_ordering_question */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { /** * Get the array of post data that will . * * @param array $items Representation of the response we want to submit. The answers in order. * @return array simulated POST data. */ protected function get_response(array $items): array { $question = $this->quba->get_question($this->slot); return qtype_ordering_test_helper::get_response($question, $items); } public function test_deferred_feedback(): void { // Create an ordering question. $question = test_question_maker::make_question('ordering'); $this->start_attempt_at_question($question, 'deferredfeedback', 15); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'response_' . $question->id ), $this->get_does_not_contain_feedback_expectation() ); // Save the right answer. $rightresponse = $this->get_response(['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment']); $this->process_submission($rightresponse); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation() ); // If the same answer is saved again, we should not generate another step. $stepcount = $this->get_step_count(); $this->process_submission($rightresponse); // Verify. $this->check_step_count($stepcount); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(15); $this->check_current_output( $this->get_contains_correct_expectation(), $this->get_contains_general_feedback_expectation($question) ); } public function test_interactive_behaviour(): void { // Create a drag-and-drop question. $question = test_question_maker::make_question('ordering'); $question->hints = [ new question_hint_ordering(13, 'This is the first hint.', FORMAT_HTML, true, false, true), new question_hint_ordering(14, 'This is the second hint.', FORMAT_HTML, false, false, false), ]; $this->start_attempt_at_question($question, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'response_' . $question->id ), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation() ); // Submit the wrong answer. $this->process_submission(['-submit' => 1] + $this->get_response(['Environment', 'Modular', 'Object', 'Oriented', 'Dynamic', 'Learning'])); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the first hint') ); $this->check_output_does_not_contain(get_string('correctitemsnumber', 'qtype_ordering', 0)); $this->check_output_contains(get_string('partialitemsnumber', 'qtype_ordering', 5)); $this->check_output_contains(get_string('incorrectitemsnumber', 'qtype_ordering', 1)); // Do try again. $this->process_submission(['-tryagain' => 1]); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation() ); // Submit the right answer. $this->process_submission(['-submit' => 1] + $this->get_response(['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'])); // Verify. $this->check_current_state(question_state::$gradedright); // Note, this may not be the 'best possible' grade. Perhaps there should // partially credit for things that were right on the first try. $this->check_current_mark(2); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation() ); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); } } PK\)type/ordering/tests/questionhint_test.phpnu[. namespace qtype_ordering; use advanced_testcase; use qtype_ordering; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ordering/questiontype.php'); /** * A test class used to test question_hint_ordering. * * @package qtype_ordering * @copyright 2024 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\question_hint_ordering * @covers \qtype_ordering */ final class questionhint_test extends advanced_testcase { /** @var qtype_ordering Instance of the question type class to test. */ protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_ordering(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } /** * Test that hints can be fetched from the DB. * @return void */ public function test_load_from_record(): void { $this->resetAfterTest(); $obj = (object) [ 'id' => 13, 'hint' => 'Hint 1', 'hintformat' => FORMAT_HTML, 'shownumcorrect' => 0, 'clearwrong' => 0, 'options' => null, ]; $hint = question_hint_ordering::load_from_record($obj); $this->assertInstanceOf(question_hint_ordering::class, $hint); } public function test_make_hint(): void { $this->resetAfterTest(); $syscontext = \context_system::instance(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(['contextid' => $syscontext->id]); $fromdata = \test_question_maker::get_question_form_data('ordering'); $fromdata->category = $category->id . ',' . $syscontext->id; $question = new \stdClass(); $question->category = $category->id; $question->qtype = 'ordering'; $question->createdby = 0; $question = $this->qtype->save_question($question, $fromdata); $questiondata = question_bank::load_question_data($question->id); // Build the expected hint base. $hintbase = [ 'questionid' => $questiondata->id, 'shownumcorrect' => '0', 'clearwrong' => '0', 'options' => '0', ]; $expectedhints = []; foreach ($fromdata->hint as $key => $value) { $hint = $hintbase + [ 'hint' => $value['text'], 'hintformat' => $value['format'], ]; $expectedhints[] = (object)$hint; } // Need to get rid of ids. $gothints = array_map(function($hint) { unset($hint->id); return $hint; }, $questiondata->hints); // Compare hints. $this->assertEquals($expectedhints, array_values($gothints)); } } PK\8Pl8l8)type/ordering/tests/questiontype_test.phpnu[. namespace qtype_ordering; use core_question_generator; use phpunit_util; use qtype_ordering; use qtype_ordering_test_helper; use qtype_ordering_edit_form; use qtype_ordering_question; use test_question_maker; use question_bank; use question_possible_response; use qformat_xml; use qformat_gift; use question_check_specified_fields_expectation; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/ordering/questiontype.php'); require_once($CFG->dirroot . '/question/type/ordering/edit_ordering_form.php'); require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/format/gift/format.php'); require_once($CFG->dirroot . '/question/format/xml/format.php'); /** * Unit tests for the ordering question type class. * * @package qtype_ordering * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering * @covers \qtype_ordering_question */ final class questiontype_test extends \question_testcase { /** * Define the import object to compare against. * * @return object The expected import object. */ private static function expectedimport(): object { return (object) [ 'qtype' => 'ordering', 'idnumber' => 'myid', 'name' => 'Moodle', 'length' => 1, 'penalty' => 0.3333333, 'questiontext' => 'Put these words in order.', 'questiontextformat' => 1, 'generalfeedback' => 'The correct answer is "Modular Object Oriented Dynamic Learning Environment".', 'generalfeedbackformat' => 1, 'defaultmark' => 1, ]; } public function test_name(): void { $ordering = new qtype_ordering(); $this->assertEquals('ordering', $ordering->name()); } public function test_can_analyse_responses(): void { $ordering = new qtype_ordering(); $this->assertTrue($ordering->can_analyse_responses()); } public function test_question_saving(): void { $this->resetAfterTest(); $this->setAdminUser(); $questiondata = test_question_maker::get_question_data('ordering'); $formdata = test_question_maker::get_question_form_data('ordering'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category([]); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_ordering_edit_form::mock_submit((array) $formdata); $form = qtype_ordering_test_helper::get_question_editing_form($cat, $questiondata); $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); $ordering = new qtype_ordering(); $returnedfromsave = $ordering->save_question($questiondata, $fromform); $actualquestiondata = question_bank::load_question_data($returnedfromsave->id); foreach ($questiondata as $property => $value) { if (!in_array($property, ['id', 'version', 'timemodified', 'timecreated', 'options', 'stamp'])) { $this->assertContainsEquals($value, (array)$actualquestiondata); $this->assertContainsEquals($property, array_keys((array)$actualquestiondata)); } } foreach ($questiondata->options as $optionname => $value) { if ($optionname != 'answers') { $this->assertContainsEquals($value, (array)$actualquestiondata->options); $this->assertContainsEquals($optionname, array_keys((array)$actualquestiondata->options)); } } foreach ($questiondata->options->answers as $answer) { $actualanswer = array_shift($actualquestiondata->options->answers); foreach ($answer as $ansproperty => $ansvalue) { if ($ansproperty === 'question') { $this->assertContainsEquals($returnedfromsave->id, (array)$actualanswer); $this->assertContainsEquals($ansproperty, array_keys((array)$actualanswer)); } else if ($ansproperty !== 'id') { $this->assertContainsEquals($ansvalue, (array)$actualanswer); $this->assertContainsEquals($ansproperty, array_keys((array)$actualanswer)); } } } } public function test_get_possible_responses(): void { $questiondata = test_question_maker::get_question_data('ordering'); $ordering = new qtype_ordering(); $possibleresponses = $ordering->get_possible_responses($questiondata); $expectedresponseclasses = [ 'Modular' => [ 1 => new question_possible_response('Position 1', 0.1666667), 2 => new question_possible_response('Position 2', 0), 3 => new question_possible_response('Position 3', 0), 4 => new question_possible_response('Position 4', 0), 5 => new question_possible_response('Position 5', 0), 6 => new question_possible_response('Position 6', 0), ], 'Object' => [ 1 => new question_possible_response('Position 1', 0), 2 => new question_possible_response('Position 2', 0.1666667), 3 => new question_possible_response('Position 3', 0), 4 => new question_possible_response('Position 4', 0), 5 => new question_possible_response('Position 5', 0), 6 => new question_possible_response('Position 6', 0), ], 'Oriented' => [ 1 => new question_possible_response('Position 1', 0), 2 => new question_possible_response('Position 2', 0), 3 => new question_possible_response('Position 3', 0.1666667), 4 => new question_possible_response('Position 4', 0), 5 => new question_possible_response('Position 5', 0), 6 => new question_possible_response('Position 6', 0), ], 'Dynamic' => [ 1 => new question_possible_response('Position 1', 0), 2 => new question_possible_response('Position 2', 0), 3 => new question_possible_response('Position 3', 0), 4 => new question_possible_response('Position 4', 0.1666667), 5 => new question_possible_response('Position 5', 0), 6 => new question_possible_response('Position 6', 0), ], 'Learning' => [ 1 => new question_possible_response('Position 1', 0), 2 => new question_possible_response('Position 2', 0), 3 => new question_possible_response('Position 3', 0), 4 => new question_possible_response('Position 4', 0), 5 => new question_possible_response('Position 5', 0.1666667), 6 => new question_possible_response('Position 6', 0), ], 'Environment' => [ 1 => new question_possible_response('Position 1', 0), 2 => new question_possible_response('Position 2', 0), 3 => new question_possible_response('Position 3', 0), 4 => new question_possible_response('Position 4', 0), 5 => new question_possible_response('Position 5', 0), 6 => new question_possible_response('Position 6', 0.1666667), ], ]; $this->assertEqualsWithDelta($expectedresponseclasses, $possibleresponses, 0.0000005); } public function test_get_possible_responses_very_long(): void { $questiondata = test_question_maker::get_question_data('ordering'); $ordering = new qtype_ordering(); $onehundredchars = str_repeat('1234567890', 9) . '123456789碁'; // Set one of the answers to over 100 chars, with a multi-byte UTF-8 character at position 100. $questiondata->options->answers[13]->answer = $onehundredchars . 'and some more'; $possibleresponses = $ordering->get_possible_responses($questiondata); $this->assertArrayHasKey($onehundredchars, $possibleresponses); } public function test_get_numberingstyle(): void { $questiondata = test_question_maker::get_question_data('ordering'); $ordering = new qtype_ordering(); $expected = qtype_ordering_question::NUMBERING_STYLE_DEFAULT; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); $questiondata->options->numberingstyle = 'abc'; $expected = 'abc'; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); $questiondata->options->numberingstyle = 'ABCD'; $expected = 'ABCD'; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); $questiondata->options->numberingstyle = '123'; $expected = '123'; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); $questiondata->options->numberingstyle = 'iii'; $expected = 'iii'; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); $questiondata->options->numberingstyle = 'III'; $expected = 'III'; $actual = $ordering->get_numberingstyle($questiondata); $this->assertEquals($expected, $actual); } public function test_xml_import(): void { $this->resetAfterTest(); // Import a question from XML. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'testimport.moodle.xml')); $xmldata = xmlize($xml); $format = new qformat_xml(); $imported = $format->try_importing_using_qtypes( $xmldata['question'], null, null, 'ordering'); $this->assert(new question_check_specified_fields_expectation(self::expectedimport()), $imported); } public function test_xml_import_long(): void { $this->resetAfterTest(); // Import a question from XML. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'testimportlong.moodle.xml')); $xmldata = xmlize($xml); $format = new qformat_xml(); $imported = $format->try_importing_using_qtypes( $xmldata['question'], null, null, 'ordering'); $expected = self::expectedimport(); $expected->name = 'Moodle Moodle Moodle Moodle Moodle Moodle Moodle'; $this->assert(new question_check_specified_fields_expectation($expected), $imported); } public function test_xml_export(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(); $question = $generator->create_question('ordering', 'moodle', ['category' => $category->id, 'idnumber' => 'myid']); // Export it. $questiondata = question_bank::load_question_data($question->id); // Force the question id to be 123, to ensure it comes through the export. $questiondata->id = 123; $questiondata->options->numberingstyle = null; // Add some feedback to ensure it comes through the export. foreach ($questiondata->options->answers as $answer) { $answer->feedback = $answer->answer . ' is correct.'; $answer->feedbackformat = FORMAT_HTML; $answer->feedbackfiles = 0; } $exporter = new qformat_xml(); $xml = $exporter->writequestion($questiondata); $expectedxml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'testexport.moodle.xml')); $this->assert_same_xml($expectedxml, $xml); } public function test_gift_import(): void { $this->resetAfterTest(); // Import a question from GIFT. $gift = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'testimport.gift.txt')); $format = new qformat_gift(); $lines = preg_split('/[\\n\\r]/', phpunit_util::normalise_line_endings($gift)); $imported = $format->readquestion($lines); $this->assert(new question_check_specified_fields_expectation(self::expectedimport()), $imported); } public function test_gift_export(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(); $question = $generator->create_question('ordering', 'moodle', ['category' => $category->id, 'idnumber' => 'myid']); // Export it. $questiondata = question_bank::load_question_data($question->id); // Force the question id to be 123, to ensure it comes through the export. $questiondata->id = 123; $exporter = new qformat_gift(); $gift = $exporter->writequestion($questiondata); $expectedgift = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'testexport.gift.txt')); $this->assertEquals( phpunit_util::normalise_line_endings($expectedgift), phpunit_util::normalise_line_endings($gift) ); } } PK\;;$;$type/ordering/tests/helper.phpnu[. /** * Test helper for the ordering question type. * * @package qtype_ordering * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ordering/question.php'); /** * Test helper for the ordering question type. * * The class has code to generate question data structures for sample ordering questions. * * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ordering_test_helper extends question_test_helper { /** * Get the question types that this helper can handle. * * @return array the question types. */ public function get_test_questions(): array { return ['moodle']; } /** * Makes an ordering question to sort the words Modular Object Oriented Dynamic Learning Environment. * * @return qtype_ordering_question the question instance. */ public function make_ordering_question_moodle(): qtype_ordering_question { question_bank::load_question_definition_classes('ordering'); $q = new qtype_ordering_question(); $q->hints = [ [ 'text' => 'Hint 1', 'format' => FORMAT_HTML, ], [ 'text' => 'Hint 2', 'format' => FORMAT_HTML, ], ]; test_question_maker::initialise_a_question($q); $q->qtype = question_bank::get_qtype('ordering'); $q->name = 'Moodle'; $q->questiontext = 'Put these words in order'; $q->generalfeedback = 'The correct answer is "Modular Object Oriented Dynamic Learning Environment".'; test_question_maker::set_standard_combined_feedback_fields($q); $q->answers = [ 13 => $this->make_answer(13, 'Modular', FORMAT_HTML, 1, true), 14 => $this->make_answer(14, 'Object', FORMAT_HTML, 2, true), 15 => $this->make_answer(15, 'Oriented', FORMAT_HTML, 3, true), 16 => $this->make_answer(16, 'Dynamic', FORMAT_HTML, 4, true), 17 => $this->make_answer(17, 'Learning', FORMAT_HTML, 5, true), 18 => $this->make_answer(18, 'Environment', FORMAT_HTML, 6, true), ]; $q->layouttype = qtype_ordering_question::LAYOUT_HORIZONTAL; $q->selecttype = qtype_ordering_question::SELECT_ALL; $q->selectcount = qtype_ordering_question::MIN_SUBSET_ITEMS; $q->gradingtype = qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT; $q->showgrading = true; $q->numberingstyle = qtype_ordering_question::NUMBERING_STYLE_DEFAULT; $q->shownumcorrect = 1; return $q; } /** * Create an answer record to use in a test question. * * @param int $id the id to set. * @param string $text * @param int $textformat one of the FORMAT_... constants. * @param int $order the position in order, numbered from 1. * @param bool $addmd5 whether to add the md5key property. * @return stdClass the answer. */ public function make_answer(int $id, string $text, int $textformat, int $order, bool $addmd5 = false): stdClass { global $CFG; $answer = new stdClass(); $answer->id = $id; $answer->question = 0; $answer->answer = $text; $answer->answerformat = $textformat; $answer->fraction = $order; $answer->feedback = ''; $answer->feedbackformat = FORMAT_MOODLE; if ($addmd5) { if (isset($CFG->passwordsaltmain)) { $salt = $CFG->passwordsaltmain; } else { $salt = ''; } $answer->md5key = 'ordering_item_' . md5($salt . $answer->answer); } return $answer; } /** * Get the form data that corresponds an ordering question. * * The question is to sort the words Modular Object Oriented Dynamic Learning Environment. * * @return stdClass simulated question form data. */ public function get_ordering_question_form_data_moodle(): stdClass { $form = new stdClass(); $form->name = 'Moodle'; $form->questiontext = ['text' => 'Put these words in order.', 'format' => FORMAT_HTML]; $form->defaultmark = 1; $form->generalfeedback = [ 'text' => 'The correct answer is "Modular Object Oriented Dynamic Learning Environment".', 'format' => FORMAT_HTML, ]; $form->layouttype = qtype_ordering_question::LAYOUT_HORIZONTAL; $form->selecttype = qtype_ordering_question::SELECT_ALL; $form->selectcount = qtype_ordering_question::MIN_SUBSET_ITEMS; $form->gradingtype = qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT; $form->showgrading = true; $form->numberingstyle = qtype_ordering_question::NUMBERING_STYLE_DEFAULT; $form->countanswers = 6; $form->answer = [ ['text' => 'Modular', 'format' => FORMAT_HTML], ['text' => 'Object', 'format' => FORMAT_HTML], ['text' => 'Oriented', 'format' => FORMAT_HTML], ['text' => 'Dynamic', 'format' => FORMAT_HTML], ['text' => 'Learning', 'format' => FORMAT_HTML], ['text' => 'Environment', 'format' => FORMAT_HTML], ]; test_question_maker::set_standard_combined_feedback_form_data($form); $form->penalty = '0.3333333'; // Build the expected hint base. $form->numhints = 2; $form->hint = [ [ 'text' => 'Hint 1', 'format' => FORMAT_HTML, ], [ 'text' => 'Hint 2', 'format' => FORMAT_HTML, ], ];; $form->qtype = 'ordering'; return $form; } /** * Get the raw data that corresponds an ordering question. * * The question is to sort the words Modular Object Oriented Dynamic Learning Environment. * * @return stdClass simulated question form data. */ public function get_ordering_question_data_moodle(): stdClass { $questiondata = new stdClass(); test_question_maker::initialise_question_data($questiondata); $questiondata->qtype = 'ordering'; $questiondata->name = 'Moodle'; $questiondata->questiontext = 'Put these words in order'; $questiondata->generalfeedback = 'The correct answer is "Modular Object Oriented Dynamic Learning Environment".'; $questiondata->options = new stdClass(); test_question_maker::set_standard_combined_feedback_fields($questiondata->options); $questiondata->options->layouttype = qtype_ordering_question::LAYOUT_HORIZONTAL; $questiondata->options->selecttype = qtype_ordering_question::SELECT_ALL; $questiondata->options->selectcount = qtype_ordering_question::MIN_SUBSET_ITEMS; $questiondata->options->gradingtype = qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT; $questiondata->options->showgrading = true; $questiondata->options->numberingstyle = qtype_ordering_question::NUMBERING_STYLE_DEFAULT; $questiondata->options->answers = [ 13 => $this->make_answer(13, 'Modular', FORMAT_HTML, 1), 14 => $this->make_answer(14, 'Object', FORMAT_HTML, 2), 15 => $this->make_answer(15, 'Oriented', FORMAT_HTML, 3), 16 => $this->make_answer(16, 'Dynamic', FORMAT_HTML, 4), 17 => $this->make_answer(17, 'Learning', FORMAT_HTML, 5), 18 => $this->make_answer(18, 'Environment', FORMAT_HTML, 6), ]; return $questiondata; } /** * Return an array of answer codes in the order of given response. * * @param question_definition $question The question object. * @param array $items The array of input items. * @return array The array of answer codes in the order of given response. */ public static function get_response(question_definition $question, array $items): array { $md5keys = []; foreach ($items as $item) { foreach ($question->answers as $answer) { if ($item === $answer->answer) { $md5keys[] = $answer->md5key; break; } } } return ['response_' . $question->id => implode(',', $md5keys)]; } } PK\r5type/ordering/tests/output/num_parts_correct_test.phpnu[. namespace qtype_ordering\output; use advanced_testcase; use test_question_maker; use qtype_ordering_question; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * A test class used to test specific_grade_detail_feedback. * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\output\renderable_base * @covers \qtype_ordering\output\num_parts_correct */ final class num_parts_correct_test extends advanced_testcase { /** * Test the exported data for the template that renders the specific grade detail feedback test to a given question attempt. * * @dataProvider export_for_template_provider * @param array $answeritems The array of ordered answers. * @param int $gradingtype Grading type. * @param array $expected The expected exported data. * @return void */ public function test_export_for_template(array $answeritems, int $gradingtype, array $expected): void { global $PAGE; $question = test_question_maker::make_question('ordering'); $qa = new \testable_question_attempt($question, 0); $step = new \question_attempt_step(); $qa->add_step($step); $question->start_attempt($step, 1); $question->gradingtype = $gradingtype; $keys = implode(',', array_keys($answeritems)); $step->set_qt_var('_currentresponse', $keys); $renderer = $PAGE->get_renderer('core'); $specificgradedetailfeedback = new num_parts_correct($qa); $actual = $specificgradedetailfeedback->export_for_template($renderer); $this->assertEquals($expected, $actual); } /** * Data provider for the test_export_for_template test. * * @return array */ public static function export_for_template_provider(): array { global $CFG; require_once($CFG->dirroot . '/question/type/ordering/question.php'); return [ 'Partially correct question attempt. Absolute position' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, [ 'numcorrect' => 4, 'numpartial' => 2, 'numincorrect' => 0, ], ], 'Incorrect question attempt (horizontal layout). Relative to ALL the previous and next items' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_ABSOLUTE_POSITION, [ 'numcorrect' => 0, 'numpartial' => 0, 'numincorrect' => 6, ], ], ]; } } PK\ CDCD,type/ordering/tests/output/feedback_test.phpnu[. namespace qtype_ordering\output; use qbehaviour_walkthrough_test_base; use qtype_ordering\question_hint_ordering; use test_question_maker; use qtype_ordering_question; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Test the feedback exporter. * * @package qtype_ordering * @copyright 2023 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\output\renderable_base * @covers \qtype_ordering\output\feedback */ final class feedback_test extends qbehaviour_walkthrough_test_base { /** @var array The correct answers for the question, added to quickly reference. */ const CORRECTANSWERS = [ 0 => [ 'answertext' => 'Modular', ], 1 => [ 'answertext' => 'Object', ], 2 => [ 'answertext' => 'Oriented', ], 3 => [ 'answertext' => 'Dynamic', ], 4 => [ 'answertext' => 'Learning', ], 5 => [ 'answertext' => 'Environment', ], ]; /** * Test the exported data for the template that renders the feedback test to a given question attempt. * * @dataProvider export_for_template_provider * @param array $answeritems The array of ordered answers. * @param int $gradingtype Grading type. * @param array $testoptions Do we want to change direction, is it in progress and do we want feedback. * @param array $expected The expected exported data. * @return void */ public function test_export_for_template(array $answeritems, int $gradingtype, array $testoptions, array $expected): void { global $PAGE; $question = test_question_maker::make_question('ordering'); $question->hints = [ new question_hint_ordering(13, 'This is the first hint.', FORMAT_HTML, true, false, true), new question_hint_ordering(14, 'This is the second hint.', FORMAT_HTML, false, false, false), ]; $question->layouttype = $testoptions['rot'] === 'horizontal' ? qtype_ordering_question::LAYOUT_HORIZONTAL : qtype_ordering_question::LAYOUT_VERTICAL; // If we need to access the attempt midway through, we need a flow where we don't grade instantly. if (!$testoptions['inprogress']) { $qa = new \testable_question_attempt($question, 0); $step = new \question_attempt_step(); $qa->add_step($step); $qa->set_behaviour($question->make_behaviour($qa, 'interactive')); $question->gradingtype = $gradingtype; $question->start_attempt($step, 1); // Process a response and check the expected result. $keys = implode(',', array_keys($answeritems)); $values = array_values($answeritems); $step->set_qt_var('_currentresponse', $keys); list($fraction, $state) = $question->grade_response(qtype_ordering_test_helper::get_response($question, $values)); $qa->get_last_step()->set_state($state); $attempt = $qa; } else { $this->start_attempt_at_question($question, 'interactive'); $response = qtype_ordering_test_helper::get_response($question, array_values($answeritems)); $this->process_submission(array_merge(['-submit' => 1], $response)); $attempt = $this->get_question_attempt(); // Omit the numparts as we are not testing it here, and it can be a bit flaky when manually processing an attempt. $this->displayoptions->numpartscorrect = false; } if (!$testoptions['feedback']) { $this->displayoptions->feedback = false; } $renderer = $PAGE->get_renderer('core'); $feedbackobj = new feedback($attempt, $this->displayoptions); $actual = $feedbackobj->export_for_template($renderer); $this->assertEquals($expected, $actual); } /** * Data provider for the test_export_for_template test. * * @return array */ public static function export_for_template_provider(): array { global $CFG; require_once($CFG->dirroot . '/question/type/ordering/question.php'); return [ 'Do not show partial or wrong' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 16 => 'Dynamic', 17 => 'Learning', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, ['rot' => 'horizontal', 'inprogress' => false, 'feedback' => true], [ 'specificfeedback' => 'Well done!', 'numpartscorrect' => [ 'numcorrect' => 5, 'numpartial' => 0, 'numincorrect' => 0, ], 'specificgradedetailfeedback' => [ 'showpartialwrong' => 0, ], 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => false, ], ], ], 'Partially correct question attempt (horizontal layout). Relative to ALL the previous and next items' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, ['rot' => 'horizontal', 'inprogress' => false, 'feedback' => true], [ 'specificfeedback' => 'Parts, but only parts, of your response are correct.', 'numpartscorrect' => [ 'numcorrect' => 4, 'numpartial' => 2, 'numincorrect' => 0, ], 'specificgradedetailfeedback' => [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', 'orderinglayoutclass' => 'horizontal', 'scoredetails' => [ 0 => [ 'score' => 5, 'maxscore' => 5, 'percent' => 100.00, ], 1 => [ 'score' => 5, 'maxscore' => 5, 'percent' => 100.00, ], 2 => [ 'score' => 5, 'maxscore' => 5, 'percent' => 100.00, ], 3 => [ 'score' => 4, 'maxscore' => 5, 'percent' => 80.00, ], 4 => [ 'score' => 4, 'maxscore' => 5, 'percent' => 80.00, ], 5 => [ 'score' => 5, 'maxscore' => 5, 'percent' => 100.00, ], ], 'gradedetails' => 93.0, 'totalscore' => 28, 'totalmaxscore' => 30, ], 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'horizontal', 'correctanswers' => self::CORRECTANSWERS, ], ], ], 'Partially correct question attempt in progress (horizontal layout). Relative to ALL the previous and next ' . 'items with hints' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, ['rot' => 'horizontal', 'inprogress' => true, 'feedback' => true], [ 'specificfeedback' => 'Parts, but only parts, of your response are correct.', 'specificgradedetailfeedback' => [ 'showpartialwrong' => 0, ], 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => false, ], 'hint' => 'This is the first hint.', ], ], 'Partially correct question attempt in progress (No feedback). Relative to ALL the previous and next items' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, ['rot' => 'horizontal', 'inprogress' => false, 'feedback' => false], [ 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'horizontal', 'correctanswers' => self::CORRECTANSWERS, ], 'numpartscorrect' => [ 'numcorrect' => 4, 'numpartial' => 2, 'numincorrect' => 0, ], ], ], 'Incorrect question attempt (horizontal layout). Relative to ALL the previous and next items' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, ['rot' => 'horizontal', 'inprogress' => false, 'feedback' => true], [ 'specificfeedback' => 'Parts, but only parts, of your response are correct.', 'numpartscorrect' => [ 'numcorrect' => 0, 'numpartial' => 6, 'numincorrect' => 0, ], 'specificgradedetailfeedback' => [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', 'orderinglayoutclass' => 'horizontal', 'scoredetails' => [ 0 => [ 'score' => 4, 'maxscore' => 5, 'percent' => 80.00, ], 1 => [ 'score' => 3, 'maxscore' => 5, 'percent' => 60.00, ], 2 => [ 'score' => 3, 'maxscore' => 5, 'percent' => 60.00, ], 3 => [ 'score' => 4, 'maxscore' => 5, 'percent' => 80.0, ], 4 => [ 'score' => 4, 'maxscore' => 5, 'percent' => 80.00, ], 5 => [ 'score' => 2, 'maxscore' => 5, 'percent' => 40.0, ], ], 'gradedetails' => 67.0, 'totalscore' => 20, 'totalmaxscore' => 30, ], 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'horizontal', 'correctanswers' => self::CORRECTANSWERS, ], ], ], 'Incorrect question attempt (vertical layout). Grading type: Relative to the next item (excluding last)' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, ['rot' => 'vertical', 'inprogress' => false, 'feedback' => true], [ 'specificfeedback' => 'Parts, but only parts, of your response are correct.', 'numpartscorrect' => [ 'numcorrect' => 1, 'numpartial' => 0, 'numincorrect' => 4, ], 'specificgradedetailfeedback' => [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to the next item (excluding last)', 'orderinglayoutclass' => 'vertical', 'scoredetails' => [ 0 => [ 'score' => 0, 'maxscore' => 1, 'percent' => 0.0, ], 1 => [ 'score' => 0, 'maxscore' => 1, 'percent' => 0.0, ], 2 => [ 'score' => 0, 'maxscore' => 1, 'percent' => 0.0, ], 3 => [ 'score' => 1, 'maxscore' => 1, 'percent' => 100.0, ], 4 => [ 'score' => 'No score', 'maxscore' => null, 'percent' => 0, ], 5 => [ 'score' => 0, 'maxscore' => 1, 'percent' => 0.0, ], ], 'gradedetails' => 20.0, 'totalscore' => 1, 'totalmaxscore' => 5, ], 'generalfeedback' => '
    The correct answer is ' . '"Modular Object Oriented Dynamic Learning Environment".
    ', 'rightanswer' => [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'vertical', 'correctanswers' => self::CORRECTANSWERS, ], ], ], ]; } } PK\n# 6 6Btype/ordering/tests/output/specific_grade_detail_feedback_test.phpnu[. namespace qtype_ordering\output; use advanced_testcase; use test_question_maker; use qtype_ordering_question; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * A test class used to test specific_grade_detail_feedback. * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\output\renderable_base * @covers \qtype_ordering\output\specific_grade_detail_feedback */ final class specific_grade_detail_feedback_test extends advanced_testcase { /** * Test the exported data for the template that renders the specific grade detail feedback test to a given question attempt. * * @dataProvider export_for_template_provider * @param array $answeritems The array of ordered answers. * @param int $gradingtype Grading type. * @param string $layouttype The type of the layout. * @param array $expected The expected exported data. * @param int $selecttype The type of the select. * @return void */ public function test_export_for_template(array $answeritems, int $gradingtype, string $layouttype, array $expected, int $selecttype): void { global $PAGE; $this->resetAfterTest(); $question = test_question_maker::make_question('ordering'); // Options need to be set before starting the attempt otherwise they are not passed along. $question->layouttype = $layouttype === 'horizontal' ? qtype_ordering_question::LAYOUT_HORIZONTAL : qtype_ordering_question::LAYOUT_VERTICAL; $question->gradingtype = $gradingtype; $question->selecttype = $selecttype; $qa = new \testable_question_attempt($question, 0); $step = new \question_attempt_step(); $qa->add_step($step); $question->start_attempt($step, 1); $keys = implode(',', array_keys($answeritems)); $values = array_values($answeritems); $step->set_qt_var('_currentresponse', $keys); list($fraction, $state) = $question->grade_response(qtype_ordering_test_helper::get_response($question, $values)); $qa->get_last_step()->set_state($state); $renderer = $PAGE->get_renderer('core'); $specificgradedetailfeedback = new specific_grade_detail_feedback($qa); $actual = $specificgradedetailfeedback->export_for_template($renderer); if ($selecttype === qtype_ordering_question::SELECT_ALL) { $this->assertEquals($expected, $actual); } else { // Since the order of the scores is random or contiguous, we need to check the score details separately. $this->assertEquals($expected['showpartialwrong'], $actual['showpartialwrong']); $this->assertEquals($expected['gradingtype'], $actual['gradingtype']); $this->assertEquals($expected['orderinglayoutclass'], $actual['orderinglayoutclass']); // All or nothing grading type does not have score details. if ($gradingtype !== qtype_ordering_question::GRADING_ALL_OR_NOTHING) { $this->assertEquals($expected['totalmaxscore'], $actual['totalmaxscore']); $this->assertArrayHasKey('scoredetails', $actual); } } } /** * Data provider for the test_export_for_template test. * * @return array */ public static function export_for_template_provider(): array { global $CFG; require_once($CFG->dirroot . '/question/type/ordering/question.php'); return [ 'Do not show partial or wrong' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 16 => 'Dynamic', 17 => 'Learning', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, 'horizontal', [ 'showpartialwrong' => false, ], qtype_ordering_question::SELECT_ALL, ], 'Partially correct question attempt (horizontal layout). Relative to ALL the previous and next items' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, 'horizontal', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', 'orderinglayoutclass' => 'horizontal', 'gradedetails' => 93.0, 'totalscore' => 28, 'totalmaxscore' => 30, 'scoredetails' => [ ['score' => 5, 'maxscore' => 5, 'percent' => 100], ['score' => 5, 'maxscore' => 5, 'percent' => 100], ['score' => 5, 'maxscore' => 5, 'percent' => 100], ['score' => 4, 'maxscore' => 5, 'percent' => 80], ['score' => 4, 'maxscore' => 5, 'percent' => 80], ['score' => 5, 'maxscore' => 5, 'percent' => 100], ], ], qtype_ordering_question::SELECT_ALL, ], 'Incorrect question attempt (horizontal layout). Relative to ALL the previous and next items' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, 'horizontal', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', 'orderinglayoutclass' => 'horizontal', 'gradedetails' => 67.0, 'totalscore' => 20, 'totalmaxscore' => 30, 'scoredetails' => [ ['score' => 4, 'maxscore' => 5, 'percent' => 80], ['score' => 3, 'maxscore' => 5, 'percent' => 60], ['score' => 3, 'maxscore' => 5, 'percent' => 60], ['score' => 4, 'maxscore' => 5, 'percent' => 80], ['score' => 4, 'maxscore' => 5, 'percent' => 80], ['score' => 2, 'maxscore' => 5, 'percent' => 40], ], ], qtype_ordering_question::SELECT_ALL, ], 'Incorrect question attempt (vertical layout). Grading type: Relative to the next item (excluding last)' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, 'vertical', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Relative to the next item (excluding last)', 'orderinglayoutclass' => 'vertical', 'gradedetails' => 20.0, 'totalscore' => 1, 'totalmaxscore' => 5, 'scoredetails' => [ ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ], ], qtype_ordering_question::SELECT_ALL, ], 'Incorrect question attempt (vertical layout). Grading type: GRADING_LONGEST_ORDERED_SUBSET' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_LONGEST_ORDERED_SUBSET, 'vertical', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Longest ordered subset', 'orderinglayoutclass' => 'vertical', 'gradedetails' => 67.0, 'totalscore' => 4, 'totalmaxscore' => 6, 'scoredetails' => [ ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ], ], qtype_ordering_question::SELECT_ALL, ], 'Incorrect question attempt (SELECT_RANDOM). Grading type: GRADING_ABSOLUTE_POSITION' => [ [16 => 'Dynamic', 14 => 'Object', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_ABSOLUTE_POSITION, 'vertical', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Absolute position', 'orderinglayoutclass' => 'vertical', 'gradedetails' => 0, 'totalscore' => 0, 'totalmaxscore' => 2, 'scoredetails' => [ ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ], ], qtype_ordering_question::SELECT_RANDOM, ], 'Incorrect question attempt (SELECT_CONTIGUOUS). Grading type: GRADING_ABSOLUTE_POSITION' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_ABSOLUTE_POSITION, 'vertical', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: Absolute position', 'orderinglayoutclass' => 'vertical', 'gradedetails' => 0, 'totalscore' => 0, 'totalmaxscore' => 2, 'scoredetails' => [ ['score' => 0, 'maxscore' => 1, 'percent' => 0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ], ], qtype_ordering_question::SELECT_CONTIGUOUS, ], 'Incorrect question attempt (SELECT_CONTIGUOUS). Grading type: GRADING_ALL_OR_NOTHING' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_ALL_OR_NOTHING, 'vertical', [ 'showpartialwrong' => 1, 'gradingtype' => 'Grading type: All or nothing', 'orderinglayoutclass' => 'vertical', 'gradedetails' => 0, 'totalscore' => 0, 'totalmaxscore' => 2, 'scoredetails' => [ ['score' => 0, 'maxscore' => 1, 'percent' => 0], ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], ['score' => 0, 'maxscore' => 1, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ['score' => 'No score', 'maxscore' => null, 'percent' => 0], ], ], qtype_ordering_question::SELECT_CONTIGUOUS, ], ]; } } PK\77<type/ordering/tests/output/formulation_and_controls_test.phpnu[. namespace qtype_ordering\output; use advanced_testcase; use question_display_options; use test_question_maker; use qtype_ordering_question; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ordering/question.php'); /** * A test class used to test formulation_and_controls. * * @package qtype_ordering * @copyright 2023 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\output\renderable_base * @covers \qtype_ordering\output\formulation_and_controls * @covers \qtype_ordering_renderer::feedback_image */ final class formulation_and_controls_test extends advanced_testcase { /** * Test the exported data for the template that renders the formulation and controls for a given question. * * @dataProvider export_for_template_provider * @param array $answeritems The array of ordered answers. * @param int $gradingtype Grading type. * @param string $layouttype The type of the layout. * @param array $expected The expected exported data. * @return void */ public function test_export_for_template(array $answeritems, int $gradingtype, string $layouttype, array $expected): void { global $OUTPUT, $PAGE; $correct = $OUTPUT->pix_icon('i/grade_correct', get_string('correct', 'question')); $partiallycorrect = $OUTPUT->pix_icon('i/grade_partiallycorrect', get_string('partiallycorrect', 'question')); $incorrect = $OUTPUT->pix_icon('i/grade_incorrect', get_string('incorrect', 'question')); foreach ($expected['answers'] as $key => $answer) { if (array_key_exists('feedbackimage', $expected['answers'][$key])) { $expected['answers'][$key]['feedbackimage'] = match ($expected['answers'][$key]['feedbackimage']) { 'correct' => $correct, 'partiallycorrect' => $partiallycorrect, 'incorrect' => $incorrect, default => null }; } } $question = test_question_maker::make_question('ordering'); $question->layouttype = $layouttype === 'horizontal' ? qtype_ordering_question::LAYOUT_HORIZONTAL : qtype_ordering_question::LAYOUT_VERTICAL; $qa = new \testable_question_attempt($question, 0); $step = new \question_attempt_step(); $qa->add_step($step); $question->start_attempt($step, 1); $options = new question_display_options(); $options->feedback = question_display_options::VISIBLE; $options->numpartscorrect = question_display_options::VISIBLE; $options->generalfeedback = question_display_options::VISIBLE; $options->rightanswer = question_display_options::VISIBLE; $options->manualcomment = question_display_options::VISIBLE; $options->history = question_display_options::VISIBLE; $question->gradingtype = $gradingtype; $keys = implode(',', array_keys($answeritems)); $values = array_values($answeritems); $step->set_qt_var('_currentresponse', $keys); [$fraction, $state] = $question->grade_response(qtype_ordering_test_helper::get_response($question, $values)); // Force the state to be complete if it is graded correct for testing purposes. if ($state === \question_state::$gradedright) { $state = \question_state::$complete; } $qa->get_last_step()->set_state($state); $renderer = $PAGE->get_renderer('core'); $formulationandcontrols = new formulation_and_controls($qa, $options); $actual = $formulationandcontrols->export_for_template($renderer); $this->assertEquals($expected, $actual); } /** * Data provider for the test_export_for_template test. * * @return array */ public static function export_for_template_provider(): array { return [ 'Horizontal, correct and partially correct' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, 'horizontal', [ 'readonly' => false, 'questiontext' => '
    Put these words in order
    ', 'responsename' => 'q0:_response_0', 'responseid' => 'id_q0_response_0', 'value' => 'ordering_item_ac5fc041de63c8c5b34d0aabb96cf33d,' . 'ordering_item_497031794414a552435f90151ac3b54b,' . 'ordering_item_5a35edab0f2bf86dfa3901baa8c235dc,' . 'ordering_item_8af0f5c3edad8d8e158ff27b9f03afac,' . 'ordering_item_971fd8cc345d8bd9f92e9f7d88fdf20c,' . 'ordering_item_0ba29c6a1afacf586b03a26162c72274', 'ablockid' => 'id_ablock_0', 'layoutclass' => 'horizontal', 'numberingstyle' => 'none', 'horizontallayout' => true, 'active' => false, 'sortableid' => 'id_sortable_0', 'answers' => [ [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Modular'), 'answertext' => "Modular", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Object'), 'answertext' => "Object", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Oriented'), 'answertext' => "Oriented", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'partial66', 'id' => 'ordering_item_' . md5('Learning'), 'answertext' => "Learning", 'feedbackimage' => 'partiallycorrect', ], [ 'scoreclass' => 'partial66', 'id' => 'ordering_item_' . md5('Dynamic'), 'answertext' => "Dynamic", 'feedbackimage' => 'partiallycorrect', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Environment'), 'answertext' => "Environment", 'feedbackimage' => 'correct', ], ], ], ], 'Vertical, incorrect' => [ [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], qtype_ordering_question::GRADING_ABSOLUTE_POSITION, 'vertical', [ 'readonly' => false, 'questiontext' => '
    Put these words in order
    ', 'responsename' => 'q0:_response_0', 'responseid' => 'id_q0_response_0', 'value' => 'ordering_item_497031794414a552435f90151ac3b54b,' . 'ordering_item_971fd8cc345d8bd9f92e9f7d88fdf20c,' . 'ordering_item_ac5fc041de63c8c5b34d0aabb96cf33d,' . 'ordering_item_8af0f5c3edad8d8e158ff27b9f03afac,' . 'ordering_item_0ba29c6a1afacf586b03a26162c72274,' . 'ordering_item_5a35edab0f2bf86dfa3901baa8c235dc', 'ablockid' => 'id_ablock_0', 'layoutclass' => 'vertical', 'numberingstyle' => 'none', 'horizontallayout' => false, 'active' => false, 'sortableid' => 'id_sortable_0', 'answers' => [ [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Object'), 'answertext' => "Object", 'feedbackimage' => 'incorrect', ], [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Dynamic'), 'answertext' => "Dynamic", 'feedbackimage' => 'incorrect', ], [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Modular'), 'answertext' => "Modular", 'feedbackimage' => 'incorrect', ], [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Learning'), 'answertext' => "Learning", 'feedbackimage' => 'incorrect', ], [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Environment'), 'answertext' => "Environment", 'feedbackimage' => 'incorrect', ], [ 'scoreclass' => 'incorrect', 'id' => 'ordering_item_' . md5('Oriented'), 'answertext' => "Oriented", 'feedbackimage' => 'incorrect', ], ], ], ], 'Horizontal, correct and complete' => [ [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 16 => 'Dynamic', 17 => 'Learning', 18 => 'Environment'], qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, 'horizontal', [ 'readonly' => false, 'questiontext' => '
    Put these words in order
    ', 'responsename' => 'q0:_response_0', 'responseid' => 'id_q0_response_0', 'value' => 'ordering_item_ac5fc041de63c8c5b34d0aabb96cf33d,' . 'ordering_item_497031794414a552435f90151ac3b54b,' . 'ordering_item_5a35edab0f2bf86dfa3901baa8c235dc,' . 'ordering_item_971fd8cc345d8bd9f92e9f7d88fdf20c,' . 'ordering_item_8af0f5c3edad8d8e158ff27b9f03afac,' . 'ordering_item_0ba29c6a1afacf586b03a26162c72274', 'ablockid' => 'id_ablock_0', 'layoutclass' => 'horizontal', 'numberingstyle' => 'none', 'horizontallayout' => true, 'active' => true, 'sortableid' => 'id_sortable_0', 'answers' => [ [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Modular'), 'answertext' => "Modular", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Object'), 'answertext' => "Object", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Oriented'), 'answertext' => "Oriented", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Dynamic'), 'answertext' => "Dynamic", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Learning'), 'answertext' => "Learning", 'feedbackimage' => 'correct', ], [ 'scoreclass' => 'correct', 'id' => 'ordering_item_' . md5('Environment'), 'answertext' => "Environment", 'feedbackimage' => 'correct', ], ], ], ], ]; } } PK\N=4type/ordering/tests/output/correct_response_test.phpnu[. namespace qtype_ordering\output; use advanced_testcase; use test_question_maker; use qtype_ordering_question; use qtype_ordering_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * A test class used to test correct_response. * * @package qtype_ordering * @copyright 2023 Mihail Geshoski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ordering\output\renderable_base * @covers \qtype_ordering\output\correct_response */ final class correct_response_test extends advanced_testcase { /** * Test the exported data for the template that renders the correct response to a given question attempt. * * @dataProvider export_for_template_provider * @param array $currentresponse The array of items representing the current response. * @param string $layouttype The type of the layout. * @param array $expected The expected exported data. * @return void */ public function test_export_for_template(array $currentresponse, string $layouttype, array $expected): void { global $PAGE; $question = test_question_maker::make_question('ordering'); // Set the grading type and layout type options. $question->gradingtype = qtype_ordering_question::GRADING_ABSOLUTE_POSITION; $question->layouttype = $layouttype === 'horizontal' ? qtype_ordering_question::LAYOUT_HORIZONTAL : qtype_ordering_question::LAYOUT_VERTICAL; // Create a question attempt. $qa = new \testable_question_attempt($question, 0); // Create a question attempt step and add it to the question attempt. $step = new \question_attempt_step(); $qa->add_step($step); $question->start_attempt($qa->get_last_step(), 1); // Get the grading state based on the correct response and the current response, and later set it in the question // attempt step. [$fraction, $state] = $question->grade_response(qtype_ordering_test_helper::get_response($question, $currentresponse)); $qa->get_last_step()->set_state($state); if ($expected['hascorrectresponse'] === false) { $qa->get_question()->correctresponse = 0; } $renderer = $PAGE->get_renderer('core'); $correctresponse = new correct_response($qa); // Validate the exported data for the template. $this->assertEquals($expected, $correctresponse->export_for_template($renderer)); } /** * Data provider for the test_export_for_template test. * * @return array */ public static function export_for_template_provider(): array { return [ 'Correct question attempt.' => [ ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'], 'horizontal', [ 'hascorrectresponse' => true, 'showcorrect' => false, ], ], 'Partially correct question attempt (horizontal layout).' => [ ['Modular', 'Object', 'Dynamic', 'Learning', 'Oriented', 'Environment'], 'horizontal', [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'horizontal', 'correctanswers' => [ ['answertext' => 'Modular'], ['answertext' => 'Object'], ['answertext' => 'Oriented'], ['answertext' => 'Dynamic'], ['answertext' => 'Learning'], ['answertext' => 'Environment'], ], ], ], 'Incorrect question attempt (horizontal layout).' => [ ['Object', 'Dynamic', 'Modular', 'Learning', 'Environment', 'Oriented'], 'horizontal', [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'horizontal', 'correctanswers' => [ ['answertext' => 'Modular'], ['answertext' => 'Object'], ['answertext' => 'Oriented'], ['answertext' => 'Dynamic'], ['answertext' => 'Learning'], ['answertext' => 'Environment'], ], ], ], 'Incorrect question attempt (vertical layout).' => [ ['Object', 'Dynamic', 'Modular', 'Learning', 'Environment', 'Oriented'], 'vertical', [ 'hascorrectresponse' => true, 'showcorrect' => true, 'orderinglayoutclass' => 'vertical', 'correctanswers' => [ ['answertext' => 'Modular'], ['answertext' => 'Object'], ['answertext' => 'Oriented'], ['answertext' => 'Dynamic'], ['answertext' => 'Learning'], ['answertext' => 'Environment'], ], ], ], 'Correct state not set somehow' => [ ['Modular', 'Object', 'Oriented', 'Dynamic', 'Learning', 'Environment'], 'horizontal', [ 'hascorrectresponse' => false, ], ], ]; } } PK\TVGG2type/ordering/tests/behat/behat_qtype_ordering.phpnu[. // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Behat steps definitions for the ordering question type. * * @package qtype_ordering * @category test * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qtype_ordering extends behat_base { /** * Get the xpath for a given item by label. * @param string $label the text of the item to drag. * @return string the xpath expression. */ protected function item_xpath_by_label(string $label): string { return "//li[contains(concat(' ', normalize-space(@class), ' '), 'sortableitem') and contains(normalize-space(.), '" . $this->escape($label) . "')]"; } /** * Get the xpath for a given drop box. * @param string $position the number of place to drop it. * @return string the xpath expression. */ protected function item_xpath_by_position(string $position): string { return "//li[contains(concat(' ', normalize-space(@class), ' '), 'sortableitem')][" . $position . "]"; } /** * Drag the drag item with the given text to the given space. * * Warning, only works if the question is using a behaviour like Immediate * feedback that has a check button. * * Also, do not use this to drag an item to the last place. Just drag all * the other non-last items to their place. * * @param string $label the text of the item to drag. * @param int $position the number of the position to drop it at. * * @Given /^I drag "(?P
  • The abyssal plain is a flat almost featureless expanse of ocean floor 4km to 6km below sea-level.
  • The continental rise is the gently sloping part of the ocean floor beyond the continental slope.
  • The continental shelf is the gently sloping ocean floor just offshore from the land.
  • The continental slope is the relatively steep part of the ocean floor beyond the continental shelf.
  • A mid-ocean ridge is a broad submarine ridge several kilometres high.
  • A mountain belt is a long range of mountains.
  • An island arc is a chain of volcanic islands.
  • An oceanic trench is a deep trough in the ocean floor.
  • ]]> Incorrect placements will be removed.

    ]]>
  • The abyssal plain is a flat almost featureless expanse of ocean floor 4km to 6km below sea-level.
  • The continental rise is the gently sloping part of the ocean floor beyond the continental slope.
  • The continental shelf is the gently sloping ocean floor just offshore from the land.
  • The continental slope is the relatively steep part of the ocean floor beyond the continental shelf.
  • A mid-ocean ridge is a broad submarine ridge several kilometres high.
  • A mountain belt is a long range of mountains.
  • An island arc is a chain of volcanic islands.
  • An oceanic trench is a deep trough in the ocean floor.
  • ]]>
    PK\gfafa6type/ddimageortext/tests/fixtures/oceanflooranswer.jpgnu[JFIFHHC  !"$"$CQ" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?W>y+Z^uyL (0QqVg~(&.>?FsbW]]V9nr?aC>C1sQE]~(&.>P?L\}uQdg#8G O??q?]uY>b <$H\?_nk(xh/#&>@d_01ܧ y8a3q.e9Y"a1.O|v  \y?q"iq 3LAIBNYJ#tzh[QaI?ydžw&}XVKTvy }?/ '~'XCn?>C_[5_h,L#Y@Rc.;ψ,bid;18U>ҝ?Y9`r~(w3)O??q?\SWRĘoX\| )ROV*Kot'cVӐH`v 玸`TiN9eVJkR~(&.>P?L\}uVF-r?aC>C1sQE]~(&.>P?L\}uQdgx]LJo_Mw{A+"1nUA1 7/}{Wl[7bޱ:eze)Y1?xr׿xr׿{kVyȱƭ$ T d˛{;In" +DP2Y;~5|jG#-N֔8+aC>C1sQ[Y]~(&.>P?L\}uQdg#8G O??q?]uY>b|#']EAvr?aC>C1sQE]~(&.>P?L\}uQdg#8G O??q?]uY>b|#']EAvr?aC>C1sQE]~(&.>P?L\}uQdg#8G O??q?]uY>b|#']EAvr?aC>C1sQE]~(&.>P?L\}uQdg#8G O??q?]uY>b|#']EAvr?aC>C1sQE]~(&.>P?L\}uQdg#8G O??q?]uY>b|#']EAvr?aCΤGKѥjYjp8vՏH?[Jꠏ=GvSz3pK`+C0(-oXҝ2wN Zhl9s*䨈 #Fyo -oXҝ2csgsn=Znrʃҹ4ߺpֿ&v -nLKDangb_0uQ2͚vznnt뙼Ot 5WI6knaD&=j2Ő^qW:徻qj}%5KʲGl~O'3E\| $rw6z^+Z.Q]??޶+\|;i栒M ,Vh @#kg_U5/u|.KvZws$2,YB<8=–t4m%Y8:^t.EΝi0[y ؍>e<|֓5;JQkoOyIӴ;OS%^k&]>K I>$X +ڹ]R2YU> xvT ZX떲&[ y aQ ܠ/AL؊#]oVMJEx入֋s75ڧu]6STPڣ^ƪbLHd`0W]aRHbWSvSUn5k[OH[[-$p0~U;zI^ $M e8=1q\ӫf{X˺>7 Ep [흿u[))5ϴ|ÂgPހI?.JBQEDoz%-SWW=Or.5t(;{%-#v+n.r_*@kzFikZedf95)0PM'W[ڴWVP$xe ̠aJ74+:-I%Vz`zZ<ҬU#EHۏ_4$K=2- 6 Ƙ*>Q*3z4y"ixFd1d|ڿwhoy?iW2|.5^iY+q TnKvRpze_7]owl7uJD椒])˙%oWD_?;ZWm\ObiP!mErQ@Q@Q@Q@Q@g؏"B:Kմ.OҬﴙo$%BKؒXWCto)뽬%&b8/G'%naG#t?tB//lkM}$n#*P:Jyw+v9OG'%naG#t?MGu=2T|E^YUսrEѹ>*$gĚ8sGlۏ7v߽?;O+s ?CtW>0𕮗s >3,7R_İȁK+FAwIbYbuxC+)`z{|X'#t?o|I]Fյ]>{x|=qo#%6VCzHV+ԫ"UZ(SwG !?&']F2:~zwG]CW,KB(/LƘ/n*K]aN/3O0)Ĩ;ʹtzVxY7VO4*O28mGRϖqTL{_hbٮ,#&1n1;a Sanғu;ooM^_rxOWYzQnJbY3|YIUb6pG5o~K/wmY$tXy0yЈ T%Hc;3pZ핼+ c Dtesuk+xRMTi[#c[6)C 5'oa%(L|"M~O-nMSi{q*Eb.\5xkB:5֯9J.Hާ?g2[[moslIa Q$RG#G"6T*H8$sZ]_`;bM:}z%y߃uc?i|'SE3?.k{ ) e8NTX$m2 x9esǼ?.(}q=Oz.+x#Ju*Ʃbu Ӗ\0VcoPx}x#Ju*QEq5'~8ڇ]uhfҒ;nsW<a[6:+okJ_0 NQE(Wx25xrmzOs}KeSxR& r~(o l|gk:IZ[6qP;,`o$*, yJH 2 >;;3ײHV&kn*2>MZ{M6 QEH?[JqxŽGI \OC}E8գrci  Q]&,p}k-`&u#,)u/ Jꮑ}ō-|=[)E^j(;p>H^W(;sZ8ٜ˩gx߫^_&:)zQzW}VMoտ/}/GuPԤz?G^q[ߒ4}VMCRyo ~K[ߒ4gUJGoտ/}/Go ~KT?)zQF%h~?=0K:jRuGN,4ۋIn$T U 1v##=_r׷0<BiPP 086o5_j7\qg ɐ]"$@^`=}o^i&k[˨#[fđLGO~ hW7uד".kgqi4~WUփY RCeR2*YHv 5/Z2hIIbE\4W!Qyl!>I,dTopǭ[Yyw4p[K,0UDQĞg&4jZWD65jBx Ap' Z7c ^·~Kmyr-L"f9T1] Ŗ$f=洷w/rK갢yBhT*$UBIs9v[xW/%tJ66x|k}t>~$ݦ\[i}4WZ:(oO$ M->;]u=.X.^dK 6,*d9ZGi7.x&0mIv8?jeKQ_FmXVr*@JF3=F> II H9XpAnR̈́{''gV1hvڸ>nk1V^-pELU䑜ݢsOط4u׆x~? ᫿6Ξob;s8z<5D]Q#9~tzy-N=HjX=Iu?ilJp2Iy_>8-e"ل'Jѱ<Ү¢A5ڷ#)t\GFizuikE}-̐2#/-KIl#Qku05ž3M¢AGE i\UzÃ?DeyW[mǝ;˷=7=:Vy/,Ћ*'?dx_ Q? <%1< nh(ӧO,Ћ*'_Ɛ_@ TܭٵAfgVϫݢǣK yC 5H G*[ 1񓳤h/GT\j.o'Q ¾::bh/GUV.Vz+*GuEW9pxCI䮺?FsbW]Z1! ;7WBwro+F}H( znnڮi uCF@`FpHϹtP \/)?.Ч/&!~OAt_M;=5EϸrG_S]G!~OAt_MoQG3`E_S][MГOB(iSNno}B]2,U}Bh[J?bNO{Z̋4Uo_Э?%u1G/V=?_yf&> S #gXwmG8pir%%tH <|8 WVžsP Hwϻ`VƳqC-snkc#pXC{xВC`( gNx zn|\ÝVL^qŔh'X-ʓ +s <_cCǘ*GFn jW>~GhS[{CL_ ZOSG={24ygVh_,hϯjג<ٕ|754ioM-|gRx뗆[⶷LM,Qg9b@`d ךjZ2kcD(Q#r0 |thzzf],MUk8#-8P"dvy4[1:O*bt{.WBjs=:z=sjJgʜgO5c>^é|A%qLJyx3\0#<E{|ŚoY5uXm;=,ϥ\ǽn/7#|eF~-4.8-WOv^Ku<IaocK:+<nR>ۭ2wyrnUe*'`zOw߃矊[~ ~*KUjJWVYQGe?5뫻/ i}5my-c&0s;!.CJ(>in;C5IWdwarG dF>_,z~՝}m Udۮ wXݰ(*Cpv/CnYZb8I``ϸJBۚ$oycҝ]ZjVwCsCD pA> Ȉ02O#ЏhEꙜ4UJO,zU![ҫfXJO!{A{4^zԤ?j-'KG]?RB,ZOػN^1ޫgeQEz'H׎^?J뫑$k?/jO%uաӿ1\w!&b ?g`(((((r/i?7P&oJBg.3GQ\,wڥuk|[gjXuxo33s)T!4^m_O$H5vIm$\d`_4}Y;) Q㻥cm[sj${,&I! `W{>Zo⛻| uNƖGWw_u%Pֵ{?maK'+uǚ#ݸ_5Kj2֮t]ѥ{=Q&;nXEY6K`!\Z"yzEwsmiuqap0U\w$ k7V7i<_\v[2yjF U(~wCk77uζu[i5b;n"-TTt ~Kkӿ _Mq8?޽ YYˁ\G `bt` Op+ƛGյe'^Q67inaFo}q ;n~[:Vs9yVe]܀*Z) >|ɀl(#ϻHGu]+LӴ6֑Q*>qvɫc ~c혺o[eԯ;o/d2Ȭ{(mpNA?ZL=O$qSo43m$n*A0_ x\O ԣie#eeYw`+N?xV(.!C1#ӿxSk'WZ1D~m^ɞ&ZXa+9u9Cop-OG1xPuu#&!UG'#m@8ָy{Ǔ7l 2(Mgj?2{\FnGqp v iJy_އ3-rW Β0`BTK.$1`EL1=3:])o`|cC̿^&"5+JlFF<3/?Q~+ *-a1I4Vװ^ r>\85lp6_^(.>J|32ʙqz~k^,ӣ$j7~[a3.eȌq52kG,I=Iu[ L7zU&*<ޥ_ wڛMBaԃєӂ9!?#O@q~^376y=}ffɷF=gM=3gN*Tٔ^VHH=9uXq%k$DǨY0f?<0񟊤 JWW-LƔ;)U-8<כc_'ό|actOTj}NWNGI1uWc'#е[[jwldӥ$<<.0Ny٧J9J nq='OiP@0%䑰O>%YˁnEВ8X17ٿS,i[ڊc:gxCsVs=bGp+5gYWiLQt ˀyB6a{?*M<+o"T ?x3VErժ{|-.<c6fJv N~]1䎔Y k̸yhN'$Ė$׼§YD"(O?EmW#/~O<&ݮm.MΛ}sc+d1>VL>d`U)RSI%>ܰ?]z*M<+o"T ?x3VEtRZ[HF}e }N>w%Ef4~P[> _ UO?EmQ gOdRzhe99\ހ^k5;vO|9Ciְ$*[Nf! eW$pI洓~* Wŝ϶ctZi#ZL|h/GUĺ}LHŶ9X8j?Z2Vu u\nM6}Zv[IjqF3psD:>ǫ\GM|.$N>VW{JҢ&%(WKCl֑ ]CD!ڹQ=U4 . m[DU;W|WZxĚ||Y-'gyP[BcVy ps<[+qiiAgeYZ<[J34sxw\Ƿ7Z'LJ84(쩘3 gqҢ|-6m4hvq*b0lŋLŌcƯKOia✵+9F ڇ((>3DUZw"Wk8nk#-Ti9O)P(Xml|%4Ӯ"ck%gӶV6mk5Io.퓳ƌv!FHK{Z38Q6= xFҏQV_PiDD^weY6_/c{}yo\{+/1tokM*j k<2Z*a3gsۦ*]% տn\MpIOCʝ'OV$^&ԓ1ZU'48S_џXEN ![4Gf.v˄(26ww>9eVkhH7*FM|nQ mmWrf%h=ϒ$q=WƒrH* 7Ox}Ιukaa]QhhrҷC#(F0IJO:dIb\R#bc%A}Ff 5v 'vxg:O5Q[;9 2r_'ux&W Chi6@y.0$Ls#I= {/+^5?XI5C1JƾNd?q\kizȗh^ Jcj#zGG/7Ј$0k/m㺲q)Lc$}+|U8RQ%xnaZx90?6?u+;r4Fh-b"0tysOPVbԯ@>A H]kw,&^)eZV* P-Bqx"2#dG9.,NXbN%9^3Te`CO#==/|g׿4Q^->ZH_4@O&\SN'yRzŽUjF>3/*E5}F5BǛ2erO1tXaM> T6vU;qrNAS fo]|vߧn>R^xm$\K[xn5hC;vyn`69'h9v7V4Hno4{xZo:7sI"a+ *7^^]AR5+F]\^y<1}u5N[Hn$xĒHIeYF\Cw[ӽ~:m'Uv;-4ԭ!iLAC6D)iaa;@km/th[(,,3-Dlf|7VWeγ:Locsph`yhy@[-8l5?xPY`+^١IH#%ыtb@'n%>(6&iH:าfr$[%p6<60{Or@MBY\,OI |.܄UIdf~!e5[?dHɖobX*a +oEa%$A;ar:|o;Y[x¶m}8J+m*k̫٠Iznucƞt-%wHҝJ2דi]F%Ŀ.SWN= >3«Q}UNTn<9z\h:\ #\|ǡ}i|7NVfwGl ZLycd'^jOxGBD=;÷Z.43Hʑ*ǐeɒ~QU'ÞlfӏCo5GR4̒CXZK;'evTm9<#.$B/H b?߲/9suZzsjǑlK;E K0(ı[muv%BXڃjcU䆥+[+[9MO޳٣ɷ_բ3y\ABIbAFm4{L>4{s]* ŋ}̸%9'φ,Eq<;d hdM'awkcW6IU JRWpKqYx=[]a S̱䄅 0/'?^ִV{`ijW7=4༄9-mkhk~iKnIokFAoo]% տn\My0 u!?ɣެk {MN6?VQ^f_|;i߈57H F| N=iW*ė:EA pDDU>r=yO'rOZ}*]I\(YQYtQEQEIuƯ~">SWنٞn;AMFόHF5+Co_^KM;_)-buKhW+Uc.$ aGkgXKcCeA44ܠo5Rw_Mdw=&d@*\c'ּ -^{j fIAo3%>TXT`ʋRʌCQ`\CQ*,QK*0}EJ)p}F ^13Ɵ27Xךʶ>K+S]CWO`ֆ2d͝0h_qs[iKS($>?^_O|iG/[/'4Kxך{ON?z=]/ie?\~zw% ?=;pSVdѿP|`[Fև?7c]^cRz?w:{ K0}FQsw:{ Kty[Ho5N>)o6(2YN*0}QR:2+%>U%>TXT`ʋ J)p}F E.??zwLJ-'>i-vWv23Ak?>¦GA ;|Mw?37?\?Wr>KAhO> &?*0}Gp.A ;|Mw?37?\?}괻,gn6Y#ms>T{iخkZηY\_is/ZmPUi$?vI-Ԟ=.fvl#9#5y7i?ZW+KMW+KMW+KMW+KMW+KMW+KMW+KMӓR8{VFHP@2p02ƴQVFrΒvPK\^%|x3x3*type/ddimageortext/tests/question_test.phpnu[. namespace qtype_ddimageortext; use question_attempt_step; use question_classified_response; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddimageortext/tests/helper.php'); /** * Unit tests for the matching question definition class. * * @package qtype_ddimageortext * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers qtype_ddimageortext_question */ final class question_test extends \basic_testcase { public function test_get_question_summary(): void { $dd = \test_question_maker::make_question('ddimageortext'); $this->assertEquals('The quick brown fox jumped over the lazy dog.; '. '[[Drop zone 1]] -> {1. quick / 2. fox}; '. '[[Drop zone 2]] -> {1. quick / 2. fox}; '. '[[Drop zone 3]] -> {3. lazy / 4. dog}; '. '[[Drop zone 4]] -> {3. lazy / 4. dog}', $dd->get_question_summary()); } public function test_get_question_summary_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $this->assertEquals('Fill in the operators to make this equation work: '. '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3; '. '[[Drop zone 1]] -> {1. + / 2. -}; '. '[[Drop zone 2]] -> {1. + / 2. -}; '. '[[Drop zone 3]] -> {1. + / 2. -}; '. '[[Drop zone 4]] -> {1. + / 2. -}', $dd->get_question_summary()); } public function test_summarise_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals('Drop zone 1 -> {1. quick} '. 'Drop zone 2 -> {1. quick} '. 'Drop zone 3 -> {3. lazy} '. 'Drop zone 4 -> {3. lazy}', $dd->summarise_response(array('p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'))); } public function test_summarise_response_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals('Drop zone 1 -> {1. +} '. 'Drop zone 2 -> {2. -} '. 'Drop zone 3 -> {1. +} '. 'Drop zone 4 -> {2. -}', $dd->summarise_response(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'))); } public function test_get_random_guess_score(): void { $dd = \test_question_maker::make_question('ddimageortext'); $this->assertEquals(0.5, $dd->get_random_guess_score()); } public function test_get_random_guess_score_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $this->assertEquals(0.5, $dd->get_random_guess_score()); } public function test_get_random_guess_score_broken_question(): void { // Before MDL-76298 was fixed, it was possible to create a question with // no drop zones, and that caused a fatal division by zero error. Because // people might have questions like that in their database, we need to // check the calculation is robust to it. /** @var \qtype_ddimageortext_question $dd */ $dd = \test_question_maker::make_question('ddimageortext'); $dd->places = []; $this->assertNull($dd->get_random_guess_score()); } public function test_get_right_choice_for(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(1, $dd->get_right_choice_for(1)); $this->assertEquals(2, $dd->get_right_choice_for(2)); } public function test_get_right_choice_for_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(1, $dd->get_right_choice_for(1)); $this->assertEquals(2, $dd->get_right_choice_for(2)); $this->assertEquals(1, $dd->get_right_choice_for(3)); $this->assertEquals(2, $dd->get_right_choice_for(4)); } public function test_clear_wrong_from_response(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $initialresponse = array('p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'); $this->assertEquals(array('p1' => '1', 'p2' => '', 'p3' => '1', 'p4' => ''), $dd->clear_wrong_from_response($initialresponse)); } public function test_get_num_parts_right(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(2, 4), $dd->get_num_parts_right(array('p1' => '1', 'p2' => '1', 'p3' => '2', 'p4' => '2'))); $this->assertEquals(array(4, 4), $dd->get_num_parts_right(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'))); } public function test_get_num_parts_right_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(2, 4), $dd->get_num_parts_right(array( 'p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'))); } public function test_get_expected_data(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals( array('p1' => PARAM_INT, 'p2' => PARAM_INT, 'p3' => PARAM_INT, 'p4' => PARAM_INT), $dd->get_expected_data() ); } public function test_get_correct_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'), $dd->get_correct_response()); } public function test_get_correct_response_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'), $dd->get_correct_response()); } public function test_is_same_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertTrue($dd->is_same_response( array(), array('p1' => '', 'p2' => '', 'p3' => '', 'p4' => ''))); $this->assertFalse($dd->is_same_response( array(), array('p1' => '1', 'p2' => '', 'p3' => '', 'p4' => ''))); $this->assertFalse($dd->is_same_response( array('p1' => '', 'p2' => '', 'p3' => '', 'p4' => ''), array('p1' => '1', 'p2' => '', 'p3' => '', 'p4' => ''))); $this->assertTrue($dd->is_same_response( array('p1' => '1', 'p2' => '2', 'p3' => '3', 'p4' => '4'), array('p1' => '1', 'p2' => '2', 'p3' => '3', 'p4' => '4'))); $this->assertFalse($dd->is_same_response( array('p1' => '1', 'p2' => '2', 'p3' => '3', 'p4' => '4'), array('p1' => '1', 'p2' => '2', 'p3' => '2', 'p4' => '4'))); } public function test_is_complete_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertFalse($dd->is_complete_response(array())); $this->assertFalse($dd->is_complete_response( array('p1' => '1', 'p2' => '1', 'p3' => '', 'p4' => ''))); $this->assertFalse($dd->is_complete_response(array('p1' => '1'))); $this->assertTrue($dd->is_complete_response( array('p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'))); } public function test_is_gradable_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertFalse($dd->is_gradable_response(array())); $this->assertFalse($dd->is_gradable_response( array('p1' => '', 'p2' => '', 'p3' => ''))); $this->assertTrue($dd->is_gradable_response( array('p1' => '1', 'p2' => '1', 'p3' => ''))); $this->assertTrue($dd->is_gradable_response(array('p1' => '1'))); $this->assertTrue($dd->is_gradable_response( array('p1' => '1', 'p2' => '1', 'p3' => '1'))); } public function test_grading(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(1, question_state::$gradedright), $dd->grade_response(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'))); $this->assertEquals(array(0.25, question_state::$gradedpartial), $dd->grade_response(array('p1' => '1'))); $this->assertEquals(array(0, question_state::$gradedwrong), $dd->grade_response(array('p1' => '2', 'p2' => '1', 'p3' => '2', 'p4' => '1'))); } public function test_grading_maths(): void { $dd = \test_question_maker::make_question('ddimageortext', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(1, question_state::$gradedright), $dd->grade_response(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'))); $this->assertEquals(array(0.5, question_state::$gradedpartial), $dd->grade_response(array('p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'))); $this->assertEquals(array(0, question_state::$gradedwrong), $dd->grade_response(array('p1' => '', 'p2' => '1', 'p3' => '2', 'p4' => '1'))); } public function test_classify_response(): void { $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array( 1 => new question_classified_response(1, '1. quick', 1), 2 => new question_classified_response(2, '2. fox', 1), 3 => new question_classified_response(3, '3. lazy', 1), 4 => new question_classified_response(4, '4. dog', 1) ), $dd->classify_response(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2'))); $this->assertEquals(array( 1 => question_classified_response::no_response(), 2 => new question_classified_response(1, '1. quick', 0), 3 => new question_classified_response(4, '4. dog', 0), 4 => new question_classified_response(4, '4. dog', 1) ), $dd->classify_response(array('p1' => '', 'p2' => '1', 'p3' => '2', 'p4' => '2'))); } public function test_summarise_response_choice_deleted(): void { /** @var qtype_ddtoimage_question_base $dd */ $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); // Simulation of an instructor deleting 1 choice after an attempt has been made. unset($dd->choices[1][1]); $delquestionstr = get_string('deletedchoice', 'qtype_ddimageortext'); $this->assertEquals("Drop zone 1 -> {{$delquestionstr}} ". "Drop zone 2 -> {{$delquestionstr}} ". 'Drop zone 3 -> {3. lazy} '. 'Drop zone 4 -> {3. lazy}', $dd->summarise_response(array('p1' => '1', 'p2' => '1', 'p3' => '1', 'p4' => '1'))); } } PK\(0type/ddimageortext/tests/form/edit_form_test.phpnu[. namespace qtype_ddimageortext\form; use qtype_ddimageortext_edit_form; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/ddimageortext/edit_ddimageortext_form.php'); /** * Unit tests for the drag-and-drop onto image edit form. * * @package qtype_ddimageortext * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class edit_form_test extends \advanced_testcase { /** * Helper method. * * @return array with two elements: * question_edit_form great a question form instance that can be tested. * stdClass the question category. */ protected function get_form() { $this->setAdminUser(); $this->resetAfterTest(); $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $bankcontext = \context_module::instance($qbank->cmid); $category = question_get_default_category($bankcontext->id, true); $fakequestion = new \stdClass(); $fakequestion->qtype = 'ddimageortext'; $fakequestion->contextid = $bankcontext->id; $fakequestion->createdby = 2; $fakequestion->category = $category->id; $fakequestion->questiontext = 'Test question'; $fakequestion->options = new \stdClass(); $fakequestion->options->answers = array(); $fakequestion->formoptions = new \stdClass(); $fakequestion->formoptions->movecontext = null; $fakequestion->formoptions->repeatelements = true; $fakequestion->inputs = null; $form = new qtype_ddimageortext_edit_form(new \moodle_url('/'), $fakequestion, $category, new \core_question\local\bank\question_edit_contexts($bankcontext)); return [$form, $category]; } /** * Test the form correctly validates the HTML allowed in items. */ public function test_item_validation(): void { list($form, $category) = $this->get_form(); $submitteddata = [ 'category' => $category->id, 'bgimage' => '', 'nodropzone' => 0, 'noitems' => 5, 'drags' => [ ['dragitemtype' => 'image'], ['dragitemtype' => 'image'], ['dragitemtype' => 'word'], ['dragitemtype' => 'word'], ['dragitemtype' => 'word'], ], 'dragitem' => [ 0, 0, 0, 0, 0, ], 'draglabel' => [ 'frog', 'toad', 'cat', 'chien', '', ], ]; $errors = $form->validation($submitteddata, []); $this->assertArrayNotHasKey('draglabel[0]', $errors); $this->assertEquals('HTML tags are not allowed in this text which is the alt text for a draggable image.', $errors['draglabel[1]']); $this->assertArrayNotHasKey('draglabel[2]', $errors); $this->assertArrayNotHasKey('draglabel[3]', $errors); $this->assertEquals('Only "<br><sub><sup><b><i><strong><em><span>" ' . 'tags are allowed in this draggable text.', $errors['draglabel[4]']); } } PK\ z2type/ddimageortext/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package qtype_ddimageortext * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_ddimageortext\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\user_preference_provider; use qtype_ddimageortext\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ddimageortext/classes/privacy/provider.php'); /** * Privacy provider tests class. * * @package qtype_ddimageortext * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_ddimageortext'); $actual = \qtype_ddimageortext\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_ddimageortext_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_ddimageortext'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_ddimageortext_{$key}", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_ddimageortext'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the multiple choice question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'default mark 1' => ['defaultmark', 1, 1], 'penalty 33.33333%' => ['penalty', 0.3333333, '33.33333%'], 'shuffle yes' => ['shuffleanswers', 1, 'Yes'], 'shuffle no' => ['shuffleanswers', 0, 'No'] ]; } } PK\бQ/type/ddimageortext/tests/question_type_test.phpnu[. namespace qtype_ddimageortext; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddimageortext/tests/helper.php'); /** * Unit tests for the drag-and-drop onto image question definition class. * * @package qtype_ddimageortext * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \basic_testcase { /** @var qtype_ddimageortext instance of the question type class to test. */ protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = question_bank::get_qtype('ddimageortext');; } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'ddimageortext'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } } PK\nrFF-type/ddimageortext/tests/walkthrough_test.phpnu[. namespace qtype_ddimageortext; use question_display_options; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddimageortext/tests/helper.php'); /** * Unit tests for the drag-and-drop onto image question type. * * @package qtype_ddimageortext * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { /** * Get an expectation that the output contains an item ready to drag. * @param int $dragitemno the item number. * @param int $choice which choice this is. * @param int $group which drag group it belongs to. * @return \question_contains_tag_with_attributes the required expectation. */ protected function get_contains_drag_image_home_expectation($dragitemno, $choice, $group) { $class = 'group' . $group . ' draghome choice' . $choice; $expectedattrs = array(); $expectedattrs['class'] = $class; return new \question_contains_tag_with_attributes('div', $expectedattrs); } public function test_interactive_behaviour(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->hints = array( new question_hint_with_parts(13, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(14, 'This is the second hint.', FORMAT_HTML, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the wrong answer. $this->process_submission(array('p1' => '2', 'p2' => '1', 'p3' => '2', 'p4' => '1')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 1), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Submit the wrong answer. $this->process_submission( array('p1' => '2', 'p2' => '1', 'p3' => '2', 'p4' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 1), $this->get_contains_try_again_button_expectation(true), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', '1'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission( array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(8); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', '2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', '1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', '2'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(8); } public function test_deferred_feedback(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission(array('p1' => '2', 'p2' => '1')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', ''), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', ''), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save the right answer. $this->process_submission( array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2')); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 2), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(12); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 2), $this->get_contains_correct_expectation()); // Change the right answer a bit. $dd->rightchoices[2] = 1; // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(9); } public function test_deferred_feedback_unanswered(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save a blank response. $this->process_submission(array('p1' => '', 'p2' => '', 'p3' => '', 'p4' => '')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', ''), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', ''), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', ''), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', ''), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2)); } public function test_deferred_feedback_partial_answer(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save a partial response. $this->process_submission(array('p1' => '1', 'p2' => '2', 'p3' => '', 'p4' => '')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 0), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1.5); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_partcorrect_expectation()); } public function test_interactive_grading(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->hints = array( new question_hint_with_parts(1, 'This is the first hint.', FORMAT_MOODLE, true, true), new question_hint_with_parts(2, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->assertEquals('interactivecountback', $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation()); // Submit an response with the first two parts right. $this->process_submission( array('p1' => '1', 'p2' => '2', 'p3' => '2', 'p4' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the first hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 1)); // Check that extract responses will return the reset data. $prefix = $this->quba->get_field_prefix($this->slot); $this->assertEquals(array('p1' => '1', 'p2' => '2'), $this->quba->extract_responses($this->slot, array($prefix . 'p1' => '1', $prefix . 'p2' => '2', '-tryagain' => 1))); // Do try again. // keys p3 and p4 are extra hidden fields to clear data. $this->process_submission( array('p1' => '1', 'p2' => '2', 'p3' => '', 'p4' => '', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 0), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit an response with the first and last parts right. $this->process_submission( array('p1' => '1', 'p2' => '1', 'p3' => '2', 'p4' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the second hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 2)); // Do try again. $this->process_submission( array('p1' => '1', 'p2' => '', 'p3' => '', 'p4' => '2', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 2), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(1), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission( array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(7); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 1), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_contains_standard_correct_combined_feedback_expectation()); } public function test_interactive_correct_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, false), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the right answer. $this->process_submission(array('p1' => '1', 'p2' => '2', 'p3' => '1', 'p4' => '2')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); } public function test_interactive_partial_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, false), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 4); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a partially right answer. $this->process_submission(array('p1' => '1', 'p2' => '1', 'p3' => '2', 'p4' => '1')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1); $this->check_current_output( $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_partcorrect_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(1); } public function test_interactive_no_right_clears(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $dd->hints = array( new question_hint_with_parts(23, 'This is the first hint.', FORMAT_MOODLE, false, true), new question_hint_with_parts(24, 'This is the second hint.', FORMAT_MOODLE, true, true), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a completely wrong answer. $this->process_submission( array('p1' => '2', 'p2' => '1', 'p3' => '2', 'p4' => '1', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission( array('p1' => '', 'p2' => '', 'p3' => '', 'p4' => '', '-tryagain' => 1)); // Check that all the wrong answers have been cleared. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_drag_image_home_expectation(1, 1, 1), $this->get_contains_drag_image_home_expectation(2, 2, 1), $this->get_contains_drag_image_home_expectation(3, 1, 2), $this->get_contains_drag_image_home_expectation(4, 2, 2), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', 0), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', 0), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); } public function test_display_of_right_answer_when_shuffled(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext'); $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4'), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission($dd->get_correct_response()); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p1', $dd->get_right_choice_for(1)), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p2', $dd->get_right_choice_for(2)), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p3', $dd->get_right_choice_for(3)), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'p4', $dd->get_right_choice_for(4)), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->displayoptions->rightanswer = question_display_options::VISIBLE; $this->assertEquals('Drop zone 1 -> {1. quick} '. 'Drop zone 2 -> {2. fox} '. 'Drop zone 3 -> {3. lazy} '. 'Drop zone 4 -> {4. dog}', $dd->get_right_answer_summary()); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); } public function test_mixed_lang_rendering(): void { // Create a mixe drag-and-drop question. $dd = \test_question_maker::make_question('ddimageortext', 'mixedlang'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 1); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( new \question_pattern_expectation( '~
    la
    ~'), new \question_pattern_expectation( '~
    ma
    ~') ); } } PK\$::#type/ddimageortext/tests/helper.phpnu[. /** * Test helpers for the drag-and-drop onto image question type. * * @package qtype_ddimageortext * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the drag-and-drop onto image question type. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddimageortext_test_helper extends question_test_helper { public function get_test_questions() { return ['fox', 'maths', 'xsection', 'mixedlang', 'mathjax']; } /** * @return qtype_ddimageortext_question */ public function make_ddimageortext_question_fox() { question_bank::load_question_definition_classes('ddimageortext'); $dd = new qtype_ddimageortext_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop onto image question'; $dd->questiontext = 'The quick brown fox jumped over the lazy dog.'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddimageortext'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = $this->make_choice_structure(array( new qtype_ddimageortext_drag_item('quick', 1, 1), new qtype_ddimageortext_drag_item('fox', 2, 1), new qtype_ddimageortext_drag_item('lazy', 3, 2), new qtype_ddimageortext_drag_item('dog', 4, 2) )); $dd->places = $this->make_place_structure(array( new qtype_ddimageortext_drop_zone('', 1, 1), new qtype_ddimageortext_drop_zone('', 2, 1), new qtype_ddimageortext_drop_zone('', 3, 2), new qtype_ddimageortext_drop_zone('', 4, 2) )); $dd->rightchoices = array(1 => 1, 2 => 2, 3 => 1, 4 => 4); return $dd; } protected function make_choice_structure($choices) { $choicestructure = array(); foreach ($choices as $choice) { if (!isset($choicestructure[$choice->group])) { $choicestructure[$choice->group][1] = $choice; } else { $choicestructure[$choice->group][$choice->no] = $choice; } } return $choicestructure; } protected function make_place_structure($places) { $placestructure = array(); foreach ($places as $place) { $placestructure[$place->no] = $place; } return $placestructure; } /** * Make a mathematical ddimageortext question. * * @return qtype_ddimageortext_question */ public function make_ddimageortext_question_maths() { question_bank::load_question_definition_classes('ddimageortext'); $dd = new qtype_ddimageortext_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop onto image question'; $dd->questiontext = 'Fill in the operators to make this equation work: ' . '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddimageortext'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = $this->make_choice_structure(array( new qtype_ddimageortext_drag_item('+', 1, 1), new qtype_ddimageortext_drag_item('-', 2, 1) )); $dd->places = $this->make_place_structure(array( new qtype_ddimageortext_drop_zone('', 1, 1), new qtype_ddimageortext_drop_zone('', 2, 1), new qtype_ddimageortext_drop_zone('', 3, 1), new qtype_ddimageortext_drop_zone('', 4, 1) )); $dd->rightchoices = array(1 => 1, 2 => 2, 3 => 1, 4 => 2); return $dd; } /** * @return stdClass date to create a ddimageortext question. */ public function get_ddimageortext_question_form_data_xsection() { global $CFG, $USER; $fromform = new stdClass(); $bgdraftitemid = 0; file_prepare_draft_area($bgdraftitemid, null, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = context_user::instance($USER->id)->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $bgdraftitemid; $filerecord->filepath = '/'; $filerecord->filename = 'oceanfloorbase.jpg'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/ddimageortext/tests/fixtures/oceanfloorbase.jpg'); $fromform->name = 'Geography cross-section'; $fromform->questiontext = array( 'text' => '

    Identify the features in this cross-section by dragging the labels into the boxes.

    Use the mouse to drag the boxed words into the empty boxes. '. 'Alternatively, use the tab key to select an empty box, '. 'then use the space key to cycle through the options.

    ', 'format' => FORMAT_HTML, ); $fromform->defaultmark = 1; $fromform->generalfeedback = array( 'text' => '

    More information about the major features of the Earth\'s surface '. 'can be found in Block 3, Section 6.2.

    ', 'format' => FORMAT_HTML, ); $fromform->bgimage = $bgdraftitemid; $fromform->shuffleanswers = 0; $fromform->dropzonevisibility = 0; $fromform->drags = array( array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), array('dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'), ); $fromform->dragitem = array(0, 0, 0, 0, 0, 0, 0, 0); $fromform->draglabel = array( 'island
    arc', 'mid-ocean
    ridge', 'abyssal
    plain', 'continental
    rise', 'ocean
    trench', 'continental
    slope', 'mountain
    belt', 'continental
    shelf', ); $fromform->drops = array( array('xleft' => '53', 'ytop' => '17', 'choice' => '7', 'droplabel' => ''), array('xleft' => '172', 'ytop' => '2', 'choice' => '8', 'droplabel' => ''), array('xleft' => '363', 'ytop' => '31', 'choice' => '5', 'droplabel' => ''), array('xleft' => '440', 'ytop' => '13', 'choice' => '3', 'droplabel' => ''), array('xleft' => '115', 'ytop' => '74', 'choice' => '6', 'droplabel' => ''), array('xleft' => '210', 'ytop' => '94', 'choice' => '4', 'droplabel' => ''), array('xleft' => '310', 'ytop' => '87', 'choice' => '1', 'droplabel' => ''), array('xleft' => '479', 'ytop' => '84', 'choice' => '2', 'droplabel' => ''), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->penalty = '0.3333333'; $fromform->hint = array( array( 'text' => '

    1. Incorrect placements will be removed.

    ', 'format' => FORMAT_HTML, ), array( 'text' => '
    • 2. The abyssal plain is a flat almost featureless expanse of ocean '. 'floor 4km to 6km below sea-level.
    • The continental rise is the gently sloping part of the ocean floor beyond the continental slope.
    • The continental shelf is the gently sloping ocean floor just offshore from the land.
    • The continental slope is the relatively steep part of the ocean floor '. 'beyond the continental shelf.
    • A mid-ocean ridge is a broad submarine ridge several kilometres high.
    • A mountain belt is a long range of mountains.
    • An island arc is a chain of volcanic islands.
    • An oceanic trench is a deep trough in the ocean floor.
    ', 'format' => FORMAT_HTML, ), array( 'text' => '

    3. Incorrect placements will be removed.

    ', 'format' => FORMAT_HTML, ), array( 'text' => '
    • 4. The abyssal plain is a flat almost featureless expanse of ocean '. 'floor 4km to 6km below sea-level.
    • The continental rise is the gently sloping part of the ocean floor beyond the continental slope.
    • The continental shelf is the gently sloping ocean floor just offshore from the land.
    • The continental slope is the relatively steep part of the ocean floor '. 'beyond the continental shelf.
    • A mid-ocean ridge is a broad submarine ridge several kilometres high.
    • A mountain belt is a long range of mountains.
    • An island arc is a chain of volcanic islands.
    • An oceanic trench is a deep trough in the ocean floor.
    ', 'format' => FORMAT_HTML, ), ); $fromform->hintclearwrong = array(1, 0, 1, 0); $fromform->hintshownumcorrect = array(1, 1, 1, 1); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Get data required to save a drag-drop into text question where the the answer contain equation * * * @return stdClass data to create a ddwtos question. */ public function get_ddimageortext_question_form_data_mathjax() { global $CFG, $USER; $fromform = new stdClass(); $bgdraftitemid = 0; file_prepare_draft_area($bgdraftitemid, null, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = context_user::instance($USER->id)->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $bgdraftitemid; $filerecord->filepath = '/'; $filerecord->filename = 'oceanfloorbase.jpg'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/ddimageortext/tests/fixtures/oceanfloorbase.jpg'); $fromform->name = 'Drag-and-drop words into image question with equation'; $fromform->questiontext = ['text' => 'Fill in the correct mathjax equation: y = 2, x =4', 'format' => FORMAT_HTML]; $fromform->defaultmark = 1.0; $fromform->generalfeedback = ['text' => 'The right answer is: "y = x^2"', 'format' => FORMAT_HTML]; $fromform->drags = [ ['dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'], ['dragitemtype' => 'word', 'draggroup' => '1', 'infinite' => '0'], ]; $fromform->bgimage = $bgdraftitemid; $fromform->dragitem = [0, 0]; $fromform->draglabel = [ '$$ y = x^2 $$', '$$ y = x^5 $$', ]; $fromform->drops = [ ['xleft' => '53', 'ytop' => '17', 'choice' => '1', 'droplabel' => ''], ['xleft' => '172', 'ytop' => '2', 'choice' => '2', 'droplabel' => ''], ]; test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Make a test question where the drag items are a different language than the main question text. * * @return qtype_ddimageortext_question */ public function make_ddimageortext_question_mixedlang() { question_bank::load_question_definition_classes('ddimageortext'); $dd = new qtype_ddimageortext_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Question about French in English.'; $dd->questiontext = '

    Complete the blanks in this sentence.

    ' . '

    J\'ai perdu [[1]] plume de [[2]] tante - l\'avez-vous vue?

    '; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddimageortext'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = $this->make_choice_structure(array( new qtype_ddimageortext_drag_item('la', 1, 1), new qtype_ddimageortext_drag_item('ma', 2, 1), )); $dd->places = $this->make_place_structure(array( new qtype_ddimageortext_drop_zone('', 1, 1), new qtype_ddimageortext_drop_zone('', 2, 1) )); $dd->rightchoices = array(1 => 1, 2 => 2); return $dd; } } PK\Q Ō*type/ddimageortext/tests/behat/add.featurenu[@qtype @qtype_ddimageortext Feature: Test creating a drag and drop onto image question As a teacher In order to test my students I need to be able to create drag and drop onto image questions Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: Create a drag and drop onto image question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Drag and drop onto image" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I set the field "Question name" to "Drag and drop onto image 001" And I set the field "Question text" to "Identify the features in this cross-section." And I set the field "General feedback" to "The locations are now labelled on the diagram below." And I upload "question/type/ddimageortext/tests/fixtures/oceanfloorbase.jpg" file to "Background image" filemanager # Draggable items And I follow "Draggable items" And I press "Blanks for 3 more draggable items" And I set the field "id_shuffleanswers" to "1" And I set the field "id_drags_0_dragitemtype" to "Draggable text" And I set the field "id_draglabel_0" to "island
    arc" And I set the field "id_drags_1_dragitemtype" to "Draggable text" And I set the field "id_draglabel_1" to "mid-ocean
    ridge" And I set the field "id_drags_2_dragitemtype" to "Draggable text" And I set the field "id_draglabel_2" to "abyssal
    plain" And I set the field "id_drags_3_dragitemtype" to "Draggable text" And I set the field "id_draglabel_3" to "continental
    rise" And I set the field "id_drags_4_dragitemtype" to "Draggable text" And I set the field "id_draglabel_4" to "ocean
    trench" And I set the field "id_drags_5_dragitemtype" to "Draggable text" And I set the field "id_draglabel_5" to "continental
    slope" And I set the field "id_drags_6_dragitemtype" to "Draggable text" And I set the field "id_draglabel_6" to "mountain
    belt" And I set the field "id_drags_7_dragitemtype" to "Draggable text" And I set the field "id_draglabel_7" to "continental
    shelf" # Drop zones And I follow "Drop zones" And I set the field "id_dropzonevisibility" to "1" And I press "Blanks for 3 more drop zones" And I set the field "id_drops_0_xleft" to "53" And I set the field "id_drops_0_ytop" to "17" And I set the field "id_drops_0_choice" to "7" And I set the field "id_drops_1_xleft" to "172" And I set the field "id_drops_1_ytop" to "2" And I set the field "id_drops_1_choice" to "8" And I set the field "id_drops_2_xleft" to "363" And I set the field "id_drops_2_ytop" to "31" And I set the field "id_drops_2_choice" to "5" And I set the field "id_drops_3_xleft" to "440" And I set the field "id_drops_3_ytop" to "13" And I set the field "id_drops_3_choice" to "3" And I set the field "id_drops_4_xleft" to "115" And I set the field "id_drops_4_ytop" to "74" And I set the field "id_drops_4_choice" to "6" And I set the field "id_drops_5_xleft" to "210" And I set the field "id_drops_5_ytop" to "94" And I set the field "id_drops_5_choice" to "4" And I set the field "id_drops_6_xleft" to "310" And I set the field "id_drops_6_ytop" to "87" And I set the field "id_drops_6_choice" to "1" And I set the field "id_drops_7_xleft" to "479" And I set the field "id_drops_7_ytop" to "84" And I set the field "id_drops_7_choice" to "2" And I press "id_submitbutton" Then I should see "Drag and drop onto image 001" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "item_qtype_ddimageortext" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | id_shuffleanswers | 1 | | id_dropzonevisibility | 1 | @javascript @_file_upload Scenario: Question must have at least one drag item and one drop zone When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Drag and drop onto image" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I set the field "Question name" to "Test question" And I set the field "Question text" to "Identify the features in this cross-section." And I upload "question/type/ddimageortext/tests/fixtures/oceanfloorbase.jpg" file to "Background image" filemanager And I press "Save changes" Then I should see "You must add at least one draggable item to this question." And I should see "You must define at least one drop zone for this question." PK\A|d+type/ddimageortext/tests/behat/edit.featurenu[@qtype @qtype_ddimageortext Feature: Test editing a drag and drop onto image questions As a teacher In order to be able to update my drag and drop onto image questions I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddimageortext | Drag onto image | xsection | @javascript Scenario: Edit a drag and drop onto image question When I am on the "Drag onto image" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Edited question name | And I press "id_submitbutton" Then I should see "Edited question name" Scenario: Edit a drag and drop onto image question and verify penalty works as expected When I am on the "Drag onto image" "core_question > edit" page logged in as teacher Then the following fields match these values: | Question name | Drag onto image | | Penalty for each incorrect try | 33.33333% | | Penalty for each incorrect try | 0.3333333 | Scenario: Edit a drag and drop onto image question and verify penalty works as expected with custom decimal separator Given the following "language customisations" exist: | component | stringid | value | | core_langconfig | decsep | # | When I am on the "Drag onto image" "core_question > edit" page logged in as teacher Then the following fields match these values: | Question name | Drag onto image | | Penalty for each incorrect try | 33#33333% | | Penalty for each incorrect try | 0.3333333 | PK\*uu-type/ddimageortext/tests/behat/export.featurenu[@qtype @qtype_ddimageortext Feature: Test exporting drag and drop onto image questions As a teacher In order to be able to reuse my drag and drop onto image questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddimageortext | Drag onto image | xsection | @javascript Scenario: Export a drag and drop onto image question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | Drag onto image | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\##<type/ddimageortext/tests/behat/behat_qtype_ddimageortext.phpnu[. /** * Behat steps definitions for drag and drop onto image. * * @package qtype_ddimageortext * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Steps definitions related with the drag and drop onto image question type. * * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qtype_ddimageortext extends behat_base { /** * Get the xpath for a given drag item. * @param string $dragitem the text of the item to drag. * @return string the xpath expression. */ protected function drag_xpath($dragitem) { return '//div[contains(concat(" ", @class, " "), " draghome ") and ' . 'contains(normalize-space(.), "' . $this->escape($dragitem) . '") and not(contains(@class, "dragplaceholder"))]'; } /** * Get the xpath for a given drop box. * @param string $placenumber the number of the drop box. * @return string the xpath expression. */ protected function drop_xpath($placenumber) { return '//div[contains(concat(" ", @class, " "), " dropzone ") and ' . 'contains(concat(" ", @class, " "), " place' . $placenumber . ' ")]'; } /** * Drag the drag item with the given text to the given space. * * @param string $dragitem the text of the item to drag. * @param int $placenumber the number of the place to drop into. * * @Given /^I drag "(?P[^"]*)" to place "(?P\d+)" in the drag and drop onto image question$/ */ public function i_drag_to_place_in_the_drag_and_drop_onto_image_question($dragitem, $placenumber) { $generalcontext = behat_context_helper::get('behat_general'); $generalcontext->i_drag_and_i_drop_it_in($this->drag_xpath($dragitem), 'xpath_element', $this->drop_xpath($placenumber), 'xpath_element'); } /** * Type some characters while focussed on a given drop box. * * @param string $keys the characters to type. * @param int $placenumber the number of the place to drop into. * * @Given /^I type "(?P[^"]*)" on place "(?P\d+)" in the drag and drop onto image question$/ */ public function i_type_on_place_in_the_drag_and_drop_onto_image_question($keys, $placenumber) { $node = $this->get_selected_node('xpath_element', $this->drop_xpath($placenumber)); $this->ensure_node_is_visible($node); $node->focus(); foreach (str_split($keys) as $key) { behat_base::type_keys($this->getSession(), [$key]); $this->wait_for_pending_js(); } } } PK\O-type/ddimageortext/tests/behat/import.featurenu[@qtype @qtype_ddimageortext Feature: Test importing drag and drop onto image questions As a teacher In order to reuse drag and drop onto image questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import drag and drop onto image question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/ddimageortext/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "Identify the features in this cross-section by dragging the labels into the boxes." And I press "Continue" And I should see "Imported Drag and drop onto image 001" PK\[0[09type/ddimageortext/tests/behat/backup_and_restore.featurenu[@qtype @qtype_ddimageortext Feature: Test duplicating a quiz containing a drag and drop onto image question As a teacher In order re-use my courses containing drag and drop onto image questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddimageortext | Drag onto image | xsection | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | Drag onto image | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing a drag and drop onto image question When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "Drag onto image" in the question bank Then the following fields match these values: | Question name | Drag onto image | | General feedback |

    More information about the major features of the Earth's surface can be found in Block 3, Section 6.2.

    | | Default mark | 1 | | Shuffle | 0 | | id_dropzonevisibility | 0 | | id_drags_0_dragitemtype | Draggable text | | id_drags_0_draggroup | 1 | | id_draglabel_0 | island
    arc | | id_drags_1_dragitemtype | Draggable text | | id_drags_1_draggroup | 1 | | id_draglabel_1 | mid-ocean
    ridge | | id_drags_2_dragitemtype | Draggable text | | id_drags_2_draggroup | 1 | | id_draglabel_2 | abyssal
    plain | | id_drags_3_dragitemtype | Draggable text | | id_drags_3_draggroup | 1 | | id_draglabel_3 | continental
    rise | | id_drags_4_dragitemtype | Draggable text | | id_drags_4_draggroup | 1 | | id_draglabel_4 | ocean
    trench | | id_drags_5_dragitemtype | Draggable text | | id_drags_5_draggroup | 1 | | id_draglabel_5 | continental
    slope | | id_drags_6_dragitemtype | Draggable text | | id_drags_6_draggroup | 1 | | id_draglabel_6 | mountain
    belt | | id_drags_7_dragitemtype | Draggable text | | id_drags_7_draggroup | 1 | | id_draglabel_7 | continental
    shelf | | id_drops_0_xleft | 53 | | id_drops_0_ytop | 17 | | id_drops_0_choice | 7. mountain
    belt | | id_drops_1_xleft | 172 | | id_drops_1_ytop | 2 | | id_drops_1_choice | 8. continental
    shelf | | id_drops_2_xleft | 363 | | id_drops_2_ytop | 31 | | id_drops_2_choice | 5. ocean
    trench | | id_drops_3_xleft | 440 | | id_drops_3_ytop | 13 | | id_drops_3_choice | 3. abyssal
    plain | | id_drops_4_xleft | 115 | | id_drops_4_ytop | 74 | | id_drops_4_choice | 6. continental
    slope | | id_drops_5_xleft | 210 | | id_drops_5_ytop | 94 | | id_drops_5_choice | 4. continental
    rise | | id_drops_6_xleft | 310 | | id_drops_6_ytop | 87 | | id_drops_6_choice | 1. island
    arc | | id_drops_7_xleft | 479 | | id_drops_7_ytop | 84 | | id_drops_7_choice | 2. mid-ocean
    ridge | | For any correct response | Well done! | | For any partially correct response | Parts, but only parts, of your response are correct. | | id_shownumcorrect | 1 | | For any incorrect response | That is not right at all. | | Penalty for each incorrect try | 33.33333% | | Hint 1 | 1. Incorrect placements will be removed. | | id_hintclearwrong_0 | 1 | | id_hintshownumcorrect_0 | 1 | | id_hintclearwrong_1 | 0 | | id_hintshownumcorrect_1 | 1 | | Hint 3 | 3. Incorrect placements will be removed. | | id_hintclearwrong_2 | 1 | | id_hintshownumcorrect_2 | 1 | | id_hintclearwrong_3 | 0 | | id_hintshownumcorrect_3 | 1 | PK\ʷy.type/ddimageortext/tests/behat/preview.featurenu[@qtype @qtype_ddimageortext @_switch_window Feature: Preview a drag-drop onto image question As a teacher In order to check my drag-drop onto image questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddimageortext | Drag onto image | xsection | | Test questions | ddimageortext | Drag to mathjax equation | mathjax | @javascript @_bug_phantomjs Scenario: Preview a question using the mouse. When I am on the "Drag onto image" "core_question > preview" page logged in as teacher And I wait "2" seconds # Odd, but the
    s go to nothing, not a space. And I drag "mountainbelt" to place "1" in the drag and drop onto image question And I drag "continentalshelf" to place "2" in the drag and drop onto image question And I drag "oceantrench" to place "3" in the drag and drop onto image question And I drag "abyssalplain" to place "4" in the drag and drop onto image question And I drag "continentalslope" to place "5" in the drag and drop onto image question And I drag "continentalrise" to place "6" in the drag and drop onto image question And I drag "islandarc" to place "7" in the drag and drop onto image question And I drag "mid-oceanridge" to place "8" in the drag and drop onto image question And I press "Submit and finish" Then the state of "Identify the features" question is shown as "Correct" And I should see "Mark 1.00 out of 1.00" @javascript Scenario: Preview a question using the keyboard. When I am on the "Drag onto image" "core_question > preview" page logged in as teacher # Increase window size and wait 2 seconds to ensure elements are placed properly by js. And I type " " on place "1" in the drag and drop onto image question And I type " " on place "2" in the drag and drop onto image question And I type " " on place "3" in the drag and drop onto image question And I type " " on place "4" in the drag and drop onto image question And I type " " on place "5" in the drag and drop onto image question And I type " " on place "6" in the drag and drop onto image question And I type " " on place "7" in the drag and drop onto image question And I type " " on place "8" in the drag and drop onto image question And I press "Submit and finish" Then the state of "Identify the features" question is shown as "Correct" And I should see "Mark 1.00 out of 1.00" @javascript Scenario: Preview a drag-drop into image question with mathjax question. Given the "mathjaxloader" filter is "on" And the "mathjaxloader" filter applies to "content and headings" And I am on the "Drag to mathjax equation" "core_question > preview" page logged in as teacher And I press "Fill in correct responses" When I press "Submit and finish" Then ".filter_mathjaxloader_equation" "css_element" should exist in the ".draghome" "css_element" PK\,2  'type/ddimageortext/questiontypebase.phpnu[. /** * Question type class for the drag-and-drop onto image question type. * * @package qtype_ddimageortext * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/engine/lib.php'); require_once($CFG->dirroot . '/question/format/xml/format.php'); require_once($CFG->dirroot . '/question/type/gapselect/questiontypebase.php'); /** * The drag-and-drop onto image question type class. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddtoimage_base extends question_type { /** * Returns the choice group key. * * @return string */ protected function choice_group_key() { return 'draggroup'; } public function get_question_options($question) { global $DB; $dbprefix = 'qtype_'.$this->name(); $question->options = $DB->get_record($dbprefix, array('questionid' => $question->id), '*', MUST_EXIST); $question->options->drags = $DB->get_records($dbprefix.'_drags', array('questionid' => $question->id), 'no ASC', '*'); $question->options->drops = $DB->get_records($dbprefix.'_drops', array('questionid' => $question->id), 'no ASC', '*'); parent::get_question_options($question); } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $question->shufflechoices = $questiondata->options->shuffleanswers; if (isset($questiondata->options->dropzonevisibility)) { $question->dropzonevisibility = $questiondata->options->dropzonevisibility; } $this->initialise_combined_feedback($question, $questiondata, true); $question->choices = array(); $choiceindexmap = array(); // Store the choices in arrays by group. // This code is weird. The first choice in each group gets key 1 in the // $question->choices[$choice->choice_group()] array, and the others get // key $choice->no. Therefore you need to think carefully whether you // are using the key, or $choice->no. This is presumably a mistake, but // one that is now essentially un-fixable, since many questions of this // type have been attempted, and theys keys get stored in the attempt data. foreach ($questiondata->options->drags as $dragdata) { $choice = $this->make_choice($dragdata); if (array_key_exists($choice->choice_group(), $question->choices)) { $question->choices[$choice->choice_group()][$dragdata->no] = $choice; } else { $question->choices[$choice->choice_group()][1] = $choice; } end($question->choices[$choice->choice_group()]); $choiceindexmap[$dragdata->no] = array($choice->choice_group(), key($question->choices[$choice->choice_group()])); } $question->places = array(); $question->rightchoices = array(); $i = 1; foreach ($questiondata->options->drops as $dropdata) { list($group, $choiceindex) = $choiceindexmap[$dropdata->choice]; $dropdata->group = $group; $question->places[$dropdata->no] = $this->make_place($dropdata); $question->rightchoices[$dropdata->no] = $choiceindex; } } /** * Convert files into text output in the given format. * This method is copied from qformat_default as a quick fix, as the method there is * protected. * @param array $files * @param int $indent Number of spaces to indent * @return string $string */ public function write_files($files, $indent) { if (empty($files)) { return ''; } $string = ''; foreach ($files as $file) { if ($file->is_directory()) { continue; } $string .= str_repeat(' ', $indent); $string .= ''; $string .= base64_encode($file->get_content()); $string .= "\n"; } return $string; } public function get_possible_responses($questiondata) { $question = $this->make_question($questiondata); $parts = array(); foreach ($question->places as $placeno => $place) { $choices = array(); foreach ($question->choices[$place->group] as $i => $choice) { $correct = $question->rightchoices[$placeno] == $i; $choices[$choice->no] = new question_possible_response($choice->summarise(), $correct ? 1 : 0); } $choices[null] = question_possible_response::no_response(); $parts[$placeno] = $choices; } return $parts; } public function get_random_guess_score($questiondata) { $question = $this->make_question($questiondata); return $question->get_random_guess_score(); } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('qtype_'.$this->name(), array('questionid' => $questionid)); $DB->delete_records('qtype_'.$this->name().'_drags', array('questionid' => $questionid)); $DB->delete_records('qtype_'.$this->name().'_drops', array('questionid' => $questionid)); return parent::delete_question($questionid, $contextid); } } PK\LSLS2S2#type/ddimageortext/questiontype.phpnu[. /** * Question type class for the drag-and-drop onto image question type. * * @package qtype_ddimageortext * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/ddimageortext/questiontypebase.php'); /** * The drag-and-drop onto image question type class. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddimageortext extends qtype_ddtoimage_base { protected function make_choice($dragdata) { return new qtype_ddimageortext_drag_item($dragdata->label, $dragdata->no, $dragdata->draggroup, $dragdata->infinite, $dragdata->id); } protected function make_place($dropzonedata) { return new qtype_ddimageortext_drop_zone($dropzonedata->label, $dropzonedata->no, $dropzonedata->group, $dropzonedata->xleft, $dropzonedata->ytop); } protected function make_hint($hint) { return question_hint_with_parts::load_from_record($hint); } public function save_defaults_for_new_questions(stdClass $fromform): void { parent::save_defaults_for_new_questions($fromform); $this->set_default_value('shuffleanswers', $fromform->shuffleanswers); $this->set_default_value('dropzonevisibility', $fromform->dropzonevisibility); } public function save_question_options($formdata) { global $DB, $USER; $context = $formdata->context; $options = $DB->get_record('qtype_ddimageortext', array('questionid' => $formdata->id)); if (!$options) { $options = new stdClass(); $options->questionid = $formdata->id; $options->correctfeedback = ''; $options->partiallycorrectfeedback = ''; $options->incorrectfeedback = ''; $options->id = $DB->insert_record('qtype_ddimageortext', $options); } $options->shuffleanswers = !empty($formdata->shuffleanswers); $options->dropzonevisibility = !empty($formdata->dropzonevisibility); $options = $this->save_combined_feedback_helper($options, $formdata, $context, true); $this->save_hints($formdata, true); $DB->update_record('qtype_ddimageortext', $options); $DB->delete_records('qtype_ddimageortext_drops', array('questionid' => $formdata->id)); foreach (array_keys($formdata->drops) as $dropno) { if ($formdata->drops[$dropno]['choice'] == 0) { continue; } $drop = new stdClass(); $drop->questionid = $formdata->id; $drop->no = $dropno + 1; $drop->xleft = $formdata->drops[$dropno]['xleft']; $drop->ytop = $formdata->drops[$dropno]['ytop']; $drop->choice = $formdata->drops[$dropno]['choice']; $drop->label = $formdata->drops[$dropno]['droplabel']; $DB->insert_record('qtype_ddimageortext_drops', $drop); } // An array of drag no -> drag id. $olddragids = $DB->get_records_menu('qtype_ddimageortext_drags', array('questionid' => $formdata->id), '', 'no, id'); foreach (array_keys($formdata->drags) as $dragno) { $info = file_get_draft_area_info($formdata->dragitem[$dragno]); if ($info['filecount'] > 0 || (trim($formdata->draglabel[$dragno]) != '')) { $draftitemid = $formdata->dragitem[$dragno]; $drag = new stdClass(); $drag->questionid = $formdata->id; $drag->no = $dragno + 1; $drag->draggroup = $formdata->drags[$dragno]['draggroup']; $drag->infinite = empty($formdata->drags[$dragno]['infinite']) ? 0 : 1; $drag->label = $formdata->draglabel[$dragno]; if (isset($olddragids[$dragno + 1])) { $drag->id = $olddragids[$dragno + 1]; unset($olddragids[$dragno + 1]); $DB->update_record('qtype_ddimageortext_drags', $drag); } else { $drag->id = $DB->insert_record('qtype_ddimageortext_drags', $drag); } if ($formdata->drags[$dragno]['dragitemtype'] == 'image') { file_save_draft_area_files($draftitemid, $formdata->context->id, 'qtype_ddimageortext', 'dragimage', $drag->id, array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 1)); } else { // Delete any existing files for draggable text item type. $fs = get_file_storage(); $fs->delete_area_files($formdata->context->id, 'qtype_ddimageortext', 'dragimage', $drag->id); } } } if (!empty($olddragids)) { list($sql, $params) = $DB->get_in_or_equal(array_values($olddragids)); $DB->delete_records_select('qtype_ddimageortext_drags', "id $sql", $params); } file_save_draft_area_files($formdata->bgimage, $formdata->context->id, 'qtype_ddimageortext', 'bgimage', $formdata->id, array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 1)); } public function move_files($questionid, $oldcontextid, $newcontextid) { global $DB; $fs = get_file_storage(); parent::move_files($questionid, $oldcontextid, $newcontextid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_ddimageortext', 'bgimage', $questionid); $dragids = $DB->get_records_menu('qtype_ddimageortext_drags', array('questionid' => $questionid), 'id', 'id,1'); foreach ($dragids as $dragid => $notused) { $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_ddimageortext', 'dragimage', $dragid); } $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } /** * Delete all the files belonging to this question. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. */ protected function delete_files($questionid, $contextid) { global $DB; $fs = get_file_storage(); parent::delete_files($questionid, $contextid); $dragids = $DB->get_records_menu('qtype_ddimageortext_drags', array('questionid' => $questionid), 'id', 'id,1'); foreach ($dragids as $dragid => $notused) { $fs->delete_area_files($contextid, 'qtype_ddimageortext', 'dragimage', $dragid); } $this->delete_files_in_combined_feedback($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } public function export_to_xml($question, qformat_xml $format, $extra = null) { $fs = get_file_storage(); $contextid = $question->contextid; $output = ''; if ($question->options->shuffleanswers) { $output .= " \n"; } if ($question->options->dropzonevisibility) { $output .= " \n"; } $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid); $files = $fs->get_area_files($contextid, 'qtype_ddimageortext', 'bgimage', $question->id); $output .= " ".$this->write_files($files, 2)."\n";; foreach ($question->options->drags as $drag) { $files = $fs->get_area_files($contextid, 'qtype_ddimageortext', 'dragimage', $drag->id); $output .= " \n"; $output .= " {$drag->no}\n"; $output .= $format->writetext($drag->label, 3)."\n"; $output .= " {$drag->draggroup}\n"; if ($drag->infinite) { $output .= " \n"; } $output .= $this->write_files($files, 3); $output .= " \n"; } foreach ($question->options->drops as $drop) { $output .= " \n"; $output .= $format->writetext($drop->label, 3); $output .= " {$drop->no}\n"; $output .= " {$drop->choice}\n"; $output .= " {$drop->xleft}\n"; $output .= " {$drop->ytop}\n"; $output .= " \n"; } return $output; } public function import_from_xml($data, $question, qformat_xml $format, $extra=null) { if (!isset($data['@']['type']) || $data['@']['type'] != 'ddimageortext') { return false; } $question = $format->import_headers($data); $question->qtype = 'ddimageortext'; $question->shuffleanswers = array_key_exists('shuffleanswers', $format->getpath($data, array('#'), array())); $question->dropzonevisibility = array_key_exists('dropzonevisibility', $format->getpath($data, ['#'], [])); $filexml = $format->getpath($data, array('#', 'file'), array()); $question->bgimage = $format->import_files_as_draft($filexml); $drags = $data['#']['drag']; $question->drags = array(); foreach ($drags as $dragxml) { $dragno = $format->getpath($dragxml, array('#', 'no', 0, '#'), 0); $dragindex = $dragno - 1; $question->drags[$dragindex] = array(); $question->draglabel[$dragindex] = $format->getpath($dragxml, array('#', 'text', 0, '#'), '', true); $question->drags[$dragindex]['infinite'] = array_key_exists('infinite', $dragxml['#']); $question->drags[$dragindex]['draggroup'] = $format->getpath($dragxml, array('#', 'draggroup', 0, '#'), 1); $filexml = $format->getpath($dragxml, array('#', 'file'), array()); $question->dragitem[$dragindex] = $format->import_files_as_draft($filexml); if (count($filexml)) { $question->drags[$dragindex]['dragitemtype'] = 'image'; } else { $question->drags[$dragindex]['dragitemtype'] = 'word'; } } $drops = $data['#']['drop']; $question->drops = array(); foreach ($drops as $dropxml) { $dropno = $format->getpath($dropxml, array('#', 'no', 0, '#'), 0); $dropindex = $dropno - 1; $question->drops[$dropindex] = array(); $question->drops[$dropindex]['choice'] = $format->getpath($dropxml, array('#', 'choice', 0, '#'), 0); $question->drops[$dropindex]['droplabel'] = $format->getpath($dropxml, array('#', 'text', 0, '#'), '', true); $question->drops[$dropindex]['xleft'] = $format->getpath($dropxml, array('#', 'xleft', 0, '#'), ''); $question->drops[$dropindex]['ytop'] = $format->getpath($dropxml, array('#', 'ytop', 0, '#'), ''); } $format->import_combined_feedback($question, $data, true); $format->import_hints($question, $data, true, false, $format->get_format($question->questiontextformat)); return $question; } } PK\88.type/ddimageortext/edit_ddimageortext_form.phpnu[. /** * Defines the editing form for the drag-and-drop images onto images question type. * * @package qtype_ddimageortext * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/ddimageortext/edit_ddtoimage_form_base.php'); /** * Drag-and-drop images onto images editing form definition. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddimageortext_edit_form extends qtype_ddtoimage_edit_form_base { public function qtype() { return 'ddimageortext'; } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, true, true); $dragids = array(); // Drag no -> dragid. if (!empty($question->options)) { $question->shuffleanswers = $question->options->shuffleanswers; $question->dropzonevisibility = $question->options->dropzonevisibility; $question->drags = array(); foreach ($question->options->drags as $drag) { $dragindex = $drag->no - 1; $question->drags[$dragindex] = array(); $question->draglabel[$dragindex] = $drag->label; $question->drags[$dragindex]['infinite'] = $drag->infinite; $question->drags[$dragindex]['draggroup'] = $drag->draggroup; $dragids[$dragindex] = $drag->id; } $question->drops = array(); foreach ($question->options->drops as $drop) { $question->drops[$drop->no - 1] = array(); $question->drops[$drop->no - 1]['choice'] = $drop->choice; $question->drops[$drop->no - 1]['droplabel'] = $drop->label; $question->drops[$drop->no - 1]['xleft'] = $drop->xleft; $question->drops[$drop->no - 1]['ytop'] = $drop->ytop; } } // Initialise file picker for bgimage. $draftitemid = file_get_submitted_draft_itemid('bgimage'); file_prepare_draft_area($draftitemid, $this->context->id, 'qtype_ddimageortext', 'bgimage', !empty($question->id) ? (int) $question->id : null, self::file_picker_options()); $question->bgimage = $draftitemid; // Initialise file picker for dragimages. list(, $imagerepeats) = $this->get_drag_item_repeats(); $draftitemids = optional_param_array('dragitem', array(), PARAM_INT); for ($imageindex = 0; $imageindex < $imagerepeats; $imageindex++) { $draftitemid = isset($draftitemids[$imageindex]) ? $draftitemids[$imageindex] : 0; // Numbers not allowed in filearea name. $itemid = isset($dragids[$imageindex]) ? $dragids[$imageindex] : null; file_prepare_draft_area($draftitemid, $this->context->id, 'qtype_ddimageortext', 'dragimage', $itemid, self::file_picker_options()); $question->dragitem[$imageindex] = $draftitemid; } if (!empty($question->options)) { foreach ($question->options->drags as $drag) { $dragindex = $drag->no - 1; if (!isset($question->dragitem[$dragindex])) { $fileexists = false; } else { $fileexists = self::file_uploaded($question->dragitem[$dragindex]); } $labelexists = (trim($question->draglabel[$dragindex]) != ''); if ($labelexists && !$fileexists) { $question->drags[$dragindex]['dragitemtype'] = 'word'; } else { $question->drags[$dragindex]['dragitemtype'] = 'image'; } } } $this->js_call(); return $question; } public function js_call() { global $PAGE; $PAGE->requires->js_call_amd('qtype_ddimageortext/form', 'init'); } // Drag items. protected function definition_draggable_items($mform, $itemrepeatsatstart) { $mform->addElement('header', 'draggableitemheader', get_string('draggableitems', 'qtype_ddimageortext')); $mform->addElement('advcheckbox', 'shuffleanswers', get_string('shuffleimages', 'qtype_'.$this->qtype())); $mform->setDefault('shuffleanswers', $this->get_default_value('shuffleanswers', 0)); $this->repeat_elements($this->draggable_item($mform), $itemrepeatsatstart, $this->draggable_items_repeated_options(), 'noitems', 'additems', self::ADD_NUM_ITEMS, get_string('addmoreimages', 'qtype_ddimageortext'), true); } protected function draggable_item($mform) { $draggableimageitem = array(); $grouparray = array(); $dragitemtypes = array('image' => get_string('draggableimage', 'qtype_ddimageortext'), 'word' => get_string('draggableword', 'qtype_ddimageortext')); $grouparray[] = $mform->createElement('select', 'dragitemtype', get_string('draggableitemtype', 'qtype_ddimageortext'), $dragitemtypes, array('class' => 'dragitemtype')); $options = array(); for ($i = 1; $i <= self::MAX_GROUPS; $i += 1) { $options[$i] = question_utils::int_to_letter($i); } $grouparray[] = $mform->createElement('select', 'draggroup', get_string('group', 'qtype_gapselect'), $options, array('class' => 'draggroup')); $grouparray[] = $mform->createElement('advcheckbox', 'infinite', get_string('infinite', 'qtype_ddimageortext')); $draggableimageitem[] = $mform->createElement('group', 'drags', get_string('draggableitemheader', 'qtype_ddimageortext', '{no}'), $grouparray); $draggableimageitem[] = $mform->createElement('filepicker', 'dragitem', '', null, self::file_picker_options()); $draggableimageitem[] = $mform->createElement('text', 'draglabel', get_string('label', 'qtype_ddimageortext'), array('size' => 30, 'class' => 'tweakcss draglabel')); $mform->setType('draglabel', PARAM_RAW); // These are validated manually. return $draggableimageitem; } protected function draggable_items_repeated_options() { $repeatedoptions = array(); $repeatedoptions['draggroup']['default'] = '1'; return $repeatedoptions; } // Drop zones. protected function drop_zone($mform, $imagerepeats) { $dropzoneitem = array(); $grouparray = array(); $options = [ 0 => get_string('dropzonevisibility_showoption', 'qtype_ddimageortext'), 1 => get_string('dropzonevisibility_hideoption', 'qtype_ddimageortext'), ]; $mform->addElement('select', 'dropzonevisibility', get_string('dropzonevisibility', 'qtype_ddimageortext'), $options); $mform->setDefault('dropzonevisibility', $this->get_default_value('dropzonevisibility', 0)); $mform->addHelpButton('dropzonevisibility', 'dropzonevisibility', 'qtype_ddimageortext'); $grouparray[] = $mform->createElement('text', 'xleft', get_string('xleft', 'qtype_ddimageortext'), array('size' => 5, 'class' => 'tweakcss')); $grouparray[] = $mform->createElement('text', 'ytop', get_string('ytop', 'qtype_ddimageortext'), array('size' => 5, 'class' => 'tweakcss')); $options = array(); $options[0] = ''; for ($i = 1; $i <= $imagerepeats; $i += 1) { $options[$i] = $i; } $grouparray[] = $mform->createElement('select', 'choice', get_string('draggableitem', 'qtype_ddimageortext'), $options); $grouparray[] = $mform->createElement('text', 'droplabel', get_string('label', 'qtype_ddimageortext'), array('size' => 10, 'class' => 'tweakcss')); $mform->setType('droplabel', PARAM_NOTAGS); $dropzone = $mform->createElement('group', 'drops', get_string('dropzone', 'qtype_ddimageortext', '{no}'), $grouparray); return array($dropzone); } protected function drop_zones_repeated_options() { $repeatedoptions = array(); // The next two are PARAM_RAW becuase we need to distinguish 0 and ''. // We do the necessary validation in the validation method. $repeatedoptions['drops[xleft]']['type'] = PARAM_RAW; $repeatedoptions['drops[ytop]']['type'] = PARAM_RAW; $repeatedoptions['drops[droplabel]']['type'] = PARAM_TEXT; $repeatedoptions['choice']['default'] = '0'; return $repeatedoptions; } public function validation($data, $files) { $errors = parent::validation($data, $files); if (!self::file_uploaded($data['bgimage'])) { $errors['bgimage'] = get_string('formerror_nobgimage', 'qtype_'.$this->qtype()); } $dropfound = false; $allchoices = array(); for ($i = 0; $i < $data['nodropzone']; $i++) { $ytoppresent = (trim($data['drops'][$i]['ytop']) !== ''); $xleftpresent = (trim($data['drops'][$i]['xleft']) !== ''); $ytopisint = (string) clean_param($data['drops'][$i]['ytop'], PARAM_INT) === trim($data['drops'][$i]['ytop']); $xleftisint = (string) clean_param($data['drops'][$i]['xleft'], PARAM_INT) === trim($data['drops'][$i]['xleft']); $labelpresent = (trim($data['drops'][$i]['droplabel']) !== ''); $choice = $data['drops'][$i]['choice']; $imagechoicepresent = ($choice !== '0'); if ($imagechoicepresent) { $dropfound = true; if (!$ytoppresent) { $errors["drops[$i]"] = get_string('formerror_noytop', 'qtype_ddimageortext'); } else if (!$ytopisint) { $errors["drops[$i]"] = get_string('formerror_notintytop', 'qtype_ddimageortext'); } if (!$xleftpresent) { $errors["drops[$i]"] = get_string('formerror_noxleft', 'qtype_ddimageortext'); } else if (!$xleftisint) { $errors["drops[$i]"] = get_string('formerror_notintxleft', 'qtype_ddimageortext'); } if ($data['drags'][$choice - 1]['dragitemtype'] != 'word' && !self::file_uploaded($data['dragitem'][$choice - 1])) { $errors['dragitem['.($choice - 1).']'] = get_string('formerror_nofile', 'qtype_ddimageortext', $i); } if (isset($allchoices[$choice]) && !$data['drags'][$choice - 1]['infinite']) { $errors["drops[$i]"] = get_string('formerror_multipledraginstance', 'qtype_ddimageortext', $choice); $errors['drops['.($allchoices[$choice]).']'] = get_string('formerror_multipledraginstance', 'qtype_ddimageortext', $choice); $errors['drags['.($choice - 1).']'] = get_string('formerror_multipledraginstance2', 'qtype_ddimageortext', $choice); } $allchoices[$choice] = $i; } else { if ($ytoppresent || $xleftpresent || $labelpresent) { $dropfound = true; $errors["drops[$i]"] = get_string('formerror_noimageselected', 'qtype_ddimageortext'); } } } if (!$dropfound) { $errors['drops[0]'] = get_string('formerror_droprequired', 'qtype_ddimageortext'); } $dragfound = false; for ($dragindex = 0; $dragindex < $data['noitems']; $dragindex++) { $label = $data['draglabel'][$dragindex]; if ($data['drags'][$dragindex]['dragitemtype'] == 'word') { if ($label !== '') { $dragfound = true; } $allowedtags = '
    '; $errormessage = get_string('formerror_disallowedtags', 'qtype_ddimageortext', s($allowedtags)); } else { if (self::file_uploaded($data['dragitem'][$dragindex])) { $dragfound = true; } $allowedtags = ''; $errormessage = get_string('formerror_noallowedtags', 'qtype_ddimageortext'); } if ($label != strip_tags($label, $allowedtags)) { $errors["draglabel[{$dragindex}]"] = $errormessage; } } if (!$dragfound) { $errors['drags[0]'] = get_string('formerror_dragrequired', 'qtype_ddimageortext'); } return $errors; } } PK\#type/ddimageortext/questionbase.phpnu[. /** * Drag-and-drop onto image question definition class. * * @package qtype_ddimageortext * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/gapselect/questionbase.php'); /** * Represents a drag-and-drop onto image question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddtoimage_question_base extends qtype_gapselect_question_base { public function clear_wrong_from_response(array $response) { foreach ($this->places as $place => $notused) { if (array_key_exists($this->field($place), $response) && $response[$this->field($place)] != $this->get_right_choice_for($place)) { $response[$this->field($place)] = ''; } } return $response; } public function get_right_choice_for($placeno) { $place = $this->places[$placeno]; foreach ($this->choiceorder[$place->group] as $choicekey => $choiceid) { if ($this->rightchoices[$placeno] == $choiceid) { return $choicekey; } } } public function summarise_response(array $response) { $allblank = true; foreach ($this->places as $placeno => $place) { $summariseplace = $place->summarise(); if (array_key_exists($this->field($placeno), $response) && $response[$this->field($placeno)]) { $selected = $this->get_selected_choice($place->group, $response[$this->field($placeno)]); if (isset($selected)) { $summarisechoice = $selected->summarise(); } else { $summarisechoice = get_string('deletedchoice', 'qtype_ddimageortext'); } $allblank = false; } else { $summarisechoice = ''; } $choices[] = "$summariseplace -> {{$summarisechoice}}"; } if ($allblank) { return null; } return implode(' ', $choices); } public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($filearea == 'bgimage' || $filearea == 'dragimage') { $validfilearea = true; } else { $validfilearea = false; } if ($component == 'qtype_ddimageortext' && $validfilearea) { $question = $qa->get_question(); $itemid = reset($args); if ($filearea == 'bgimage') { return $itemid == $question->id; } else if ($filearea == 'dragimage') { foreach ($question->choices as $group) { foreach ($group as $drag) { if ($drag->id == $itemid) { return true; } } } return false; } } else { return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } } public function get_validation_error(array $response) { if ($this->is_complete_response($response)) { return ''; } return get_string('pleasedraganimagetoeachdropregion', 'qtype_ddimageortext'); } public function classify_response(array $response) { $parts = array(); foreach ($this->places as $placeno => $place) { $group = $place->group; if (!array_key_exists($this->field($placeno), $response) || !$response[$this->field($placeno)]) { $parts[$placeno] = question_classified_response::no_response(); continue; } $fieldname = $this->field($placeno); $choicekey = $this->choiceorder[$group][$response[$fieldname]]; $choice = $this->choices[$group][$choicekey]; $correct = $this->get_right_choice_for($placeno) == $response[$fieldname]; if ($correct) { $grade = 1; } else { $grade = 0; } $parts[$placeno] = new question_classified_response($choice->no, $choice->summarise(), $grade); } return $parts; } public function get_random_guess_score() { if (empty($this->places)) { // Having no places would be nonsensical. However, it used to be possible // to create questions like that, so avoid errors in this case. return null; } $accum = 0; foreach ($this->places as $place) { foreach ($this->choices[$place->group] as $choice) { if ($choice->infinite) { return null; } } $accum += 1 / count($this->choices[$place->group]); } return $accum / count($this->places); } public function get_question_summary() { $summary = ''; if (!html_is_blank($this->questiontext)) { $question = $this->html_to_text($this->questiontext, $this->questiontextformat); $summary .= $question . '; '; } $places = array(); foreach ($this->places as $place) { $cs = array(); foreach ($this->choices[$place->group] as $choice) { $cs[] = $choice->summarise(); } $places[] = '[[' . $place->summarise() . ']] -> {' . implode(' / ', $cs) . '}'; } $summary .= implode('; ', $places); return $summary; } } PK\O88type/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes for question type plugins. === 4.0.2, 4.1 === * There was one issue caused by the changes in Moodle 4.0 which requires changes in question types, and that is regrading. There are two new methods which some question types will need to implement, - validate_can_regrade_with_other_version - update_attempt_state_data_for_new_version these methods are introduced in this commit, and there are details PHPdoc comments about what they must do. Then the immediately following commits implement them in the core question types where they are required. Generally, you will need to implement one or both of these if you question type does something significant in the apply_attempt_state method. If you have not implemented that method, then almost certainly you don't need to worry about this. === 4.0 === * The major question bank changes should not affect most basic question type plugins. The navigation changes may affect Behat tests. If you encounter this, the best way to fix it is to use the new navigation steps in MDL-74130. * The qualification 'most' is because some question types do more complex things, which will require changes related to question versionning. Some examples that come to mind: - the way qtype_mulitanswer (or qtype_combined) aggregates several sub-questions into a parent question. - the way some contrib plugins (e.g. qtype_stack, qtype_pmatch) store additional data (question tests) linked to questions. That relationship will need to be updated. === 3.11 === * Introducing the following \question_type base class methods to save/fetch the last form values that were used when creating questions as the new defaults when creating new questions: - \question_type::get_default_value() - Fetches the default value for a given question field from the user preference. Question type plugins can use this in edit_{qtypename}_form.php when using $mform->setDefault(). - \question_type::set_default_value() - Saves the default value for a given question form field in the user preferences. - \question_type::save_defaults_for_new_questions() - Saves the question type plugin's defined form defaults into the user preferences. It calls \question_type::set_default_value() to save each form field default value into the user preferences. - Question type plugins using \question_type::get_default_value() for their form fields must implement this in order to save the values from these form fields as defaults for new questions. This will help teachers who repeatedly create questions and use the same values for the fields (e.g. Default mark, Penalty for each incorrect try, etc.) in the question edit form. === 3.8 === * There is a new method for question types get_extra_question_bank_actions. Assuming the question bank display is using the new 'edit_menu_column' (which it will be by default) this method lets you add question-type-specific actions to the menu. The question_type base class has extensive PHPdoc comments on the method to explain what you should do, and there is an example of how to use it in a question type at https://github.com/moodleou/moodle-qtype_pmatch/commit/2aefa8b5dcc7bab768f4707a4ffb7befcf4c2540. === 3.8, 3.7.3, 3.6.7 === * Coming up in Moodle 3.8 are some changes to the question bank UI. These will break any Behat automated tests which use the common pattern When I click on "Duplicate" "link" in the "Test question" "table_row" to trigger actions on questions when looking at the question bank screen. Therefore, a new step has been introduced: When I choose "Duplicate" action for "Test question" in the question bank If you want your Behat tests to continue working with Moodle 3.8, you will need to use the new step. The new step has been back-ported, so you can start updating your tests and have them work with Moodle 3.6 and 3.7. In addition, if you want to trigger the "Edit" action, you should change that to "Edit question". === 3.5 === + Added new classes backup_qtype_extrafields_plugin and restore_qtype_extrafields_plugin in order to use extra fields method in backup/restore question type. Require and inherit new classes for using it. See backup_qtype_shortanswer_plugin and restore_qtype_shortanswer_plugin for an example of using this. + The declaration of is_gradable_response has been moved from question_automatically_gradable to question_manually_gradable. + The default implementation of is_gradable_response has been moved from question_graded_automatically to question_with_responses. + Note that format_text() is no longer applied to the results of qtype_elements_embedded_in_question_text_renderer::embedded_element(). If question type overrides this method make sure you apply format_text() to any text that came from a user. === 3.1.5, 3.2.2, 3.3 === * If you are using check_combined_feedback_file_access in your check_file_access method, then you must now pass $args as the 4th argument, so the correct permission checks can be performed. If you don't, you will get a developer debug notice. === 3.1 === * The following functions, previously used (exclusively) by upgrade steps are not available anymore because of the upgrade cleanup performed for this version. See MDL-51580 for more info: - qtype_essay_convert_to_html() === 2.7 === + We have added a new method to the question_type base class 'break_down_stats_and_response_analysis_by_variant'. By default it returns true. If your question type does not have variants of question instances then you can ignore this method as it only applies to question types that have variants. If a question type does have variants the default action is to break down response analysis and question stats by variant. But for some question types there might be an almost infinite quantity of variants for the question, in this case you can suppress break down by variant by returning false from this method. See for example the non-core question type varnumeric or the slightly more complex stack question type. + We have added a pair of methods to the question_definition class 'prepare_simulated_post_data' and 'get_student_response_values_for_simulation'. You may want to override these methods in question.php in your question type plug -in. These methods are used to convert data from a csv file of simulated step data into the simulated post data that is fed to the question engine. These csv files can be used for unit testing or manual testing the use of your question type within the quiz module. You can use the simulate quiz report for manual testing and also to easily generate csv test files. You can run a number of students through a test and then download a csv file representing their interaction with the quiz. For most question types the default of just passing csv data as post data, directly from the csv file will probably work fine. But for certain question types where the meaning of the post data is deliberately obfuscated it might be necessary to convert from a human friendly format in the csv file to response data expected by the question type using 'prepare_simulated_post_data' and to convert back from a question type response array to values for download in a csv file using 'get_student_response_values_for_simulation'. === 2.6 === + The changes in MDL-32750 were reverted in favour of the new pdw toggle toolbars plugin for TinyMCE. The get_non_collapsible_editor_options method has been deprecated. === 2.5 === * There have been some tweaks to the helper class that is used to write walkthrough tests. You should not have to change your code, but you might like to take a look at some of the new helper methods available. In particular, if you had any code that did $this->process_submission(array('-finish' => 1)); you should change that to $this->finish(); * There have been lots of usability improvements to the question editing forms. MDL-37417 links to all the changes. There are only a few API changes that *require* you to upgrade your question type, but you are strongly recommended to make similar usability improvements in your own question types. + Some of the ids used in the form HTML have had ‘id_’ added at the front. Take care if you refer to these in your JavasSript or CSS. + MDL-32750 The HTML editing tools are now collapsed. This is applied by default to all HTML editors except question text and general feedback. If you want to add more exceptions, see the get_non_collabsible_editor_options method. + Form fields have been grouped onto one line where appropriate, to reduce the height of the form. qtype_numerical is a good example of this. + Where elements are in groups, we have changed the normal accesshide CSS, so that their labels are visible. If you were using grouped elements in the past with static elements to lable the fields, then you will need to remove the statics. + All the choices / answers have been merged into a single section of the form. This works better with the new 'shortforms' MDL-30637. Also the "Add blanks for more ..." buttons are now inside that section. This probably requries that you remove any headings from your per-answer fields, and change some of the labels. + Having merged all the elements into one form section, we then used CSS to visually group the fields for one choice, answer, etc. + When editing an existing question, we only show as many repeats are are actually needed until the user clicks the "Add blanks for more ..." button. Where you have your own repeat elements, you may need to change the number of repeats calculation. + As with all forms, setType() is now required for all text form elements. + A good example of a question type being upgraded to take account of all these changes is https://github.com/moodleou/moodle-qtype_pmatch/commit/9d8e1beb9f780246416a0f3a7622f700b8fa90c8 === 2.3.5 / 2.4.2 / 2.5 === * The special value question_attempt::PARAM_CLEANHTML_FILES that could be used in the get_expected_data method was renamed to question_attempt::PARAM_RAW_FILES in order to fix a bug. We failed to think this throught, and so did not realised that this might break some question types. If this affected your question type, please accept our apologies. Details in MDL-37847. === 2.3 === * Support for backwards-compatible string names dropped. MDL-30120. (See under 2.2 below.) * If you are overriding export_to_xml and import_from_xml to provide Moodle XML format import and export, then you will probably get PHP strict syntax notices in developer debug mode until you change the method signature to include qformat_xml $format. That is, you need to specify the argument type. * qtype_xxx_pluginfile() is now given the 7th parameter (hopefully the last one) that contains additional options for the file serving. The array should be re-passed to question_pluginfile() as is. === 2.2 === * The XML import/export base class has had some minor API changes. The - write_combined_feedback method now requires that you pass the questionid and contextid. (MDL-29058) - calls to the import_hints and import_answer methods now should pass the question text format as the last argument, to be used as a default if necessary. (MDL-29739) If you do not upgrade your code, it will not break, but there will be PHP warnings, and it the export will not work 100% correctly. * The old public function requires_qtypes() method is no more. Instead use the ->dependencies facility in version.php. E.g. $plugin->dependencies = array( 'qtype_numerical' => 2011102700, ); * The plugin name and related strings used to be defined in language strings called the same thing as the format, for example: $string['addingdescription'] = 'Adding a Description'; $string['description'] = 'Description'; $string['description_help'] = 'A description is not really a question type. It simply enables text to be displayed without requiring any answers, similar to a label on the course page. $string['description_link'] = 'A description is not really a question type. It simply enables text to be displayed without requiring any answers, similar to a label on the course page. $string['descriptionsummary'] = 'This is not actually a question. Instead it is a way to add some instructions, rubric or other content to the activity. This is similar to the way that labels can be used to add content to the course page.'; $string['editingdescription'] = 'Editing a Description'; All these need to be changed to use the standard string name pluginname, as for other plugin types, and similar for the other strings. $string['pluginname'] = 'Description'; $string['pluginname_help'] = 'A description is not really a question type. It simply enables text to be displayed without requiring any answers, similar to a label on the course page. $string['pluginname_link'] = 'A description is not really a question type. It simply enables text to be displayed without requiring any answers, similar to a label on the course page. $string['pluginnameadding'] = 'Adding a Description'; $string['pluginnameediting'] = 'Editing a Description'; $string['pluginnamesummary'] = 'This is not actually a question. Instead it is a way to add some instructions, rubric or other content to the activity. This is similar to the way that labels can be used to add content to the course page.'; The old strings will continue to work, but only until Moodle 2.3 is released. * If you are using the facilities provided by overriding the extra_answer_fields or questionid_column_name methods, then you must change these to be public methods. (This is required so that backup and restore can be made to work automatically. MDL-24408, MDL-25617, MDL-30562) === 2.1 === * Lots of API changes due to the new question engine. See http://docs.moodle.org/dev/Developing_a_Question_Type#Converting_a_Moodle_2.0_question_type === 2.0 === * Lots of changes due to all the API changes in Moodle 2.0. * This plugin type now supports cron in the standard way. If required, Create a lib.php file containing function qtype_mypluginname_cron() {}; PK\6 /type/match/styles.cssnu[/* Editing form. */ body#page-question-type-match div[id^=fitem_id_][id*=subquestions_] { background: #eee; margin-top: 0; margin-bottom: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-bottom: 0; } body#page-question-type-match div[id^=fitem_id_][id*=subquestions_] .fitemtitle { font-weight: bold; } body#page-question-type-match div[id^=fitem_id_][id*=subanswers_] { background: #eee; margin-bottom: 2em; margin-top: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-top: 0; } /* stylelint-disable declaration-no-important */ div[id^="fitem_id_subquestions_"].mb-3 { margin-bottom: 0 !important; } /* stylelint-enable declaration-no-important */ PK\bttype/match/renderer.phpnu[. /** * Matching question renderer class. * * @package qtype_match * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Generates the output for matching questions. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match_renderer extends qtype_with_combined_feedback_renderer { public function formulation_and_controls(question_attempt $qa, question_display_options $options) { $question = $qa->get_question(); $stemorder = $question->get_stem_order(); $response = $qa->get_last_qt_data(); $choices = $this->format_choices($question); $questiontextid = $qa->get_qt_field_name('qtext'); $result = ''; $result .= html_writer::tag('div', $question->format_questiontext($qa), array('class' => 'qtext', 'id' => $questiontextid)); $result .= html_writer::start_tag('div', array('class' => 'ablock')); $result .= html_writer::start_tag('table', ['class' => 'answer table-reboot', 'role' => 'presentation']); $result .= html_writer::start_tag('tbody', ['role' => 'presentation']); $parity = 0; $i = 1; foreach ($stemorder as $key => $stemid) { $result .= html_writer::start_tag('tr', array('class' => 'r' . $parity, 'role' => 'presentation')); $fieldname = 'sub' . $key; $itemtextid = $qa->get_qt_field_name($fieldname . '_itemtext'); $result .= html_writer::tag('td', $this->format_stem_text($qa, $stemid), array('class' => 'text', 'id' => $itemtextid, 'role' => 'presentation')); $classes = 'control'; $feedbackimage = ''; if (array_key_exists($fieldname, $response)) { $selected = $response[$fieldname]; } else { $selected = 0; } $fraction = (int) ($selected && $selected == $question->get_right_choice_for($stemid)); if ($options->correctness && $selected) { $classes .= ' ' . $this->feedback_class($fraction); $feedbackimage = $this->feedback_image($fraction); } // We only want to add the question text to the first answer field to // avoid repetition of the question text for the subsequent answer fields. if ($i == 1) { $ariadescribedbyids = $questiontextid . ' ' . $itemtextid; } else { $ariadescribedbyids = $itemtextid; } $labeltext = $options->add_question_identifier_to_label(get_string('answer', 'qtype_match', $i)); $result .= html_writer::tag('td', html_writer::label($labeltext, 'menu' . $qa->get_qt_field_name('sub' . $key), false, array('class' => 'accesshide')) . html_writer::select($choices, $qa->get_qt_field_name('sub' . $key), $selected, ['0' => 'choose'], [ 'disabled' => $options->readonly, 'class' => 'form-select d-inline-block ms-1', 'aria-describedby' => $ariadescribedbyids, ]) . ' ' . $feedbackimage, array('class' => $classes, 'role' => 'presentation')); $result .= html_writer::end_tag('tr'); $parity = 1 - $parity; $i++; } $result .= html_writer::end_tag('tbody'); $result .= html_writer::end_tag('table'); $result .= html_writer::end_tag('div'); // Closes
    . if ($qa->get_state() == question_state::$invalid) { $result .= html_writer::nonempty_tag('div', $question->get_validation_error($response), array('class' => 'validationerror')); } return $result; } public function specific_feedback(question_attempt $qa) { return $this->combined_feedback($qa); } /** * Format each question stem. Overwritten by randomsamatch renderer. * * @param question_attempt $qa * @param integer $stemid stem index * @return string */ public function format_stem_text($qa, $stemid) { $question = $qa->get_question(); return $question->format_text( $question->stems[$stemid], $question->stemformat[$stemid], $qa, 'qtype_match', 'subquestion', $stemid); } protected function format_choices($question) { $choices = array(); foreach ($question->get_choice_order() as $key => $choiceid) { $choices[$key] = format_string($question->choices[$choiceid]); } return $choices; } public function correct_response(question_attempt $qa) { $question = $qa->get_question(); $stemorder = $question->get_stem_order(); $choices = $this->format_choices($question); $right = array(); foreach ($stemorder as $key => $stemid) { if (!isset($choices[$question->get_right_choice_for($stemid)])) { continue; } $right[] = $question->make_html_inline($this->format_stem_text($qa, $stemid)) . ' → ' . $choices[$question->get_right_choice_for($stemid)]; } if (!empty($right)) { return get_string('correctansweris', 'qtype_match', implode(', ', $right)); } } } PK\܁wwtype/match/db/upgrade.phpnu[. /** * Matching question type upgrade code. * * @package qtype_match * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the matching question type. * @param int $oldversion the version we are upgrading from. */ function xmldb_qtype_match_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\-* * type/match/db/install.xmlnu[
    PK\#^66type/match/db/upgradelib.phpnu[. /** * Upgrade library code for the match question type. * * @package qtype_match * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Class for converting attempt data for match questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match_qe2_attempt_updater extends question_qtype_attempt_updater { protected $stems; protected $choices; protected $right; protected $stemorder; protected $choiceorder; protected $flippedchoiceorder; public function question_summary() { $this->stems = array(); $this->choices = array(); $this->right = array(); foreach ($this->question->options->subquestions as $matchsub) { $ans = $matchsub->answertext; $key = array_search($matchsub->answertext, $this->choices); if ($key === false) { $key = $matchsub->id; $this->choices[$key] = $matchsub->answertext; } if ($matchsub->questiontext !== '') { $this->stems[$matchsub->id] = $this->to_text($matchsub->questiontext); $this->right[$matchsub->id] = $key; } } return $this->to_text($this->question->questiontext) . ' {' . implode('; ', $this->stems) . '} -> {' . implode('; ', $this->choices) . '}'; } public function right_answer() { $answer = array(); foreach ($this->stems as $key => $stem) { $answer[$stem] = $this->choices[$this->right[$key]]; } return $this->make_summary($answer); } protected function explode_answer($answer) { if (!$answer) { return array(); } $bits = explode(',', $answer); $selections = array(); foreach ($bits as $bit) { list($stem, $choice) = explode('-', $bit); $selections[$stem] = $choice; } return $selections; } protected function make_summary($pairs) { $bits = array(); foreach ($pairs as $stem => $answer) { $bits[] = $stem . ' -> ' . $answer; } return implode('; ', $bits); } protected function lookup_choice($choice) { foreach ($this->question->options->subquestions as $matchsub) { if ($matchsub->id == $choice) { if (array_key_exists($matchsub->id, $this->choices)) { return $matchsub->id; } else { return array_search($matchsub->answertext, $this->choices); } } } return null; } public function response_summary($state) { $choices = $this->explode_answer($state->answer); if (empty($choices)) { return null; } $pairs = array(); foreach ($choices as $stemid => $choicekey) { if (array_key_exists($stemid, $this->stems) && $choices[$stemid]) { $choiceid = $this->lookup_choice($choicekey); if ($choiceid) { $pairs[$this->stems[$stemid]] = $this->choices[$choiceid]; } else { $this->logger->log_assumption("Dealing with a place where the student selected a choice that was later deleted for match question {$this->question->id}"); $pairs[$this->stems[$stemid]] = '[CHOICE THAT WAS LATER DELETED]'; } } } if ($pairs) { return $this->make_summary($pairs); } else { return ''; } } public function was_answered($state) { $choices = $this->explode_answer($state->answer); foreach ($choices as $choice) { if ($choice) { return true; } } return false; } public function set_first_step_data_elements($state, &$data) { $choices = $this->explode_answer($state->answer); foreach ($choices as $key => $notused) { if (array_key_exists($key, $this->stems)) { $this->stemorder[] = $key; } } $this->choiceorder = array_keys($this->choices); shuffle($this->choiceorder); $this->flippedchoiceorder = array_combine( array_values($this->choiceorder), array_keys($this->choiceorder)); $data['_stemorder'] = implode(',', $this->stemorder); $data['_choiceorder'] = implode(',', $this->choiceorder); } public function supply_missing_first_step_data(&$data) { throw new coding_exception('qtype_match_updater::supply_missing_first_step_data ' . 'not tested'); $data['_stemorder'] = array_keys($this->stems); $data['_choiceorder'] = shuffle(array_keys($this->choices)); } public function set_data_elements_for_step($state, &$data) { $choices = $this->explode_answer($state->answer); foreach ($this->stemorder as $i => $key) { if (empty($choices[$key])) { $data['sub' . $i] = 0; continue; } $choice = $this->lookup_choice($choices[$key]); if (array_key_exists($choice, $this->flippedchoiceorder)) { $data['sub' . $i] = $this->flippedchoiceorder[$choice] + 1; } else { $data['sub' . $i] = 0; } } } } PK\v= "type/match/lang/en/qtype_match.phpnu[. /** * Strings for component 'qtype_match', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype_match * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['answer'] = 'Answer {$a}'; $string['availablechoices'] = 'Available choices'; $string['blanksforxmorequestions'] = 'Blanks for {no} more questions'; $string['correctansweris'] = 'The correct answer is: {$a}'; $string['deletedchoice'] = '[Deleted choice]'; $string['deletedsubquestion'] = 'This part of the question was deleted after the attempt was started.'; $string['filloutthreeqsandtwoas'] = 'You must provide at least two questions and three answers. You can provide extra wrong answers by giving an answer with a blank question. Entries where both the question and the answer are blank will be ignored.'; $string['nomatchinganswer'] = 'You must specify an answer matching the question \'{$a}\'.'; $string['nomatchinganswerforq'] = 'You must specify an answer for this question.'; $string['notenoughqsandas'] = 'You must supply at least {$a->q} questions and {$a->a} answers.'; $string['notenoughquestions'] = 'You must supply at least {$a} question and answer pairs.'; $string['pleaseananswerallparts'] = 'Please answer all parts of the question.'; $string['pluginname'] = 'Matching'; $string['pluginname_help'] = 'Matching questions require the respondent to correctly match a list of names or statements (questions) to another list of names or statements (answers).'; $string['pluginname_link'] = 'question/type/match'; $string['pluginnameadding'] = 'Adding a Matching question'; $string['pluginnameediting'] = 'Editing a Matching question'; $string['pluginnamesummary'] = 'The answer to each of a number of sub-question must be selected from a list of possibilities.'; $string['privacy:metadata'] = 'Matching question type plugin allows question authors to set default options as user preferences.'; $string['privacy:preference:defaultmark'] = 'The default mark set for a given question.'; $string['privacy:preference:penalty'] = 'The penalty for each incorrect try when questions are run using the \'Interactive with multiple tries\' or \'Adaptive mode\' behaviour.'; $string['privacy:preference:shuffleanswers'] = 'Whether the answers should be automatically shuffled.'; $string['regradeissuenumchoiceschanged'] = 'The number of choices has changed.'; $string['regradeissuenumstemschanged'] = 'The number of sub-questions has changed.'; $string['shuffle'] = 'Shuffle'; $string['shuffle_help'] = 'If enabled, the order of the questions is randomly shuffled for each attempt, provided that "Shuffle within questions" in the activity settings is also enabled. In a matching question, only the questions are affected by this parameter. The answer choices will always be shuffled.'; PK\2͉&(&(>type/match/backup/moodle2/restore_qtype_match_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Restore plugin class that provides the necessary information * needed to restore one match qtype plugin. * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_match_plugin extends restore_qtype_plugin { /** * A simple answer, questiontext to id cache for a match answers. * @var array */ private $questionsubcache = array(); /** * The id of the current question in the questionsubcache. * @var int */ private $questionsubcacheid = null; /** * Returns the paths to be handled by the plugin at question level. */ protected function define_question_plugin_structure() { $paths = array(); // Add own qtype stuff. $elename = 'matchoptions'; // We used get_recommended_name() so this works. $elepath = $this->get_pathfor('/matchoptions'); $paths[] = new restore_path_element($elename, $elepath); $elename = 'match'; // We used get_recommended_name() so this works. $elepath = $this->get_pathfor('/matches/match'); $paths[] = new restore_path_element($elename, $elepath); return $paths; } /** * Process the qtype/matchoptions element */ public function process_matchoptions($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its qtype_match_options too. if ($questioncreated) { // Fill in some field that were added in 2.1, and so which may be missing // from backups made in older versions of Moodle. if (!isset($data->correctfeedback)) { $data->correctfeedback = ''; $data->correctfeedbackformat = FORMAT_HTML; } if (!isset($data->partiallycorrectfeedback)) { $data->partiallycorrectfeedback = ''; $data->partiallycorrectfeedbackformat = FORMAT_HTML; } if (!isset($data->incorrectfeedback)) { $data->incorrectfeedback = ''; $data->incorrectfeedbackformat = FORMAT_HTML; } if (!isset($data->shownumcorrect)) { $data->shownumcorrect = 0; } // Adjust some columns. $data->questionid = $newquestionid; // It is possible for old backup files to contain unique key violations. // We need to check to avoid that. if (!$DB->record_exists('qtype_match_options', array('questionid' => $data->questionid))) { $newitemid = $DB->insert_record('qtype_match_options', $data); $this->set_mapping('qtype_match_options', $oldid, $newitemid); } } } /** * Process the qtype/matches/match element */ public function process_match($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; if ($questioncreated) { // If the question has been created by restore, we need to create its // qtype_match_subquestions too. // Adjust some columns. $data->questionid = $newquestionid; // Insert record. $newitemid = $DB->insert_record('qtype_match_subquestions', $data); // Create mapping (there are files and states based on this). if (isset($data->code)) { $this->set_mapping('qtype_match_subquestion_codes', $data->code, $newitemid); } } else { // Match questions require mapping of qtype_match_subquestions, because // they are used by question_states->answer. // Have we cached the current question? if ($this->questionsubcacheid !== $newquestionid) { // The question changed, purge and start again! $this->questionsubcache = array(); $params = array('question' => $newquestionid); $potentialsubs = $DB->get_records('qtype_match_subquestions', array('questionid' => $newquestionid), '', 'id, questiontext, answertext'); $this->questionsubcacheid = $newquestionid; // Cache all cleaned answers and questiontext. foreach ($potentialsubs as $potentialsub) { $cleanquestion = core_text::trim_ctrl_chars($potentialsub->questiontext); // Clean CTRL chars. $cleanquestion = preg_replace("/\r\n|\r/", "\n", $cleanquestion); // Normalize line ending. $cleananswer = core_text::trim_ctrl_chars($potentialsub->answertext); // Clean CTRL chars. $cleananswer = preg_replace("/\r\n|\r/", "\n", $cleananswer); // Normalize line ending. $this->questionsubcache[$cleanquestion][$cleananswer] = $potentialsub->id; } } if (!isset($this->questionsubcache[$data->questiontext][$data->answertext])) { throw new restore_step_exception('error_qtype_match_subquestion_missing_in_db', $data); } $newitemid = $this->questionsubcache[$data->questiontext][$data->answertext]; } // Found one. Let's create the mapping. $this->set_mapping('qtype_match_subquestions', $oldid, $newitemid); } public function recode_response($questionid, $sequencenumber, array $response) { if (array_key_exists('_stemorder', $response)) { $response['_stemorder'] = $this->recode_match_sub_order($response['_stemorder']); } if (array_key_exists('_choiceorder', $response)) { $response['_choiceorder'] = $this->recode_match_sub_order($response['_choiceorder']); } return $response; } /** * Given one question_states record, return the answer * recoded pointing to all the restored stuff for match questions. * * answer is one comma separated list of hypen separated pairs * containing question_match_sub->id and question_match_sub->code, which * has been remapped to be qtype_match_subquestions->id, since code no longer exists. */ public function recode_legacy_state_answer($state) { $answer = $state->answer; $resultarr = array(); foreach (explode(',', $answer) as $pair) { $pairarr = explode('-', $pair); $id = $pairarr[0]; $code = $pairarr[1]; $newid = $this->get_mappingid('qtype_match_subquestions', $id); if ($code) { $newcode = $this->get_mappingid('qtype_match_subquestion_codes', $code); } else { $newcode = $code; } $resultarr[] = $newid . '-' . $newcode; } return implode(',', $resultarr); } /** * Recode the choice order as stored in the response. * @param string $order the original order. * @return string the recoded order. */ protected function recode_match_sub_order($order) { $neworder = array(); foreach (explode(',', $order) as $id) { if ($newid = $this->get_mappingid('qtype_match_subquestions', $id)) { $neworder[] = $newid; } } return implode(',', $neworder); } /** * Return the contents of this qtype to be processed by the links decoder. */ public static function define_decode_contents() { $contents = array(); $contents[] = new restore_decode_content('qtype_match_subquestions', array('questiontext'), 'qtype_match_subquestions'); $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); $contents[] = new restore_decode_content('qtype_match_options', $fields, 'qtype_match_options'); return $contents; } #[\Override] public static function convert_backup_to_questiondata(array $backupdata): \stdClass { $questiondata = parent::convert_backup_to_questiondata($backupdata); $questiondata->options = (object) $backupdata["plugin_qtype_match_question"]['matchoptions'][0]; $questiondata->options->subquestions = array_map( fn($match) => (object) $match, $backupdata["plugin_qtype_match_question"]['matches']['match'] ?? [], ); return $questiondata; } #[\Override] protected function define_excluded_identity_hash_fields(): array { return [ '/options/subquestions/id', '/options/subquestions/questionid', ]; } } PK\c۱ =type/match/backup/moodle2/backup_qtype_match_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup match questions. * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_match_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element. */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(null, '../../qtype', 'match'); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // Now create the qtype own structures. $matchoptions = new backup_nested_element('matchoptions', array('id'), array( 'shuffleanswers', 'correctfeedback', 'correctfeedbackformat', 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', 'incorrectfeedback', 'incorrectfeedbackformat', 'shownumcorrect')); $matches = new backup_nested_element('matches'); $match = new backup_nested_element('match', array('id'), array( 'questiontext', 'questiontextformat', 'answertext')); // Now the own qtype tree. $pluginwrapper->add_child($matchoptions); $pluginwrapper->add_child($matches); $matches->add_child($match); // Set source to populate the data. $matchoptions->set_source_table('qtype_match_options', array('questionid' => backup::VAR_PARENTID)); $match->set_source_table('qtype_match_subquestions', array('questionid' => backup::VAR_PARENTID), 'id ASC'); // Don't need to annotate ids nor files. return $plugin; } /** * Returns one array with filearea => mappingname elements for the qtype. * * Used by {@link get_components_and_fileareas} to know about all the qtype * files to be processed both in backup and restore. */ public static function get_qtype_fileareas() { return array( 'subquestion' => 'qtype_match_subquestions'); } } PK\ )f f !type/match/backup/moodle1/lib.phpnu[. /** * @package qtype_match * @copyright 2011 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Matching question type conversion handler. */ class moodle1_qtype_match_handler extends moodle1_qtype_handler { /** * @return array */ public function get_question_subpaths() { return array( 'MATCHOPTIONS', 'MATCHS/MATCH', ); } /** * Appends the match specific information to the question. */ public function process_question(array $data, array $raw) { global $CFG; // Populate the list of matches first to get their ids. // Note that the field is re-populated on restore anyway but let us // do our best to produce valid backup files. $matchids = array(); if (isset($data['matchs']['match'])) { foreach ($data['matchs']['match'] as $match) { $matchids[] = $match['id']; } } // Convert match options. if (isset($data['matchoptions'])) { $matchoptions = $data['matchoptions'][0]; } else { $matchoptions = array('shuffleanswers' => 1); } $matchoptions['id'] = $this->converter->get_nextid(); $matchoptions['subquestions'] = implode(',', $matchids); $this->write_xml('matchoptions', $matchoptions, array('/matchoptions/id')); // Convert matches. $this->xmlwriter->begin_tag('matches'); if (isset($data['matchs']['match'])) { foreach ($data['matchs']['match'] as $match) { // Replay the upgrade step 2009072100. $match['questiontextformat'] = 0; if ($CFG->texteditors !== 'textarea' and $data['oldquestiontextformat'] == FORMAT_MOODLE) { $match['questiontext'] = text_to_html($match['questiontext'], false, false, true); $match['questiontextformat'] = FORMAT_HTML; } else { $match['questiontextformat'] = $data['oldquestiontextformat']; } $match['questiontext'] = $this->migrate_files( $match['questiontext'], 'qtype_match', 'subquestion', $match['id']); $this->write_xml('match', $match, array('/match/id')); } } $this->xmlwriter->end_tag('matches'); } } PK\Ptype/match/pix/icon.gifnu[GIF89attt:::666111((('''"""!,BI +&Mp(!#! $@5IzǛ#r\"Y| ZUr6i'%;PK\ M&VVtype/match/pix/icon.svgnu[PK\9Entype/match/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_match * @copyright Dongsheng Cai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for matching questions. * @package qtype_match * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_match_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $DB, $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_match', $filearea, $args, $forcedownload, $options); } PK\26QQtype/match/version.phpnu[. /** * Version information for the matching question type. * * @package qtype_match * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_match'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\jL++type/match/edit_match_form.phpnu[. /** * Defines the editing form for the match question type. * * @package qtype_match * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Match question type editing form definition. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match_edit_form extends question_edit_form { protected function get_per_answer_fields($mform, $label, $gradeoptions, &$repeatedoptions, &$answersoption) { $mform->addElement('static', 'answersinstruct', get_string('availablechoices', 'qtype_match'), get_string('filloutthreeqsandtwoas', 'qtype_match')); $repeated = array(); $repeated[] = $mform->createElement('editor', 'subquestions', $label, array('rows'=>3), $this->editoroptions); $repeated[] = $mform->createElement('text', 'subanswers', get_string('answer', 'question'), array('size' => 50, 'maxlength' => 255)); $repeatedoptions['subquestions']['type'] = PARAM_RAW; $repeatedoptions['subanswers']['type'] = PARAM_TEXT; $answersoption = 'subquestions'; return $repeated; } /** * Add question-type specific form fields. * * @param object $mform the form being built. */ protected function definition_inner($mform) { $mform->addElement('advcheckbox', 'shuffleanswers', get_string('shuffle', 'qtype_match'), null, null, array(0, 1)); $mform->addHelpButton('shuffleanswers', 'shuffle', 'qtype_match'); $mform->setDefault('shuffleanswers', $this->get_default_value('shuffleanswers', 1)); $this->add_per_answer_fields($mform, get_string('questionno', 'question', '{no}'), 0); $this->add_combined_feedback_fields(true); $this->add_interactive_settings(true, true); } /** * Language string to use for 'Add {no} more {whatever we call answers}'. */ protected function get_more_choices_string() { return get_string('blanksforxmorequestions', 'qtype_match'); } protected function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, true, true); if (empty($question->options)) { return $question; } $question->shuffleanswers = $question->options->shuffleanswers; $key = 0; foreach ($question->options->subquestions as $subquestion) { $question->subanswers[$key] = $subquestion->answertext; $draftid = file_get_submitted_draft_itemid('subquestions[' . $key . ']'); $question->subquestions[$key] = array(); $question->subquestions[$key]['text'] = file_prepare_draft_area($draftid, $this->context->id, 'qtype_match', 'subquestion', !empty($subquestion->id) ? (int) $subquestion->id : null, $this->fileoptions, $subquestion->questiontext); $question->subquestions[$key]['format'] = $subquestion->questiontextformat; $question->subquestions[$key]['itemid'] = $draftid; $key++; } return $question; } public function validation($data, $files) { $errors = parent::validation($data, $files); $answers = $data['subanswers']; $questions = $data['subquestions']; $questioncount = 0; $answercount = 0; foreach ($questions as $key => $question) { $trimmedquestion = trim($question['text']); $trimmedanswer = trim($answers[$key]); if ($trimmedquestion != '') { $questioncount++; } if ($trimmedanswer != '' || $trimmedquestion != '') { $answercount++; } if ($trimmedquestion != '' && $trimmedanswer == '') { $errors['subanswers['.$key.']'] = get_string('nomatchinganswerforq', 'qtype_match', $trimmedquestion); } } $numberqanda = new stdClass(); $numberqanda->q = 2; $numberqanda->a = 3; if ($questioncount < 1) { $errors['subquestions[0]'] = get_string('notenoughqsandas', 'qtype_match', $numberqanda); } if ($questioncount < 2) { $errors['subquestions[1]'] = get_string('notenoughqsandas', 'qtype_match', $numberqanda); } if ($answercount < 3) { $errors['subanswers[2]'] = get_string('notenoughqsandas', 'qtype_match', $numberqanda); } return $errors; } public function qtype() { return 'match'; } } PK\h+>>type/match/question.phpnu[. /** * Matching question definition class. * * @package qtype_match * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * Represents a matching question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match_question extends question_graded_automatically_with_countback { /** @var boolean Whether the question stems should be shuffled. */ public $shufflestems; public $correctfeedback; public $correctfeedbackformat; public $partiallycorrectfeedback; public $partiallycorrectfeedbackformat; public $incorrectfeedback; public $incorrectfeedbackformat; /** @var array of question stems. */ public $stems; /** @var int[] FORMAT_... type for each stem. */ public $stemformat; /** @var array of choices that can be matched to each stem. */ public $choices; /** @var array index of the right choice for each stem. */ public $right; /** @var array shuffled stem indexes. */ protected $stemorder; /** @var array shuffled choice indexes. */ protected $choiceorder; public function start_attempt(question_attempt_step $step, $variant) { $this->stemorder = array_keys($this->stems); if ($this->shufflestems) { shuffle($this->stemorder); } $step->set_qt_var('_stemorder', implode(',', $this->stemorder)); $choiceorder = array_keys($this->choices); shuffle($choiceorder); $step->set_qt_var('_choiceorder', implode(',', $choiceorder)); $this->set_choiceorder($choiceorder); } public function apply_attempt_state(question_attempt_step $step) { $this->stemorder = explode(',', $step->get_qt_var('_stemorder')); $this->set_choiceorder(explode(',', $step->get_qt_var('_choiceorder'))); // Add any missing subquestions. Sometimes people edit questions after they // have been attempted which breaks things. foreach ($this->stemorder as $stemid) { if (!isset($this->stems[$stemid])) { $this->stems[$stemid] = html_writer::span( get_string('deletedsubquestion', 'qtype_match'), 'notifyproblem'); $this->stemformat[$stemid] = FORMAT_HTML; $this->right[$stemid] = 0; } } // Add any missing choices. Sometimes people edit questions after they // have been attempted which breaks things. foreach ($this->choiceorder as $choiceid) { if (!isset($this->choices[$choiceid])) { $this->choices[$choiceid] = get_string('deletedchoice', 'qtype_match'); } } } /** * Helper method used by both {@link start_attempt()} and * {@link apply_attempt_state()}. * @param array $choiceorder the choices, in order. */ protected function set_choiceorder($choiceorder) { $this->choiceorder = array(); foreach ($choiceorder as $key => $choiceid) { $this->choiceorder[$key + 1] = $choiceid; } } public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string { $basemessage = parent::validate_can_regrade_with_other_version($otherversion); if ($basemessage) { return $basemessage; } if (count($this->stems) != count($otherversion->stems)) { return get_string('regradeissuenumstemschanged', 'qtype_match'); } if (count($this->choices) != count($otherversion->choices)) { return get_string('regradeissuenumchoiceschanged', 'qtype_match'); } return null; } public function update_attempt_state_data_for_new_version( question_attempt_step $oldstep, question_definition $otherversion) { $startdata = parent::update_attempt_state_data_for_new_version($oldstep, $otherversion); // Process stems. $mapping = array_combine(array_keys($otherversion->stems), array_keys($this->stems)); $oldstemorder = explode(',', $oldstep->get_qt_var('_stemorder')); $newstemorder = []; foreach ($oldstemorder as $oldid) { $newstemorder[] = $mapping[$oldid] ?? $oldid; } $startdata['_stemorder'] = implode(',', $newstemorder); // Process choices. $mapping = array_combine(array_keys($otherversion->choices), array_keys($this->choices)); $oldchoiceorder = explode(',', $oldstep->get_qt_var('_choiceorder')); $newchoiceorder = []; foreach ($oldchoiceorder as $oldid) { $newchoiceorder[] = $mapping[$oldid] ?? $oldid; } $startdata['_choiceorder'] = implode(',', $newchoiceorder); return $startdata; } public function get_question_summary() { $question = $this->html_to_text($this->questiontext, $this->questiontextformat); $stems = array(); foreach ($this->stemorder as $stemid) { $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]); } $choices = array(); foreach ($this->choiceorder as $choiceid) { $choices[] = $this->choices[$choiceid]; } return $question . ' {' . implode('; ', $stems) . '} -> {' . implode('; ', $choices) . '}'; } public function summarise_response(array $response) { $matches = array(); foreach ($this->stemorder as $key => $stemid) { if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) { $matches[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]) . ' -> ' . $this->choices[$this->choiceorder[$response[$this->field($key)]]]; } } if (empty($matches)) { return null; } return implode('; ', $matches); } public function classify_response(array $response) { $selectedchoicekeys = array(); foreach ($this->stemorder as $key => $stemid) { if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) { $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]]; } else { $selectedchoicekeys[$stemid] = 0; } } $parts = array(); foreach ($this->stems as $stemid => $stem) { if ($this->right[$stemid] == 0 || !isset($selectedchoicekeys[$stemid])) { // Choice for a deleted subquestion, ignore. (See apply_attempt_state.) continue; } $selectedchoicekey = $selectedchoicekeys[$stemid]; if (empty($selectedchoicekey)) { $parts[$stemid] = question_classified_response::no_response(); continue; } $choice = $this->choices[$selectedchoicekey]; if ($choice == get_string('deletedchoice', 'qtype_match')) { // Deleted choice, ignore. (See apply_attempt_state.) continue; } $parts[$stemid] = new question_classified_response( $selectedchoicekey, $choice, ($selectedchoicekey == $this->right[$stemid]) / count($this->stems)); } return $parts; } public function clear_wrong_from_response(array $response) { foreach ($this->stemorder as $key => $stemid) { if (!array_key_exists($this->field($key), $response) || $response[$this->field($key)] != $this->get_right_choice_for($stemid)) { $response[$this->field($key)] = 0; } } return $response; } public function get_num_parts_right(array $response) { $numright = 0; foreach ($this->stemorder as $key => $stemid) { $fieldname = $this->field($key); if (!array_key_exists($fieldname, $response)) { continue; } $choice = $response[$fieldname]; if ($choice && $this->choiceorder[$choice] == $this->right[$stemid]) { $numright += 1; } } return array($numright, count($this->stemorder)); } /** * @param int $key stem number * @return string the question-type variable name. */ protected function field($key) { return 'sub' . $key; } public function get_expected_data() { $vars = array(); foreach ($this->stemorder as $key => $notused) { $vars[$this->field($key)] = PARAM_INT; } return $vars; } public function get_correct_response() { $response = array(); foreach ($this->stemorder as $key => $stemid) { $response[$this->field($key)] = $this->get_right_choice_for($stemid); } return $response; } public function prepare_simulated_post_data($simulatedresponse) { $postdata = array(); $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS)); $choicetochoiceno = array_flip($this->choices); $choicenotochoiceselectvalue = array_flip($this->choiceorder); foreach ($simulatedresponse as $stem => $choice) { $choice = clean_param($choice, PARAM_NOTAGS); $stemid = $stemtostemids[$stem]; $shuffledstemno = array_search($stemid, $this->stemorder); if (empty($choice)) { $choiceselectvalue = 0; } else if ($choicetochoiceno[$choice]) { $choiceselectvalue = $choicenotochoiceselectvalue[$choicetochoiceno[$choice]]; } else { throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}."); } $postdata[$this->field($shuffledstemno)] = $choiceselectvalue; } return $postdata; } public function get_student_response_values_for_simulation($postdata) { $simulatedresponse = array(); foreach ($this->stemorder as $shuffledstemno => $stemid) { if (!empty($postdata[$this->field($shuffledstemno)])) { $choiceselectvalue = $postdata[$this->field($shuffledstemno)]; $choiceno = $this->choiceorder[$choiceselectvalue]; $choice = clean_param($this->choices[$choiceno], PARAM_NOTAGS); $stem = clean_param($this->stems[$stemid], PARAM_NOTAGS); $simulatedresponse[$stem] = $choice; } } ksort($simulatedresponse); return $simulatedresponse; } public function get_right_choice_for($stemid) { foreach ($this->choiceorder as $choicekey => $choiceid) { if ($this->right[$stemid] == $choiceid) { return $choicekey; } } } public function is_complete_response(array $response) { $complete = true; foreach ($this->stemorder as $key => $stemid) { $complete = $complete && !empty($response[$this->field($key)]); } return $complete; } public function is_gradable_response(array $response) { foreach ($this->stemorder as $key => $stemid) { if (!empty($response[$this->field($key)])) { return true; } } return false; } public function get_validation_error(array $response) { if ($this->is_complete_response($response)) { return ''; } return get_string('pleaseananswerallparts', 'qtype_match'); } public function is_same_response(array $prevresponse, array $newresponse) { foreach ($this->stemorder as $key => $notused) { $fieldname = $this->field($key); if (!question_utils::arrays_same_at_key_integer( $prevresponse, $newresponse, $fieldname)) { return false; } } return true; } public function grade_response(array $response) { list($right, $total) = $this->get_num_parts_right($response); $fraction = $right / $total; return array($fraction, question_state::graded_state_for_fraction($fraction)); } public function compute_final_grade($responses, $totaltries) { $totalstemscore = 0; foreach ($this->stemorder as $key => $stemid) { $fieldname = $this->field($key); $lastwrongindex = -1; $finallyright = false; foreach ($responses as $i => $response) { if (!array_key_exists($fieldname, $response) || !$response[$fieldname] || $this->choiceorder[$response[$fieldname]] != $this->right[$stemid]) { $lastwrongindex = $i; $finallyright = false; } else { $finallyright = true; } } if ($finallyright) { $totalstemscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty); } } return $totalstemscore / count($this->stemorder); } public function get_stem_order() { return $this->stemorder; } public function get_choice_order() { return $this->choiceorder; } public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($component == 'qtype_match' && $filearea == 'subquestion') { $subqid = reset($args); // Itemid is sub question id. return array_key_exists($subqid, $this->stems); } else if ($component == 'question' && in_array($filearea, array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) { return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args); } else if ($component == 'question' && $filearea == 'hint') { return $this->check_hint_file_access($qa, $options, $args); } else { return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } } /** * Return the question settings that define this question as structured data. * * @param question_attempt $qa the current attempt for which we are exporting the settings. * @param question_display_options $options the question display options which say which aspects of the question * should be visible. * @return mixed structure representing the question settings. In web services, this will be JSON-encoded. */ public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) { // This is a partial implementation, returning only the most relevant question settings for now, // ideally, we should return as much as settings as possible (depending on the state and display options). return [ 'shufflestems' => $this->shufflestems, ]; } } PK\p6^ ^ 'type/match/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_match. * * @package qtype_match * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_match\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\transform; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_match implementing user_preference_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This component has data. // We need to return default options that have been set a user preferences. \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qtype_match_defaultmark', 'privacy:preference:defaultmark'); $collection->add_user_preference('qtype_match_penalty', 'privacy:preference:penalty'); $collection->add_user_preference('qtype_match_shuffleanswers', 'privacy:preference:shuffleanswers'); return $collection; } /** * Export all user preferences for the plugin. * * @param int $userid The userid of the user whose data is to be exported. */ public static function export_user_preferences(int $userid) { $preference = get_user_preferences('qtype_match_defaultmark', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:defaultmark', 'qtype_match'); writer::export_user_preference('qtype_match', 'defaultmark', $preference, $desc); } $preference = get_user_preferences('qtype_match_penalty', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:penalty', 'qtype_match'); writer::export_user_preference('qtype_match', 'penalty', transform::percentage($preference), $desc); } $preference = get_user_preferences('qtype_match_shuffleanswers', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:shuffleanswers', 'qtype_match'); writer::export_user_preference('qtype_match', 'shuffleanswers', transform::yesno($preference), $desc); } } } PK\-1type/match/tests/fixtures/testquestion.moodle.xmlnu[ $course$/Test questions matching-001 Classify the animals. General feedback. 1.0000000 0.3333333 0 true Well done! Parts, but only parts, of your response are correct. That is not right at all. frog amphibian cat mammal newt amphibian insect PK\9wH:H:"type/match/tests/question_test.phpnu[. namespace qtype_match; use question_attempt_step; use question_classified_response; use question_display_options; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the matching question definition class. * * @package qtype_match * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_match_question */ final class question_test extends \advanced_testcase { public function test_get_expected_data(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('sub0' => PARAM_INT, 'sub1' => PARAM_INT, 'sub2' => PARAM_INT, 'sub3' => PARAM_INT), $question->get_expected_data()); } public function test_is_complete_response(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $this->assertFalse($question->is_complete_response(array())); $this->assertFalse($question->is_complete_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '0'))); $this->assertFalse($question->is_complete_response(array('sub1' => '1'))); $this->assertTrue($question->is_complete_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '1'))); } public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $this->assertFalse($question->is_gradable_response(array())); $this->assertFalse($question->is_gradable_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_gradable_response( array('sub0' => '1', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_gradable_response(array('sub1' => '1'))); $this->assertTrue($question->is_gradable_response( array('sub0' => '1', 'sub1' => '1', 'sub2' => '3', 'sub3' => '1'))); } public function test_is_same_response(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $this->assertTrue($question->is_same_response( array(), array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertTrue($question->is_same_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); $this->assertFalse($question->is_same_response( array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); $this->assertTrue($question->is_same_response( array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); $this->assertFalse($question->is_same_response( array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); } public function test_grading(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $correctresponse = $question->prepare_simulated_post_data( array('Dog' => 'Mammal', 'Frog' => 'Amphibian', 'Toad' => 'Amphibian', 'Cat' => 'Mammal')); $this->assertEquals(array(1, question_state::$gradedright), $question->grade_response($correctresponse)); $partialresponse = $question->prepare_simulated_post_data(array('Dog' => 'Mammal')); $this->assertEquals(array(0.25, question_state::$gradedpartial), $question->grade_response($partialresponse)); $partiallycorrectresponse = $question->prepare_simulated_post_data( array('Dog' => 'Mammal', 'Frog' => 'Insect', 'Toad' => 'Insect', 'Cat' => 'Amphibian')); $this->assertEquals(array(0.25, question_state::$gradedpartial), $question->grade_response($partiallycorrectresponse)); $wrongresponse = $question->prepare_simulated_post_data( array('Dog' => 'Amphibian', 'Frog' => 'Insect', 'Toad' => 'Insect', 'Cat' => 'Amphibian')); $this->assertEquals(array(0, question_state::$gradedwrong), $question->grade_response($wrongresponse)); } public function test_get_correct_response(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $correct = $question->prepare_simulated_post_data(array('Dog' => 'Mammal', 'Frog' => 'Amphibian', 'Toad' => 'Amphibian', 'Cat' => 'Mammal')); $this->assertEquals($correct, $question->get_correct_response()); } public function test_get_question_summary(): void { $match = \test_question_maker::make_question('match'); $match->start_attempt(new question_attempt_step(), 1); $qsummary = $match->get_question_summary(); $this->assertMatchesRegularExpression('/' . preg_quote($match->questiontext, '/') . '/', $qsummary); foreach ($match->stems as $stem) { $this->assertMatchesRegularExpression('/' . preg_quote($stem, '/') . '/', $qsummary); } foreach ($match->choices as $choice) { $this->assertMatchesRegularExpression('/' . preg_quote($choice, '/') . '/', $qsummary); } } public function test_summarise_response(): void { $match = \test_question_maker::make_question('match'); $match->start_attempt(new question_attempt_step(), 1); $summary = $match->summarise_response($match->prepare_simulated_post_data(array('Dog' => 'Amphibian', 'Frog' => 'Mammal'))); $this->assertMatchesRegularExpression('/Dog -> Amphibian/', $summary); $this->assertMatchesRegularExpression('/Frog -> Mammal/', $summary); } public function test_classify_response(): void { $match = \test_question_maker::make_question('match'); $match->start_attempt(new question_attempt_step(), 1); $response = $match->prepare_simulated_post_data(array('Dog' => 'Amphibian', 'Frog' => 'Insect', 'Toad' => '', 'Cat' => '')); $this->assertEquals(array( 1 => new question_classified_response(2, 'Amphibian', 0), 2 => new question_classified_response(3, 'Insect', 0), 3 => question_classified_response::no_response(), 4 => question_classified_response::no_response(), ), $match->classify_response($response)); $response = $match->prepare_simulated_post_data(array('Dog' => 'Mammal', 'Frog' => 'Amphibian', 'Toad' => 'Amphibian', 'Cat' => 'Mammal')); $this->assertEquals(array( 1 => new question_classified_response(1, 'Mammal', 0.25), 2 => new question_classified_response(2, 'Amphibian', 0.25), 3 => new question_classified_response(2, 'Amphibian', 0.25), 4 => new question_classified_response(1, 'Mammal', 0.25), ), $match->classify_response($response)); } public function test_classify_response_choice_deleted_after_attempt(): void { $match = \test_question_maker::make_question('match'); $firststep = new question_attempt_step(); $match->start_attempt($firststep, 1); $response = $match->prepare_simulated_post_data(array( 'Dog' => 'Amphibian', 'Frog' => 'Insect', 'Toad' => '', 'Cat' => 'Mammal')); $match = \test_question_maker::make_question('match'); unset($match->stems[4]); unset($match->stemformat[4]); unset($match->right[4]); $match->apply_attempt_state($firststep); $this->assertEquals(array( 1 => new question_classified_response(2, 'Amphibian', 0), 2 => new question_classified_response(3, 'Insect', 0), 3 => question_classified_response::no_response(), ), $match->classify_response($response)); } public function test_classify_response_choice_added_after_attempt(): void { $match = \test_question_maker::make_question('match'); $firststep = new question_attempt_step(); $match->start_attempt($firststep, 1); $response = $match->prepare_simulated_post_data(array( 'Dog' => 'Amphibian', 'Frog' => 'Insect', 'Toad' => '', 'Cat' => 'Mammal')); $match = \test_question_maker::make_question('match'); $match->stems[5] = "Snake"; $match->stemformat[5] = FORMAT_HTML; $match->choices[5] = "Reptile"; $match->right[5] = 5; $match->apply_attempt_state($firststep); $this->assertEquals(array( 1 => new question_classified_response(2, 'Amphibian', 0), 2 => new question_classified_response(3, 'Insect', 0), 3 => question_classified_response::no_response(), 4 => new question_classified_response(1, 'Mammal', 0.20), ), $match->classify_response($response)); } public function test_prepare_simulated_post_data(): void { $m = \test_question_maker::make_question('match'); $m->start_attempt(new question_attempt_step(), 1); $postdata = $m->prepare_simulated_post_data(array('Dog' => 'Mammal', 'Frog' => 'Amphibian', 'Toad' => 'Amphibian', 'Cat' => 'Mammal')); $this->assertEquals(array(4, 4), $m->get_num_parts_right($postdata)); } /** * test_get_question_definition_for_external_rendering */ public function test_get_question_definition_for_external_rendering(): void { $question = \test_question_maker::make_question('match'); $question->start_attempt(new question_attempt_step(), 1); $qa = \test_question_maker::get_a_qa($question); $displayoptions = new question_display_options(); $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions); $this->assertEquals(1, $options['shufflestems']); } public function test_validate_can_regrade_with_other_version_ok(): void { $m = \test_question_maker::make_question('match'); $newm = clone($m); $this->assertNull($newm->validate_can_regrade_with_other_version($m)); } public function test_validate_can_regrade_with_other_version_bad_stems(): void { $m = \test_question_maker::make_question('match'); $newm = clone($m); unset($newm->stems[4]); $this->assertEquals(get_string('regradeissuenumstemschanged', 'qtype_match'), $newm->validate_can_regrade_with_other_version($m)); } public function test_validate_can_regrade_with_other_version_bad_choices(): void { $m = \test_question_maker::make_question('match'); $newm = clone($m); unset($newm->choices[3]); $this->assertEquals(get_string('regradeissuenumchoiceschanged', 'qtype_match'), $newm->validate_can_regrade_with_other_version($m)); } public function test_update_attempt_state_date_from_old_version_bad(): void { $m = \test_question_maker::make_question('match'); $newm = clone($m); $newm->stems = [11 => 'Dog', 12 => 'Frog', 13 => 'Toad', 14 => 'Cat', 15 => 'Hippopotamus']; $newm->stemformat = [11 => FORMAT_HTML, 12 => FORMAT_HTML, 13 => FORMAT_HTML, 14 => FORMAT_HTML, 15 => FORMAT_HTML]; $newm->choices = [11 => 'Mammal', 12 => 'Amphibian', 13 => 'Insect']; $newm->right = [11 => 11, 12 => 12, 13 => 12, 14 => 11, 15 => 11]; $oldstep = new question_attempt_step(); $oldstep->set_qt_var('_stemorder', '4,1,3,2'); $oldstep->set_qt_var('_choiceorder', '2,3,1'); $this->expectExceptionMessage(get_string('regradeissuenumstemschanged', 'qtype_match')); $newm->update_attempt_state_data_for_new_version($oldstep, $m); } public function test_update_attempt_state_date_from_old_version_ok(): void { $m = \test_question_maker::make_question('match'); $newm = clone($m); $newm->stems = [11 => 'Dog', 12 => 'Frog', 13 => 'Toad', 14 => 'Cat']; $newm->stemformat = [11 => FORMAT_HTML, 12 => FORMAT_HTML, 13 => FORMAT_HTML, 14 => FORMAT_HTML]; $newm->choices = [11 => 'Mammal', 12 => 'Amphibian', 13 => 'Insect']; $newm->right = [11 => 11, 12 => 12, 13 => 12, 14 => 11]; $oldstep = new question_attempt_step(); $oldstep->set_qt_var('_stemorder', '4,1,3,2'); $oldstep->set_qt_var('_choiceorder', '2,3,1'); $this->assertEquals(['_stemorder' => '14,11,13,12', '_choiceorder' => '12,13,11'], $newm->update_attempt_state_data_for_new_version($oldstep, $m)); } } PK\rVYY*type/match/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package qtype_match * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_match\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\user_preference_provider; use qtype_match\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/match/classes/privacy/provider.php'); /** * Privacy provider tests class. * * @package qtype_match * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_match'); $actual = \qtype_match\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_match_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_match'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_match_{$key}", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_match'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the multiple choice question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'default mark 1' => ['defaultmark', 1, 1], 'penalty 33.33333%' => ['penalty', 0.3333333, '33.33333%'], 'shuffle yes' => ['shuffleanswers', 1, 'Yes'], 'shuffle no' => ['shuffleanswers', 0, 'No'] ]; } } PK\d d type/match/tests/backup_test.phpnu[. namespace qtype_match; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot . '/course/externallib.php'); /** * Tests for the matching question type backup and restore logic. * * @package qtype_match * @copyright 2020 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. */ final class backup_test extends \advanced_testcase { /** * Duplicate quiz with a matching question, and check it worked. */ public function test_duplicate_match_question(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $coregenerator = $this->getDataGenerator(); $questiongenerator = $coregenerator->get_plugin_generator('core_question'); // Create a course with a page that embeds a question. $course = $coregenerator->create_course(); $quiz = $coregenerator->create_module('quiz', ['course' => $course->id]); $quizcontext = \context_module::instance($quiz->cmid); $cat = $questiongenerator->create_question_category(['contextid' => $quizcontext->id]); $question = $questiongenerator->create_question('match', 'trickynums', ['category' => $cat->id]); // Store some counts. $numquizzes = count(get_fast_modinfo($course)->instances['quiz']); $nummatchquestions = $DB->count_records('question', ['qtype' => 'match']); // Duplicate the page. duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); // Verify the copied quiz exists. $this->assertCount($numquizzes + 1, get_fast_modinfo($course)->instances['quiz']); // Verify the copied question. $this->assertEquals($nummatchquestions + 1, $DB->count_records('question', ['qtype' => 'match'])); $newmatchid = $DB->get_field_sql(" SELECT MAX(id) FROM {question} WHERE qtype = ? ", ['match']); $matchdata = question_bank::load_question_data($newmatchid); $subquestions = array_values($matchdata->options->subquestions); $this->assertSame('System.out.println(0);', $subquestions[0]->questiontext); $this->assertSame('0', $subquestions[0]->answertext); $this->assertSame('System.out.println(0.0);', $subquestions[1]->questiontext); $this->assertSame('0.0', $subquestions[1]->answertext); $this->assertSame('', $subquestions[2]->questiontext); $this->assertSame('NULL', $subquestions[2]->answertext); } } PK\6,n""'type/match/tests/question_type_test.phpnu[. namespace qtype_match; use qtype_match; use qtype_match_edit_form; use question_bank; use question_possible_response; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/match/questiontype.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/match/edit_match_form.php'); /** * Unit tests for the matching question definition class. * * @package qtype_match * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \advanced_testcase { /** @var qtype_match instance of the question type class to test. */ protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_match(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } protected function get_test_question_data() { global $USER; $q = new \stdClass(); $q->id = 0; $q->name = 'Matching question'; $q->category = 0; $q->contextid = 0; $q->parent = 0; $q->questiontext = 'Classify the animals.'; $q->questiontextformat = FORMAT_HTML; $q->generalfeedback = 'General feedback.'; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->versionid = 0; $q->questionbankentryid = 0; $q->idnumber = null; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; $q->modifiedby = $USER->id; $q->options = new \stdClass(); $q->options->shuffleanswers = false; \test_question_maker::set_standard_combined_feedback_fields($q->options); $q->options->subquestions = array( 14 => (object) array( 'id' => 14, 'questiontext' => 'frog', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'amphibian'), 15 => (object) array( 'id' => 15, 'questiontext' => 'cat', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'mammal'), 16 => (object) array( 'id' => 16, 'questiontext' => 'newt', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'amphibian'), 17 => (object) array( 'id' => 17, 'questiontext' => '', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'insect'), ); return $q; } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'match'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_make_question_instance(): void { $questiondata = \test_question_maker::get_question_data('match', 'trickynums'); $question = question_bank::make_question($questiondata); $this->assertEquals($questiondata->name, $question->name); $this->assertEquals($questiondata->questiontext, $question->questiontext); $this->assertEquals($questiondata->questiontextformat, $question->questiontextformat); $this->assertEquals($questiondata->generalfeedback, $question->generalfeedback); $this->assertEquals($questiondata->generalfeedbackformat, $question->generalfeedbackformat); $this->assertInstanceOf('qtype_match', $question->qtype); $this->assertEquals($questiondata->options->shuffleanswers, $question->shufflestems); $this->assertEquals( [14 => 'System.out.println(0);', 15 => 'System.out.println(0.0);'], $question->stems); $this->assertEquals([14 => '0', 15 => '0.0', 16 => 'NULL'], $question->choices); $this->assertEquals([14 => 14, 15 => 15], $question->right); } public function test_get_random_guess_score(): void { $q = $this->get_test_question_data(); $this->assertEqualsWithDelta(0.3333333, $this->qtype->get_random_guess_score($q), 0.0000001); } public function test_get_possible_responses(): void { $q = $this->get_test_question_data(); $this->assertEquals(array( 14 => array( 14 => new question_possible_response('frog: amphibian', 1/3), 15 => new question_possible_response('frog: mammal', 0), 17 => new question_possible_response('frog: insect', 0), null => question_possible_response::no_response()), 15 => array( 14 => new question_possible_response('cat: amphibian', 0), 15 => new question_possible_response('cat: mammal', 1/3), 17 => new question_possible_response('cat: insect', 0), null => question_possible_response::no_response()), 16 => array( 14 => new question_possible_response('newt: amphibian', 1/3), 15 => new question_possible_response('newt: mammal', 0), 17 => new question_possible_response('newt: insect', 0), null => question_possible_response::no_response()), ), $this->qtype->get_possible_responses($q)); } public function test_question_saving_foursubq(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questiondata = \test_question_maker::get_question_data('match'); $formdata = \test_question_maker::get_question_form_data('match'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(array()); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_match_edit_form::mock_submit((array)$formdata); $form = \qtype_match_test_helper::get_question_editing_form($cat, $questiondata); $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); // Create a new question version with the form submission. unset($questiondata->id); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'stamp', 'versionid', 'questionbankentryid', 'hints'])) { if (!empty($actualquestiondata)) { $this->assertEquals($value, $actualquestiondata->$property); } } } foreach ($questiondata->options as $optionname => $value) { if ($optionname != 'subquestions') { $this->assertEquals($value, $actualquestiondata->options->$optionname); } } $this->assertCount(1, $actualquestiondata->hints); $hint = array_pop($actualquestiondata->hints); $this->assertEquals($formdata->hint[0]['text'], $hint->hint); $this->assertEquals($formdata->hint[0]['format'], $hint->hintformat); $this->assertObjectHasProperty('subquestions', $actualquestiondata->options); $subqpropstoignore = array('id'); foreach ($questiondata->options->subquestions as $subq) { $actualsubq = array_shift($actualquestiondata->options->subquestions); foreach ($subq as $subqproperty => $subqvalue) { if (!in_array($subqproperty, $subqpropstoignore)) { $this->assertEquals($subqvalue, $actualsubq->$subqproperty); } } } } } PK\%FgFg%type/match/tests/walkthrough_test.phpnu[. namespace qtype_match; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the matching question type. * * @package qtype_match * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_deferred_feedback_unanswered(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->shufflestems = false; $this->start_attempt_at_question($m, 'deferredfeedback', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); $this->check_step_count(1); // Save a blank response. $this->process_submission(array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); $this->check_step_count(1); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, false), $this->get_contains_select_expectation('sub1', $choices, null, false), $this->get_contains_select_expectation('sub2', $choices, null, false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_deferred_feedback_partial_answer(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->shufflestems = false; $this->start_attempt_at_question($m, 'deferredfeedback', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save a partial response. $this->process_submission(array('sub0' => $orderforchoice[1], 'sub1' => $orderforchoice[2], 'sub2' => '0', 'sub3' => '0')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], true), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_partcorrect_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub2', $choices, null, false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_interactive_correct_no_submit(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save the right answer. $this->process_submission(array('sub0' => $orderforchoice[1], 'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[2], 'sub3' => $orderforchoice[1])); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(4); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false)); // Regrade with a new version of the question. /** @var \qtype_match_question $oldm */ $oldm = \test_question_maker::make_question('match'); $oldm->stems = [11 => 'Dog', 12 => 'Frog', 13 => 'Toad', 14 => 'Cat']; $oldm->stemformat = [11 => FORMAT_HTML, 12 => FORMAT_HTML, 13 => FORMAT_HTML, 14 => FORMAT_HTML]; $oldm->choices = [11 => 'Mammal', 12 => 'Amphibian', 13 => 'Insect']; $oldm->right = [11 => 11, 12 => 12, 13 => 12, 14 => 11]; $this->quba->regrade_question($this->slot, true, null, $oldm); // Verify. $this->check_current_mark(4); $this->render(); $this->assertStringContainsString('Cat', $this->currentoutput); $this->assertStringContainsString('Insect', $this->currentoutput); $this->assertStringNotContainsString( get_string('deletedsubquestion', 'qtype_match'), $this->currentoutput); $this->assertStringNotContainsString( get_string('deletedchoice', 'qtype_match'), $this->currentoutput); } public function test_interactive_partial_no_submit(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Save the right answer. $this->process_submission(array('sub0' => $orderforchoice[1], 'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[1], 'sub3' => '0')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_partcorrect_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[1], false), $this->get_contains_select_expectation('sub3', $choices, null, false)); } public function test_interactive_with_invalid(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Try to submit an invalid answer. $this->process_submission(array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0', '-submit' => '1')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_invalid_answer_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Now submit the right answer. $this->process_submission(array('sub0' => $orderforchoice[1], 'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[2], 'sub3' => $orderforchoice[1], '-submit' => '1')); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(4); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false)); } public function test_match_with_tricky_html_choices(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->stems = array( 1 => '(1, 2]', 2 => '[1, 2]', 3 => '[1, 2)', ); $m->choices = array( 1 => '1 < x ≤ 2', 2 => '1 ≤ x ≤ 2', 3 => '1 ≤ x < 2', ); $m->right = array(1 => 1, 2 => 2, 3 => 3); $m->shufflestems = false; $this->start_attempt_at_question($m, 'deferredfeedback', 3); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); // Do not use check_output_contains_selectoptions as there are multibyte characters ('1 ≤ x ≤ 2') that // seem to be read as ascii ('1 ≤ x ≤ 2') in this test. $this->check_current_output( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_question_text_expectation($m), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); $rightresponse = array('sub0' => $orderforchoice[1], 'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[3]); $rightresponsesummary = '(1, 2] -> 1 < x ≤ 2; [1, 2] -> 1 ≤ x ≤ 2; [1, 2) -> 1 ≤ x < 2'; $this->process_submission($rightresponse); $this->finish(); $this->assertEquals($rightresponsesummary, $m->summarise_response($rightresponse)); $this->displayoptions->history = 1; $this->check_current_output( new \question_pattern_expectation('/' . preg_quote(htmlspecialchars($rightresponsesummary, ENT_COMPAT), '/') . '/')); } public function test_match_clear_wrong(): void { // Create a matching question. $m = \test_question_maker::make_question('match'); $m->hints = array( new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, true), new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), ); $m->shufflestems = false; $this->start_attempt_at_question($m, 'interactive', 4); $choiceorder = $m->get_choice_order(); $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); $choices = array(0 => get_string('choose') . '...'); foreach ($choiceorder as $key => $choice) { $choices[$key] = $m->choices[$choice]; } // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Submit a completely wrong response. $this->process_submission(array('sub0' => $orderforchoice[3], 'sub1' => $orderforchoice[3], 'sub2' => $orderforchoice[3], 'sub3' => $orderforchoice[3], '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub2', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub3', '0'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint.')); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[3], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[3], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[3], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[3], false)); // Try again. $this->process_submission(array('sub0' => 0, 'sub1' => 0, 'sub2' => 0, 'sub3' => 0, '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, null, true), $this->get_contains_select_expectation('sub3', $choices, null, true)); // Submit a partially wrong response. $this->process_submission(array('sub0' => $orderforchoice[3], 'sub1' => $orderforchoice[3], 'sub2' => $orderforchoice[2], 'sub3' => $orderforchoice[1], '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub1', '0'), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub2', $orderforchoice[2]), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[1]), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the second hint.')); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[3], false), $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[3], false), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], false), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false)); // Try again. $this->process_submission(array('sub0' => 0, 'sub1' => 0, 'sub2' => $orderforchoice[2], 'sub3' => $orderforchoice[1], '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(1), $this->get_no_hint_visible_expectation()); $this->check_output_contains_selectoptions( $this->get_contains_select_expectation('sub0', $choices, null, true), $this->get_contains_select_expectation('sub1', $choices, null, true), $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], true), $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], true)); } } PK\cRsi*i*type/match/tests/helper.phpnu[. /** * Test helpers for the match question type. * * @package qtype_match * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/match/question.php'); /** * Test helper class for the match question type. * * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match_test_helper extends question_test_helper { public function get_test_questions() { return array('foursubq', 'trickynums'); } /** * Makes a match question about completing two blanks in some text. * @return object the question definition data, as it might be returned from * get_question_options. */ public function get_match_question_data_foursubq() { global $USER; $q = new stdClass(); test_question_maker::initialise_question_data($q); $q->name = 'Matching question'; $q->qtype = 'match'; $q->parent = 0; $q->questiontext = 'Classify the animals.'; $q->questiontextformat = FORMAT_HTML; $q->generalfeedback = 'General feedback.'; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; $q->createdby = $USER->id; $q->modifiedby = $USER->id; $q->options = new stdClass(); $q->options->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_fields($q->options); $q->options->subquestions = array( 14 => (object) array( 'id' => 14, 'questiontext' => 'frog', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'amphibian'), 15 => (object) array( 'id' => 15, 'questiontext' => 'cat', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'mammal'), 16 => (object) array( 'id' => 16, 'questiontext' => 'newt', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'amphibian'), 17 => (object) array( 'id' => 17, 'questiontext' => '', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'insect'), ); return $q; } /** * Makes a match question about completing two blanks in some text. * @return object the question definition data, as it might be returned from * the question editing form. */ public function get_match_question_form_data_foursubq() { $q = new stdClass(); $q->name = 'Matching question'; $q->questiontext = array('text' => 'Classify the animals.', 'format' => FORMAT_HTML); $q->generalfeedback = array('text' => 'General feedback.', 'format' => FORMAT_HTML); $q->defaultmark = 1; $q->penalty = 0.3333333; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; $q->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_form_data($q); $q->subquestions = array( 0 => array('text' => 'frog', 'format' => FORMAT_HTML), 1 => array('text' => 'cat', 'format' => FORMAT_HTML), 2 => array('text' => 'newt', 'format' => FORMAT_HTML), 3 => array('text' => '', 'format' => FORMAT_HTML)); $q->subanswers = array( 0 => 'amphibian', 1 => 'mammal', 2 => 'amphibian', 3 => 'insect' ); $q->noanswers = 4; $q->hint = [ [ 'text' => 'Frog and newt are the same', 'format' => FORMAT_HTML, ], ]; return $q; } /** * Makes a matching question to classify 'Dog', 'Frog', 'Toad' and 'Cat' as * 'Mammal', 'Amphibian' or 'Insect'. * defaultmark 1. Stems are shuffled by default. * @return qtype_match_question */ public static function make_match_question_foursubq() { question_bank::load_question_definition_classes('match'); $match = new qtype_match_question(); test_question_maker::initialise_a_question($match); $match->name = 'Matching question'; $match->questiontext = 'Classify the animals.'; $match->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.'; $match->qtype = question_bank::get_qtype('match'); $match->shufflestems = 1; test_question_maker::set_standard_combined_feedback_fields($match); // Using unset to get 1-based arrays. $match->stems = array('', 'Dog', 'Frog', 'Toad', 'Cat'); $match->stemformat = array('', FORMAT_HTML, FORMAT_HTML, FORMAT_HTML, FORMAT_HTML); $match->choices = array('', 'Mammal', 'Amphibian', 'Insect'); $match->right = array('', 1, 2, 2, 1); unset($match->stems[0]); unset($match->stemformat[0]); unset($match->choices[0]); unset($match->right[0]); return $match; } /** * Makes a matching question with choices including '0' and '0.0'. * * @return object the question definition data, as it might be returned from * get_question_options. */ public function get_match_question_data_trickynums() { global $USER; $q = new stdClass(); test_question_maker::initialise_question_data($q); $q->name = 'Java matching'; $q->qtype = 'match'; $q->parent = 0; $q->questiontext = 'What is the output of each of these lines of code?'; $q->questiontextformat = FORMAT_HTML; $q->generalfeedback = 'Java has some advantages over PHP I guess!'; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; $q->createdby = $USER->id; $q->modifiedby = $USER->id; $q->options = new stdClass(); $q->options->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_fields($q->options); $q->options->subquestions = array( 14 => (object) array( 'id' => 14, 'questiontext' => 'System.out.println(0);', 'questiontextformat' => FORMAT_HTML, 'answertext' => '0'), 15 => (object) array( 'id' => 15, 'questiontext' => 'System.out.println(0.0);', 'questiontextformat' => FORMAT_HTML, 'answertext' => '0.0'), 16 => (object) array( 'id' => 16, 'questiontext' => '', 'questiontextformat' => FORMAT_HTML, 'answertext' => 'NULL'), ); return $q; } /** * Makes a match question about completing two blanks in some text. * @return object the question definition data, as it might be returned from * the question editing form. */ public function get_match_question_form_data_trickynums() { $q = new stdClass(); $q->name = 'Java matching'; $q->questiontext = ['text' => 'What is the output of each of these lines of code?', 'format' => FORMAT_HTML]; $q->generalfeedback = ['text' => 'Java has some advantages over PHP I guess!', 'format' => FORMAT_HTML]; $q->defaultmark = 1; $q->penalty = 0.3333333; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; $q->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_form_data($q); $q->subquestions = array( 0 => array('text' => 'System.out.println(0);', 'format' => FORMAT_HTML), 1 => array('text' => 'System.out.println(0.0);', 'format' => FORMAT_HTML), 2 => array('text' => '', 'format' => FORMAT_HTML), ); $q->subanswers = array( 0 => '0', 1 => '0.0', 2 => 'NULL', ); $q->noanswers = 3; return $q; } /** * Makes a matching question with choices including '0' and '0.0'. * * @return qtype_match_question */ public static function make_match_question_trickynums() { question_bank::load_question_definition_classes('match'); $match = new qtype_match_question(); test_question_maker::initialise_a_question($match); $match->name = 'Java matching'; $match->questiontext = 'What is the output of each of these lines of code?'; $match->generalfeedback = 'Java has some advantages over PHP I guess!'; $match->qtype = question_bank::get_qtype('match'); $match->shufflestems = 1; test_question_maker::set_standard_combined_feedback_fields($match); // Using unset to get 1-based arrays. $match->stems = array('', 'System.out.println(0);', 'System.out.println(0.0);'); $match->stemformat = array('', FORMAT_HTML, FORMAT_HTML); $match->choices = array('', '0', '0.0', 'NULL'); $match->right = array('', 1, 2); unset($match->stems[0]); unset($match->stemformat[0]); unset($match->choices[0]); unset($match->right[0]); return $match; } } PK\J(+2type/match/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_match; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of match question attempts. * * @package qtype_match * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_match_deferredfeedback_history6220(): void { $quiz = (object) array( 'id' => '72', 'course' => '1181', 'name' => 'Study Guide 4 Quiz', 'intro' => '', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '1', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71760879', 'questionsperpage' => '2', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '48', 'grade' => '48', 'timecreated' => '0', 'timemodified' => '1170427370', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '3562', 'uniqueid' => '3562', 'quiz' => '72', 'userid' => '91483', 'attempt' => '6', 'sumgrades' => '43', 'timestart' => '1177419915', 'timefinish' => '1177419962', 'timemodified' => '1168015476', 'layout' => '689,690,0,691,692,0,693,694,0,695,696,0,697,698,0', 'preview' => '0', ); $question = (object) array( 'id' => '695', 'category' => '65', 'parent' => '0', 'name' => 'Question 7', 'questiontext' => '

    Associate the appropriate definition with each term.

    ', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0', 'qtype' => 'match', 'length' => '1', 'stamp' => 'learn.open.ac.uk+061123110024+a0RsuG', 'version' => 'learn.open.ac.uk+061123163015+Oe63zC', 'hidden' => '0', 'generalfeedback' => 'For further information about this question see Study Guide 4 SAQ 3.1', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '3', 'options' => (object) array( 'id' => '27', 'questionid' => '695', 'subquestions' => array( 148 => (object) array( 'questionid' => '695', 'questiontext' => 'Active adjacent system', 'answertext' => 'A system that interacts with or participates in the work.', 'id' => 148, ), 149 => (object) array( 'questionid' => '695', 'questiontext' => 'Autonomous adjacent system', 'answertext' => 'An external entity that acts independently of the work under study.', 'id' => 149, ), 150 => (object) array( 'questionid' => '695', 'questiontext' => 'Cooperative adjacent system', 'answertext' => 'A system that is involved in the response to a business event.', 'id' => 150, ), 151 => (object) array( 'questionid' => '695', 'questiontext' => '', 'answertext' => 'A system which does not supply or receive data from the work.', 'id' => 151, ), 152 => (object) array( 'questionid' => '695', 'questiontext' => '', 'answertext' => 'An external entity that performs part of the work under study.', 'id' => 152, ), ), 'shuffleanswers' => '1', 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctresponsesfeedback' => '0', ), 'hints' => false, ); $qsession = (object) array( 'id' => '33092', 'attemptid' => '3562', 'questionid' => '695', 'newest' => '79626', 'newgraded' => '79626', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 79604 => (object) array( 'attempt' => '3562', 'question' => '695', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '148-0,149-0,150-0,151-0,152-0', 'timestamp' => '1177419915', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 79604, ), 79614 => (object) array( 'attempt' => '3562', 'question' => '695', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '148-148,149-151,150-152,151-0,152-0', 'timestamp' => '1177419855', 'event' => '2', 'grade' => '0', 'raw_grade' => '1', 'penalty' => '0', 'id' => 79614, ), 79619 => (object) array( 'attempt' => '3562', 'question' => '695', 'originalquestion' => '0', 'seq_number' => '2', 'answer' => '148-148,149-149,150-150,151-0,152-0', 'timestamp' => '1177419956', 'event' => '2', 'grade' => '0', 'raw_grade' => '3', 'penalty' => '0', 'id' => 79619, ), 79626 => (object) array( 'attempt' => '3562', 'question' => '695', 'originalquestion' => '0', 'seq_number' => '3', 'answer' => '148-148,149-149,150-150,151-0,152-0', 'timestamp' => '1177419956', 'event' => '6', 'grade' => '3', 'raw_grade' => '3', 'penalty' => '0', 'id' => 79626, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 695, 'variant' => 1, 'maxmark' => 3, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'Associate the appropriate definition with each term. ' . '{Active adjacent system; Autonomous adjacent system; ' . 'Cooperative adjacent system} -> {A system that interacts with ' . 'or participates in the work.; An external entity that acts ' . 'independently of the work under study.; A system that is involved ' . 'in the response to a business event.; A system which does not supply ' . 'or receive data from the work.; An external entity that performs part ' . 'of the work under study.}', 'rightanswer' => 'Active adjacent system -> A system that interacts with ' . 'or participates in the work.; Autonomous adjacent system -> ' . 'An external entity that acts independently of the work under study.; ' . 'Cooperative adjacent system -> A system that is involved in the response ' . 'to a business event.', 'responsesummary' => 'Active adjacent system -> A system that interacts with ' . 'or participates in the work.; Autonomous adjacent system -> ' . 'An external entity that acts independently of the work under study.; ' . 'Cooperative adjacent system -> A system that is involved in the response ' . 'to a business event.', 'timemodified' => 1177419956, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1177419915, 'userid' => 91483, 'data' => array('_stemorder' => '148,149,150', '_choiceorder' => 'todo - see below'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1177419855, 'userid' => 91483, 'data' => array('sub0' => 148, 'sub1' => 151, 'sub2' => 152), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1177419956, 'userid' => 91483, 'data' => array('sub0' => 148, 'sub1' => 149, 'sub2' => 150), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'gradedright', 'fraction' => 1, 'timecreated' => 1177419956, 'userid' => 91483, 'data' => array('sub0' => 148, 'sub1' => 149, 'sub2' => 150, '-finish' => 1), ), ), ); // This is a random thing, so just set expected to actual. $expectedqa->steps[0]->data['_choiceorder'] = $qa->steps[0]->data['_choiceorder']; $order = explode(',', $qa->steps[0]->data['_choiceorder']); $order = array_combine(array_values($order), array_keys($order)); for ($i = 1; $i <= 3; $i++) { for ($sub = 0; $sub < 3; $sub++) { $expectedqa->steps[$i]->data['sub' . $sub] = $order[$expectedqa->steps[$i]->data['sub' . $sub]] + 1; } } $this->compare_qas($expectedqa, $qa); } public function test_match_deferredfeedback_history60(): void { $quiz = (object) array( 'id' => '60', 'course' => '2304', 'name' => 'Types of resources available quiz', 'intro' => 'This quiz covers the different types of information resources available ' . 'and how to select which is most appropriate. ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '1164153600', 'timeclose' => '1606003200', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '3', 'review' => '71752557', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '5', 'grade' => '10', 'timecreated' => '0', 'timemodified' => '1170245956', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '1065', 'uniqueid' => '1065', 'quiz' => '60', 'userid' => '182682', 'attempt' => '1', 'sumgrades' => '3.99998', 'timestart' => '1168267317', 'timefinish' => '1168267508', 'timemodified' => '1168267508', 'layout' => '509,510,511,738,514,0', 'preview' => '0', ); $question = (object) array( 'id' => '738', 'category' => '60', 'parent' => '0', 'name' => 'TR004', 'questiontext' => '

    Which of the following statements about subject gateways are true, and which are false?

    ', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0.1', 'qtype' => 'match', 'length' => '1', 'stamp' => 'learn.open.ac.uk+061128151507+CLevuJ', 'version' => 'learn.open.ac.uk+070108115531+VvJurj', 'hidden' => '0', 'generalfeedback' => '
    • Subject gateways provide links to sites that have been quality checked = True
    • Subject gateways offer more variety than search engines = False
    • Subject gateways index websites automatically = False
    • Subject gateways can provide a more direct route to websites containing academic content = True
    ', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '1', 'options' => (object) array( 'id' => '35', 'questionid' => '738', 'subquestions' => array( 213 => (object) array( 'questionid' => '738', 'questiontext' => 'Subject gateways provide links to sites that have been quality checked ', 'answertext' => 'True', 'id' => 213, ), 214 => (object) array( 'questionid' => '738', 'questiontext' => 'Subject gateways offer more variety than search engines ', 'answertext' => 'False', 'id' => 214, ), 215 => (object) array( 'questionid' => '738', 'questiontext' => 'Subject gateways index websites automatically', 'answertext' => 'False', 'id' => 215, ), 216 => (object) array( 'questionid' => '738', 'questiontext' => 'Subject gateways can provide a more direct route ' . 'to websites containing academic content ', 'answertext' => 'True', 'id' => 216, ), ), 'shuffleanswers' => '1', 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctresponsesfeedback' => '0', ), 'hints' => false, ); $qsession = (object) array( 'id' => '9258', 'attemptid' => '1065', 'questionid' => '738', 'newest' => '24966', 'newgraded' => '24966', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 24961 => (object) array( 'attempt' => '1065', 'question' => '738', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '213-0,214-0,215-0,216-0', 'timestamp' => '1168267317', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 24961, ), 24966 => (object) array( 'attempt' => '1065', 'question' => '738', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '213-213,214-214,215-215,216-216', 'timestamp' => '1168267508', 'event' => '6', 'grade' => '1', 'raw_grade' => '1', 'penalty' => '0.1', 'id' => 24966, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 738, 'variant' => 1, 'maxmark' => 1, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'Which of the following statements about subject ' . 'gateways are true, and which are false? {Subject gateways ' . 'provide links to sites that have been quality checked; ' . 'Subject gateways offer more variety than search engines; ' . 'Subject gateways index websites automatically; ' . 'Subject gateways can provide a more direct route to websites containing academic content} -> ' . '{True; False}', 'rightanswer' => 'Subject gateways provide links to sites that have been quality checked -> True; ' . 'Subject gateways offer more variety than search engines -> False; ' . 'Subject gateways index websites automatically -> False; ' . 'Subject gateways can provide a more direct route to websites containing academic content -> True', 'responsesummary' => 'Subject gateways provide links to sites that have been quality checked -> True; ' . 'Subject gateways offer more variety than search engines -> False; ' . 'Subject gateways index websites automatically -> False; ' . 'Subject gateways can provide a more direct route to websites containing academic content -> True', 'timemodified' => 1168267508, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1168267317, 'userid' => 182682, 'data' => array('_stemorder' => '213,214,215,216', '_choiceorder' => 'todo - see below'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'gradedright', 'fraction' => 1, 'timecreated' => 1168267508, 'userid' => 182682, 'data' => array('sub0' => 213, 'sub1' => 214, 'sub2' => 214, 'sub3' => 213, '-finish' => 1), ), ), ); // This is a random thing, so just set expected to actual. $expectedqa->steps[0]->data['_choiceorder'] = $qa->steps[0]->data['_choiceorder']; $order = explode(',', $qa->steps[0]->data['_choiceorder']); $order = array_combine(array_values($order), array_keys($order)); for ($i = 1; $i <= 1; $i++) { for ($sub = 0; $sub < 4; $sub++) { $expectedqa->steps[$i]->data['sub' . $sub] = $order[$expectedqa->steps[$i]->data['sub' . $sub]] + 1; } } $this->compare_qas($expectedqa, $qa); } public function test_match_deferredfeedback_history622220(): void { $quiz = (object) array( 'id' => '719', 'course' => '3541', 'name' => 'Types of resources quiz', 'intro' => '', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71760879', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '5', 'grade' => '10', 'timecreated' => '0', 'timemodified' => '1193678199', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '23777', 'uniqueid' => '23777', 'quiz' => '719', 'userid' => '6584', 'attempt' => '1', 'sumgrades' => '3.91664', 'timestart' => '1200506648', 'timefinish' => '1200507571', 'timemodified' => '1200506959', 'layout' => '11163,0,11164,0,11165,0,11135,0,11166,0', 'preview' => '0', ); $question = (object) array( 'id' => '11135', 'category' => '675', 'parent' => '0', 'name' => 'TR004', 'questiontext' => '

    Which of the following statements about subject gateways are true, and which are false?

    ', 'questiontextformat' => '0', 'defaultmark' => '1', 'penalty' => '0.1', 'qtype' => 'match', 'length' => '1', 'stamp' => 'learn.open.ac.uk+071023110917+tqaM6z', 'version' => 'learn.open.ac.uk+071023110917+Ia7Hpz', 'hidden' => '0', 'generalfeedback' => '
    • Subject gateways provide links to sites that have been quality checked = True

    All links in a subject gateway have been added by a knowledgeable subject specialist and ' . 'so have to be of a certain quality to be added to the collection.

    • Subject gateways offer more variety than search engines = False

    Subject gateways will most likely provide fewer links than a search engine, but this is because ' . 'they are selected with a particular subject area in mind

    • Subject gateways index websites automatically = False

    Subject gateways links are indexed by knowledgeable subject specialists rather than a machine.

    • Subject gateways can provide a more direct route to websites containing academic content = True

    All links in a subject gateway have been added by a knowledgeable subject specialist ' . 'and so you can find academic content easier than using a web search engine.

    ', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '1', 'options' => (object) array( 'id' => '279', 'questionid' => '11135', 'subquestions' => array( 1632 => (object) array( 'questionid' => '11135', 'questiontext' => 'Subject gateways provide links to sites that have been quality checked', 'answertext' => 'True', 'id' => 1632, ), 1633 => (object) array( 'questionid' => '11135', 'questiontext' => 'Subject gateways offer more variety than search engines', 'answertext' => 'False', 'id' => 1633, ), 1634 => (object) array( 'questionid' => '11135', 'questiontext' => 'Subject gateways index websites automatically', 'answertext' => 'False', 'id' => 1634, ), 1635 => (object) array( 'questionid' => '11135', 'questiontext' => 'Subject gateways can provide a more direct route to websites ' . 'containing academic content', 'answertext' => 'True', 'id' => 1635, ), ), 'shuffleanswers' => '1', 'correctfeedback' => '', 'partiallycorrectfeedback' => '', 'incorrectfeedback' => '', 'correctresponsesfeedback' => '0', ), 'hints' => false, ); $qsession = (object) array( 'id' => '356418', 'attemptid' => '23777', 'questionid' => '11135', 'newest' => '862740', 'newgraded' => '862740', 'sumpenalty' => '0.1', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 862587 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '1633-0,1635-0,1634-0,1632-0', 'timestamp' => '1200506648', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 862587, ), 862638 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '1633-1633,1635-1635,1634-0,1632-1632', 'timestamp' => '1200507025', 'event' => '2', 'grade' => '0', 'raw_grade' => '0.75', 'penalty' => '0.1', 'id' => 862638, ), 862668 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '2', 'answer' => '1633-1633,1635-1635,1634-0,1632-1632', 'timestamp' => '1200507125', 'event' => '2', 'grade' => '0', 'raw_grade' => '0.75', 'penalty' => '0.1', 'id' => 862668, ), 862673 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '3', 'answer' => '1633-1633,1635-1635,1634-0,1632-1632', 'timestamp' => '1200507172', 'event' => '2', 'grade' => '0', 'raw_grade' => '0.75', 'penalty' => '0.1', 'id' => 862673, ), 862716 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '4', 'answer' => '1633-1633,1635-1635,1634-1635,1632-1632', 'timestamp' => '1200507467', 'event' => '2', 'grade' => '0', 'raw_grade' => '0.75', 'penalty' => '0.1', 'id' => 862716, ), 862740 => (object) array( 'attempt' => '23777', 'question' => '11135', 'originalquestion' => '0', 'seq_number' => '5', 'answer' => '1633-1633,1635-1635,1634-1635,1632-1632', 'timestamp' => '1200507467', 'event' => '6', 'grade' => '0.75', 'raw_grade' => '0.75', 'penalty' => '0.1', 'id' => 862740, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 11135, 'variant' => 1, 'maxmark' => 1, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'Which of the following statements about subject gateways are true, and which are false? ' . '{Subject gateways provide links to sites that have been quality checked; ' . 'Subject gateways offer more variety than search engines; ' . 'Subject gateways index websites automatically; ' . 'Subject gateways can provide a more direct route to websites containing academic content} ' . '-> {True; False}', 'rightanswer' => 'Subject gateways provide links to sites that have been quality checked -> True; ' . 'Subject gateways offer more variety than search engines -> False; ' . 'Subject gateways index websites automatically -> False; ' . 'Subject gateways can provide a more direct route to websites containing academic content -> True', 'responsesummary' => 'Subject gateways offer more variety than search engines -> False; ' . 'Subject gateways can provide a more direct route to websites containing academic content -> True; ' . 'Subject gateways index websites automatically -> True; ' . 'Subject gateways provide links to sites that have been quality checked -> True', 'timemodified' => 1200507467, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1200506648, 'userid' => 6584, 'data' => array('_stemorder' => '1633,1635,1634,1632', '_choiceorder' => 'todo - see below'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1200507025, 'userid' => 6584, 'data' => array('sub0' => 1633, 'sub1' => 1632, 'sub2' => 0, 'sub3' => 1632), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1200507125, 'userid' => 6584, 'data' => array('sub0' => 1633, 'sub1' => 1632, 'sub2' => 0, 'sub3' => 1632), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1200507172, 'userid' => 6584, 'data' => array('sub0' => 1633, 'sub1' => 1632, 'sub2' => 0, 'sub3' => 1632), ), 4 => (object) array( 'sequencenumber' => 4, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1200507467, 'userid' => 6584, 'data' => array('sub0' => 1633, 'sub1' => 1632, 'sub2' => 1632, 'sub3' => 1632), ), 5 => (object) array( 'sequencenumber' => 5, 'state' => 'gradedpartial', 'fraction' => 0.75, 'timecreated' => 1200507467, 'userid' => 6584, 'data' => array('sub0' => 1633, 'sub1' => 1632, 'sub2' => 1632, 'sub3' => 1632, '-finish' => 1), ), ), ); // This is a random thing, so just set expected to actual. $expectedqa->steps[0]->data['_choiceorder'] = $qa->steps[0]->data['_choiceorder']; $order = explode(',', $qa->steps[0]->data['_choiceorder']); $order = array_combine(array_values($order), array_keys($order)); for ($i = 1; $i <= 5; $i++) { for ($sub = 0; $sub < 5; $sub++) { if (!array_key_exists('sub' . $sub, $expectedqa->steps[$i]->data) || $expectedqa->steps[$i]->data['sub' . $sub] == 0) { continue; } $expectedqa->steps[$i]->data['sub' . $sub] = $order[$expectedqa->steps[$i]->data['sub' . $sub]] + 1; } } $this->compare_qas($expectedqa, $qa); } } PK\P "type/match/tests/behat/add.featurenu[@qtype @qtype_match Feature: Test creating a Matching question As a teacher In order to test my students I need to be able to create a Matching question Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript Scenario: Create a Matching question with 3 subquestions When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Matching" question filling the form with: | Question name | match-001 | | Question text | Match the country with the capital city. | | General feedback | England=London, France=Paris and Spain=Madrid. | | id_shuffleanswers | 0 | | id_subquestions_0 | England | | id_subanswers_0 | London | | id_subquestions_1 | France | | id_subanswers_1 | Paris | | id_subquestions_2 | Spain | | id_subanswers_2 | Madrid | | For any correct response | Your answer is correct | | For any partially correct response | Your answer is partially correct | | For any incorrect response | Your answer is incorrect | | Hint 1 | This is your first hint | | Hint 2 | This is your second hint | Then I should see "match-001" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "item_qtype_match" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | id_shuffleanswers | 0 | PK\s%))#type/match/tests/behat/edit.featurenu[@qtype @qtype_match Feature: Test editing a Matching question As a teacher In order to be able to update my Matching question I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | match | Matching for editing | foursubq | @javascript @_switch_window Scenario: Edit a Matching question When I am on the "Matching for editing" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | | And I press "id_submitbutton" And I should see "You must supply a value here." And I set the following fields to these values: | Question name | Edited Matching name | And I press "id_submitbutton" And I should see "Edited Matching name" And I choose "Edit question" action for "Edited Matching name" in the question bank And I set the following fields to these values: | Shuffle | 0 | | Question 2 | dog | | Question 4 | fly | And I press "id_submitbutton" Then I should see "Edited Matching name" And I choose "Preview" action for "Edited Matching name" in the question bank And I should see "frog" And I should see "dog" And I should see "newt" And I should see "fly" PK\%type/match/tests/behat/export.featurenu[@qtype @qtype_match Feature: Test exporting Matching questions As a teacher In order to be able to reuse my Matching questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | match | matching-001 | foursubq | Scenario: Export a Matching question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | matching-001 | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\պE11%type/match/tests/behat/import.featurenu[@qtype @qtype_match Feature: Test importing Matching questions As a teacher In order to reuse Matching questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import Matching question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/match/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "1. Classify the animals." And I press "Continue" And I should see "matching-001" PK\[" 1type/match/tests/behat/backup_and_restore.featurenu[@qtype @qtype_match Feature: Test duplicating a quiz containing a Matching question As a teacher In order re-use my courses containing Matching questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | match | matching-001 | foursubq | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | matching-001 | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing a Matching question When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "matching-001" in the question bank Then the following fields match these values: | Question name | matching-001 | | Question text | Classify the animals. | | General feedback | General feedback. | | Default mark | 1 | | Shuffle | 1 | | Question 1 | frog | | Question 2 | cat | | Question 3 | newt | | Question 4 | | | id_subanswers_0 | amphibian | | id_subanswers_1 | mammal | | id_subanswers_2 | amphibian | | id_subanswers_3 | insect | PK\g &type/match/tests/behat/preview.featurenu[@qtype @qtype_match Feature: Preview a Matching question As a teacher In order to check my Matching questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | shuffleanswers | | Test questions | match | matching-001 | foursubq | 0 | @javascript @_switch_window Scenario: Preview a Matching question and submit a correct response. When I am on the "matching-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I set the field "Answer 1" to "amphibian" And I set the field "Answer 2" to "mammal" And I set the field "Answer 3" to "amphibian" And I press "Check" Then I should see "Well done!" And I should see "General feedback." @javascript @_switch_window Scenario: Preview a Matching question and submit a partially correct response. When I am on the "matching-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I set the field "Answer 1" to "amphibian" And I set the field "Answer 2" to "insect" And I set the field "Answer 3" to "amphibian" And I press "Check" Then I should see "Parts, but only parts, of your response are correct." And I should see "General feedback." @javascript @_switch_window Scenario: Preview a Matching question and submit an incorrect response. When I am on the "matching-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I set the field "Answer 1" to "mammal" And I set the field "Answer 2" to "insect" And I set the field "Answer 3" to "insect" And I press "Check" Then I should see "That is not right at all." And I should see "General feedback." PK\@ type/match/questiontype.phpnu[. /** * Question type class for the matching question type. * * @package qtype_match * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/engine/lib.php'); /** * The matching question type class. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_match extends question_type { public function get_question_options($question) { global $DB; parent::get_question_options($question); $question->options = $DB->get_record('qtype_match_options', array('questionid' => $question->id)); $question->options->subquestions = $DB->get_records('qtype_match_subquestions', array('questionid' => $question->id), 'id ASC'); return true; } public function save_defaults_for_new_questions(stdClass $fromform): void { parent::save_defaults_for_new_questions($fromform); $this->set_default_value('shuffleanswers', $fromform->shuffleanswers); } public function save_question_options($question) { global $DB; $context = $question->context; $result = new stdClass(); $oldsubquestions = $DB->get_records('qtype_match_subquestions', array('questionid' => $question->id), 'id ASC'); // Insert all the new question & answer pairs. foreach ($question->subquestions as $key => $questiontext) { if ($questiontext['text'] == '' && trim($question->subanswers[$key]) == '') { continue; } if ($questiontext['text'] != '' && trim($question->subanswers[$key]) == '') { $result->notice = get_string('nomatchinganswer', 'qtype_match', $questiontext); } // Update an existing subquestion if possible. $subquestion = array_shift($oldsubquestions); if (!$subquestion) { $subquestion = new stdClass(); $subquestion->questionid = $question->id; $subquestion->questiontext = ''; $subquestion->answertext = ''; $subquestion->id = $DB->insert_record('qtype_match_subquestions', $subquestion); } $subquestion->questiontext = $this->import_or_save_files($questiontext, $context, 'qtype_match', 'subquestion', $subquestion->id); $subquestion->questiontextformat = $questiontext['format']; $subquestion->answertext = trim($question->subanswers[$key]); $DB->update_record('qtype_match_subquestions', $subquestion); } // Delete old subquestions records. $fs = get_file_storage(); foreach ($oldsubquestions as $oldsub) { $fs->delete_area_files($context->id, 'qtype_match', 'subquestion', $oldsub->id); $DB->delete_records('qtype_match_subquestions', array('id' => $oldsub->id)); } // Save the question options. $options = $DB->get_record('qtype_match_options', array('questionid' => $question->id)); if (!$options) { $options = new stdClass(); $options->questionid = $question->id; $options->correctfeedback = ''; $options->partiallycorrectfeedback = ''; $options->incorrectfeedback = ''; $options->id = $DB->insert_record('qtype_match_options', $options); } $options->shuffleanswers = $question->shuffleanswers; $options = $this->save_combined_feedback_helper($options, $question, $context, true); $DB->update_record('qtype_match_options', $options); $this->save_hints($question, true); if (!empty($result->notice)) { return $result; } return true; } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $question->shufflestems = $questiondata->options->shuffleanswers; $this->initialise_combined_feedback($question, $questiondata, true); $question->stems = array(); $question->choices = array(); $question->right = array(); foreach ($questiondata->options->subquestions as $matchsub) { $key = array_search($matchsub->answertext, $question->choices, true); if ($key === false) { $key = $matchsub->id; $question->choices[$key] = $matchsub->answertext; } if ($matchsub->questiontext !== '') { $question->stems[$matchsub->id] = $matchsub->questiontext; $question->stemformat[$matchsub->id] = $matchsub->questiontextformat; $question->right[$matchsub->id] = $key; } } } protected function make_hint($hint) { return question_hint_with_parts::load_from_record($hint); } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('qtype_match_options', array('questionid' => $questionid)); $DB->delete_records('qtype_match_subquestions', array('questionid' => $questionid)); parent::delete_question($questionid, $contextid); } public function get_random_guess_score($questiondata) { $q = $this->make_question($questiondata); return 1 / count($q->choices); } public function get_possible_responses($questiondata) { $subqs = array(); $q = $this->make_question($questiondata); foreach ($q->stems as $stemid => $stem) { $responses = array(); foreach ($q->choices as $choiceid => $choice) { $responses[$choiceid] = new question_possible_response( $q->html_to_text($stem, $q->stemformat[$stemid]) . ': ' . $choice, ($choiceid == $q->right[$stemid]) / count($q->stems)); } $responses[null] = question_possible_response::no_response(); $subqs[$stemid] = $responses; } return $subqs; } public function move_files($questionid, $oldcontextid, $newcontextid) { global $DB; $fs = get_file_storage(); parent::move_files($questionid, $oldcontextid, $newcontextid); $subquestionids = $DB->get_records_menu('qtype_match_subquestions', array('questionid' => $questionid), 'id', 'id,1'); foreach ($subquestionids as $subquestionid => $notused) { $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_match', 'subquestion', $subquestionid); } $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } protected function delete_files($questionid, $contextid) { global $DB; $fs = get_file_storage(); parent::delete_files($questionid, $contextid); $subquestionids = $DB->get_records_menu('qtype_match_subquestions', array('questionid' => $questionid), 'id', 'id,1'); foreach ($subquestionids as $subquestionid => $notused) { $fs->delete_area_files($contextid, 'qtype_match', 'subquestion', $subquestionid); } $this->delete_files_in_combined_feedback($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } } PK\释(UUtype/truefalse/styles.cssnu[.que.truefalse .answer div.r0, .que.truefalse .answer div.r1 { padding: 0.3em; } PK\@Ӿtype/truefalse/renderer.phpnu[. /** * True-false question renderer class. * * @package qtype * @subpackage truefalse * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Generates the output for true-false questions. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse_renderer extends qtype_renderer { public function formulation_and_controls(question_attempt $qa, question_display_options $options) { /** @var qtype_truefalse_question $question */ $question = $qa->get_question(); $response = $qa->get_last_qt_var('answer', ''); $inputname = $qa->get_qt_field_name('answer'); $trueattributes = array( 'type' => 'radio', 'name' => $inputname, 'value' => 1, 'id' => $inputname . 'true', ); $falseattributes = array( 'type' => 'radio', 'name' => $inputname, 'value' => 0, 'id' => $inputname . 'false', ); if ($options->readonly) { $trueattributes['disabled'] = 'disabled'; $falseattributes['disabled'] = 'disabled'; } // Work out which radio button to select (if any). $truechecked = false; $falsechecked = false; $responsearray = array(); if ($response) { $trueattributes['checked'] = 'checked'; $truechecked = true; $responsearray = array('answer' => 1); } else if ($response !== '') { $falseattributes['checked'] = 'checked'; $falsechecked = true; $responsearray = array('answer' => 1); } // Work out visual feedback for answer correctness. $trueclass = ''; $falseclass = ''; $truefeedbackimg = ''; $falsefeedbackimg = ''; if ($options->correctness) { if ($truechecked) { $trueclass = ' ' . $this->feedback_class((int) $question->rightanswer); $truefeedbackimg = $this->feedback_image((int) $question->rightanswer); } else if ($falsechecked) { $falseclass = ' ' . $this->feedback_class((int) (!$question->rightanswer)); $falsefeedbackimg = $this->feedback_image((int) (!$question->rightanswer)); } } $radiotrue = html_writer::empty_tag('input', $trueattributes) . html_writer::tag('label', get_string('true', 'qtype_truefalse'), array('for' => $trueattributes['id'], 'class' => 'ms-1')); $radiofalse = html_writer::empty_tag('input', $falseattributes) . html_writer::tag('label', get_string('false', 'qtype_truefalse'), array('for' => $falseattributes['id'], 'class' => 'ms-1')); $result = ''; $result .= html_writer::tag('div', $question->format_questiontext($qa), array('class' => 'qtext')); $result .= html_writer::start_tag('fieldset', array('class' => 'ablock')); if (!empty($question->showstandardinstruction)) { $legendclass = ''; $questionnumber = $options->add_question_identifier_to_label(get_string('selectone', 'qtype_truefalse'), true, true); } else { $legendclass = 'visually-hidden'; $questionnumber = $options->add_question_identifier_to_label(get_string('answer'), true, true); } $result .= html_writer::tag('legend', $questionnumber, array('class' => 'prompt h6 fw-normal ' . $legendclass)); $result .= html_writer::start_tag('div', array('class' => 'answer')); $result .= html_writer::tag('div', $radiotrue . ' ' . $truefeedbackimg, array('class' => 'r0' . $trueclass)); $result .= html_writer::tag('div', $radiofalse . ' ' . $falsefeedbackimg, array('class' => 'r1' . $falseclass)); $result .= html_writer::end_tag('div'); // Answer. $result .= html_writer::end_tag('fieldset'); // Ablock. if ($qa->get_state() == question_state::$invalid) { $result .= html_writer::nonempty_tag('div', $question->get_validation_error($responsearray), array('class' => 'validationerror')); } return $result; } public function specific_feedback(question_attempt $qa) { $question = $qa->get_question(); $response = $qa->get_last_qt_var('answer', ''); if ($response) { return $question->format_text($question->truefeedback, $question->truefeedbackformat, $qa, 'question', 'answerfeedback', $question->trueanswerid); } else if ($response !== '') { return $question->format_text($question->falsefeedback, $question->falsefeedbackformat, $qa, 'question', 'answerfeedback', $question->falseanswerid); } } public function correct_response(question_attempt $qa) { $question = $qa->get_question(); if ($question->rightanswer) { return get_string('correctanswertrue', 'qtype_truefalse'); } else { return get_string('correctanswerfalse', 'qtype_truefalse'); } } } PK\6{pptype/truefalse/db/upgrade.phpnu[. /** * True/false question type upgrade code * * @package qtype_truefalse * @copyright 2022 Paul Holden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Method to perform upgrade steps between versions * * @param int $oldversion * @return bool */ function xmldb_qtype_truefalse_upgrade(int $oldversion): bool { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\dXxiitype/truefalse/db/install.xmlnu[
    PK\C? ? type/truefalse/db/upgradelib.phpnu[. /** * Upgrade library code for the truefalse question type. * * @package qtype * @subpackage truefalse * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Class for converting attempt data for truefalse questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse_qe2_attempt_updater extends question_qtype_attempt_updater { public function right_answer() { foreach ($this->question->options->answers as $ans) { if ($ans->fraction > 0.999) { return $ans->answer; } } } public function response_summary($state) { if (is_numeric($state->answer)) { if (array_key_exists($state->answer, $this->question->options->answers)) { return $this->question->options->answers[$state->answer]->answer; } else { $this->logger->log_assumption("Dealing with a place where the student selected a choice that was later deleted for true/false question {$this->question->id}"); return null; } } else { return null; } } public function was_answered($state) { return !empty($state->answer); } public function set_first_step_data_elements($state, &$data) { } public function supply_missing_first_step_data(&$data) { } public function set_data_elements_for_step($state, &$data) { if (is_numeric($state->answer)) { $data['answer'] = (int) ($state->answer == $this->question->options->trueanswer); } } } PK\*type/truefalse/lang/en/qtype_truefalse.phpnu[. /** * Strings for component 'qtype_truefalse', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage truefalse * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['correctanswer'] = 'Correct answer'; $string['correctanswerfalse'] = 'The correct answer is \'False\'.'; $string['correctanswertrue'] = 'The correct answer is \'True\'.'; $string['false'] = 'False'; $string['feedbackfalse'] = 'Feedback for the response \'False\'.'; $string['feedbacktrue'] = 'Feedback for the response \'True\'.'; $string['pleaseselectananswer'] = 'Please select an answer.'; $string['selectone'] = 'Select one:'; $string['true'] = 'True'; $string['pluginname'] = 'True/False'; $string['pluginname_help'] = 'In response to a question (that may include an image) the respondent chooses from true or false.'; $string['pluginname_link'] = 'question/type/truefalse'; $string['pluginnameadding'] = 'Adding a True/False question'; $string['pluginnameediting'] = 'Editing a True/False question'; $string['pluginnamesummary'] = 'A simple form of multiple choice question with just the two choices \'True\' and \'False\'.'; $string['privacy:preference:showstandardinstruction'] = 'Whether showing standard instruction.'; $string['showstandardinstruction'] = 'Show standard instructions'; $string['showstandardinstruction_help'] = 'Whether to show the instructions "Select one:" before true/false answers.'; PK\) Etype/truefalse/backup/moodle2/backup_qtype_truefalse_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup truefalse questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_truefalse_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(null, '../../qtype', 'truefalse'); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // This qtype uses standard question_answers, add them here // to the tree before any other information that will use them. $this->add_question_question_answers($pluginwrapper); // Now create the qtype own structures. $truefalse = new backup_nested_element('truefalse', array('id'), array( 'trueanswer', 'falseanswer', 'showstandardinstruction')); // Now the own qtype tree. $pluginwrapper->add_child($truefalse); // Set source to populate the data. $truefalse->set_source_table('question_truefalse', array('question' => backup::VAR_PARENTID)); // Don't need to annotate ids nor files. return $plugin; } } PK\NNFtype/truefalse/backup/moodle2/restore_qtype_truefalse_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * restore plugin class that provides the necessary information * needed to restore one truefalse qtype plugin * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_truefalse_plugin extends restore_qtype_plugin { /** * Returns the paths to be handled by the plugin at question level */ protected function define_question_plugin_structure() { $paths = array(); // This qtype uses question_answers, add them. $this->add_question_question_answers($paths); // Add own qtype stuff. $elename = 'truefalse'; $elepath = $this->get_pathfor('/truefalse'); // We used get_recommended_name() so this works. $paths[] = new restore_path_element($elename, $elepath); return $paths; // And we return the interesting paths. } /** * Process the qtype/truefalse element */ public function process_truefalse($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its question_truefalse too. if ($questioncreated) { // Adjust some columns. $data->question = $newquestionid; $data->trueanswer = $this->get_mappingid('question_answer', $data->trueanswer); $data->falseanswer = $this->get_mappingid('question_answer', $data->falseanswer); // Insert record. $newitemid = $DB->insert_record('question_truefalse', $data); // Create mapping. $this->set_mapping('question_truefalse', $oldid, $newitemid); } } /** * Given one question_states record, return the answer * recoded pointing to all the restored stuff for truefalse questions * * if not empty, answer is one question_answers->id */ public function recode_legacy_state_answer($state) { $answer = $state->answer; $result = ''; if ($answer) { $result = $this->get_mappingid('question_answer', $answer); } return $result; } #[\Override] public function define_excluded_identity_hash_fields(): array { return [ '/options/trueanswer', '/options/falseanswer', '/options/question', ]; } } PK\oJ%type/truefalse/backup/moodle1/lib.phpnu[. /** * @package qtype * @subpackage truefalse * @copyright 2011 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * True/false question type conversion handler */ class moodle1_qtype_truefalse_handler extends moodle1_qtype_handler { /** * @return array */ public function get_question_subpaths() { return array( 'ANSWERS/ANSWER', 'TRUEFALSE', ); } /** * Appends the truefalse specific information to the question */ public function process_question(array $data, array $raw) { // Convert and write the answers first. if (isset($data['answers'])) { $this->write_answers($data['answers'], $this->pluginname); } // Convert and write the truefalse extra fields. foreach ($data['truefalse'] as $truefalse) { $truefalse['id'] = $this->converter->get_nextid(); $this->write_xml('truefalse', $truefalse, array('/truefalse/id')); } } } PK\E=¶type/truefalse/pix/icon.gifnu[GIF89a111ttt''':::666"""!,;&dih,E2r8X#B"G!)Q@X"#h0xL;PK\ <<type/truefalse/pix/icon.svgnu[PK\$type/truefalse/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_truefalse * @copyright 2010 The Open Unviersity * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for true-false questions. * @package qtype_truefalse * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_truefalse_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_truefalse', $filearea, $args, $forcedownload, $options); } PK\mmtype/truefalse/version.phpnu[. /** * Version information for the true/false question type. * * @package qtype * @subpackage truefalse * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_truefalse'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\,type/truefalse/question.phpnu[. /** * True-false question definition class. * * @package qtype * @subpackage truefalse * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * Represents a true-false question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse_question extends question_graded_automatically { public $rightanswer; public $truefeedback; public $falsefeedback; public $trueanswerid; public $falseanswerid; /** @var int the format of the true feedback. */ public $truefeedbackformat; /** @var int the format of the false feedback. */ public $falsefeedbackformat; /** @var bool true to show the standard instruction, otherwise hide it. */ public $showstandardinstruction; public function get_expected_data() { return array('answer' => PARAM_INT); } public function get_correct_response() { return array('answer' => (int) $this->rightanswer); } public function summarise_response(array $response) { if (!array_key_exists('answer', $response)) { return null; } else if ($response['answer']) { return get_string('true', 'qtype_truefalse'); } else { return get_string('false', 'qtype_truefalse'); } } public function un_summarise_response(string $summary) { if ($summary === get_string('true', 'qtype_truefalse')) { return ['answer' => '1']; } else if ($summary === get_string('false', 'qtype_truefalse')) { return ['answer' => '0']; } else { return []; } } public function classify_response(array $response) { if (!array_key_exists('answer', $response)) { return array($this->id => question_classified_response::no_response()); } list($fraction) = $this->grade_response($response); if ($response['answer']) { return array($this->id => new question_classified_response(1, get_string('true', 'qtype_truefalse'), $fraction)); } else { return array($this->id => new question_classified_response(0, get_string('false', 'qtype_truefalse'), $fraction)); } } public function is_complete_response(array $response) { return array_key_exists('answer', $response); } public function get_validation_error(array $response) { if ($this->is_gradable_response($response)) { return ''; } return get_string('pleaseselectananswer', 'qtype_truefalse'); } public function is_same_response(array $prevresponse, array $newresponse) { return question_utils::arrays_same_at_key_missing_is_blank( $prevresponse, $newresponse, 'answer'); } public function grade_response(array $response) { if ($this->rightanswer == true && $response['answer'] == true) { $fraction = 1; } else if ($this->rightanswer == false && $response['answer'] == false) { $fraction = 1; } else { $fraction = 0; } return array($fraction, question_state::graded_state_for_fraction($fraction)); } public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($component == 'question' && $filearea == 'answerfeedback') { $answerid = reset($args); // Itemid is answer id. $response = $qa->get_last_qt_var('answer', ''); return $options->feedback && ( ($answerid == $this->trueanswerid && $response) || ($answerid == $this->falseanswerid && $response !== '')); } else { return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } } /** * Return the question settings that define this question as structured data. * * @param question_attempt $qa the current attempt for which we are exporting the settings. * @param question_display_options $options the question display options which say which aspects of the question * should be visible. * @return mixed structure representing the question settings. In web services, this will be JSON-encoded. */ public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) { // No need to return anything, external clients do not need additional information for rendering this question type. return null; } } PK\gOO+type/truefalse/classes/privacy/provider.phpnu[. namespace qtype_truefalse\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\transform; use core_privacy\local\request\writer; /** * Privacy Subsystem implementation for qtype_truefalse * * @package qtype_truefalse * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns metadata about this system * * @param collection $collection * @return collection */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qtype_truefalse_showstandardinstruction', 'privacy:preference:showstandardinstruction'); return $collection; } /** * Export plugin user preferences for the given user * * @param int $userid */ public static function export_user_preferences(int $userid): void { $preference = get_user_preferences('qtype_truefalse_showstandardinstruction', null, $userid); if ($preference !== null) { writer::export_user_preference('qtype_truefalse', 'showstandardinstruction', transform::yesno($preference), get_string('privacy:preference:showstandardinstruction', 'qtype_truefalse')); } } } PK\?5type/truefalse/tests/fixtures/testquestion.moodle.xmlnu[ $course$/Test questions true-false-001 The answer is true. You should have selected true. 1.0000000 1.0000000 0 true This is the right answer. false This is the wrong answer. PK\WɅ~&type/truefalse/tests/question_test.phpnu[. namespace qtype_truefalse; use question_attempt_step; use question_classified_response; use question_display_options; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the true-false question definition class. * * @package qtype_truefalse * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \advanced_testcase { public function test_is_complete_response(): void { $question = \test_question_maker::make_question('truefalse', 'true'); $this->assertFalse($question->is_complete_response(array())); $this->assertTrue($question->is_complete_response(array('answer' => 0))); $this->assertTrue($question->is_complete_response(array('answer' => 1))); } public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('truefalse', 'true'); $this->assertFalse($question->is_gradable_response(array())); $this->assertTrue($question->is_gradable_response(array('answer' => 0))); $this->assertTrue($question->is_gradable_response(array('answer' => 1))); } public function test_grading(): void { $question = \test_question_maker::make_question('truefalse', 'true'); $this->assertEquals(array(0, question_state::$gradedwrong), $question->grade_response(array('answer' => 0))); $this->assertEquals(array(1, question_state::$gradedright), $question->grade_response(array('answer' => 1))); } public function test_get_correct_response(): void { $question = \test_question_maker::make_question('truefalse', 'true'); // True. $this->assertSame(array('answer' => 1), $question->get_correct_response()); // False. $question->rightanswer = false; $this->assertSame(array('answer' => 0), $question->get_correct_response()); } public function test_get_question_summary(): void { $tf = \test_question_maker::make_question('truefalse', 'true'); $qsummary = $tf->get_question_summary(); $this->assertEquals('The answer is true.', $qsummary); } public function test_summarise_response(): void { $tf = \test_question_maker::make_question('truefalse', 'true'); $this->assertEquals(get_string('false', 'qtype_truefalse'), $tf->summarise_response(array('answer' => '0'))); $this->assertEquals(get_string('true', 'qtype_truefalse'), $tf->summarise_response(array('answer' => '1'))); } public function test_classify_response(): void { $tf = \test_question_maker::make_question('truefalse', 'true'); $tf->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array( $tf->id => new question_classified_response( 0, get_string('false', 'qtype_truefalse'), 0.0)), $tf->classify_response(array('answer' => '0'))); $this->assertEquals(array( $tf->id => new question_classified_response( 1, get_string('true', 'qtype_truefalse'), 1.0)), $tf->classify_response(array('answer' => '1'))); $this->assertEquals(array( $tf->id => question_classified_response::no_response()), $tf->classify_response(array())); } /** * test_get_question_definition_for_external_rendering */ public function test_get_question_definition_for_external_rendering(): void { $this->resetAfterTest(); $question = \test_question_maker::make_question('truefalse', 'true'); $question->start_attempt(new question_attempt_step(), 1); $qa = \test_question_maker::get_a_qa($question); $displayoptions = new question_display_options(); $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions); $this->assertNull($options); } } PK\r"r"+type/truefalse/tests/question_type_test.phpnu[. namespace qtype_truefalse; use qtype_truefalse; use qtype_truefalse_edit_form; use question_bank; use question_possible_response; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/truefalse/questiontype.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/truefalse/edit_truefalse_form.php'); /** * Unit tests for the true-false question definition class. * * @package qtype_truefalse * @copyright 2007 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \advanced_testcase { protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_truefalse(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'truefalse'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_get_random_guess_score(): void { $this->assertEquals(0.5, $this->qtype->get_random_guess_score(null)); } public function test_load_question(): void { $this->resetAfterTest(); $syscontext = \context_system::instance(); /** @var core_question_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(['contextid' => $syscontext->id]); $fromform = \test_question_maker::get_question_form_data('truefalse'); $fromform->category = $category->id . ',' . $syscontext->id; $question = new \stdClass(); $question->category = $category->id; $question->qtype = 'truefalse'; $question->createdby = 0; $this->qtype->save_question($question, $fromform); $questiondata = question_bank::load_question_data($question->id); $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); $this->assertEquals(0, $questiondata->parent); $this->assertEquals($fromform->name, $questiondata->name); $this->assertEquals($fromform->questiontext, $questiondata->questiontext); $this->assertEquals($fromform->questiontextformat, $questiondata->questiontextformat); $this->assertEquals($fromform->generalfeedback['text'], $questiondata->generalfeedback); $this->assertEquals($fromform->generalfeedback['format'], $questiondata->generalfeedbackformat); $this->assertEquals($fromform->defaultmark, $questiondata->defaultmark); $this->assertEquals(1, $questiondata->penalty); $this->assertEquals('truefalse', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); $this->assertEquals($category->contextid, $questiondata->contextid); // Options. $this->assertEquals($questiondata->id, $questiondata->options->question); $this->assertEquals('True', $questiondata->options->answers[$questiondata->options->trueanswer]->answer); $this->assertEquals('False', $questiondata->options->answers[$questiondata->options->falseanswer]->answer); $this->assertEquals(1.0, $questiondata->options->answers[$questiondata->options->trueanswer]->fraction); $this->assertEquals(0.0, $questiondata->options->answers[$questiondata->options->falseanswer]->fraction); $this->assertEquals('This is the right answer.', $questiondata->options->answers[$questiondata->options->trueanswer]->feedback); $this->assertEquals('This is the wrong answer.', $questiondata->options->answers[$questiondata->options->falseanswer]->feedback); $this->assertEquals(FORMAT_HTML, $questiondata->options->answers[$questiondata->options->trueanswer]->feedbackformat); $this->assertEquals(FORMAT_HTML, $questiondata->options->answers[$questiondata->options->falseanswer]->feedbackformat); // Hints. $this->assertEquals([], $questiondata->hints); } public function test_get_possible_responses(): void { $q = new \stdClass(); $q->id = 1; $q->options = new \stdClass(); $q->options->trueanswer = 1; $q->options->falseanswer = 2; $q->options->answers[1] = (object) array('fraction' => 1); $q->options->answers[2] = (object) array('fraction' => 0); $this->assertEquals(array( $q->id => array( 0 => new question_possible_response(get_string('false', 'qtype_truefalse'), 0), 1 => new question_possible_response(get_string('true', 'qtype_truefalse'), 1), null => question_possible_response::no_response()), ), $this->qtype->get_possible_responses($q)); } public function test_question_saving_true(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questiondata = \test_question_maker::get_question_data('truefalse'); $formdata = \test_question_maker::get_question_form_data('truefalse'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(array()); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_truefalse_edit_form::mock_submit((array)$formdata); $form = \qtype_truefalse_test_helper::get_question_editing_form($cat, $questiondata); $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, array('options'))) { $this->assertEquals($value, $actualquestiondata->$property); } } foreach ($questiondata->options as $optionname => $value) { if (!in_array($optionname, array('trueanswer', 'falseanswer', 'answers'))) { $this->assertEquals($value, $actualquestiondata->options->$optionname); } } $answerindexes = array(); foreach ($questiondata->options->answers as $ansindex => $answer) { $actualanswer = array_shift($actualquestiondata->options->answers); foreach ($answer as $ansproperty => $ansvalue) { // This question does not use 'answerformat', will ignore it. if (!in_array($ansproperty, array('id', 'question', 'answerformat'))) { $this->assertEquals($ansvalue, $actualanswer->$ansproperty); } } $answerindexes[$answer->answer] = $ansindex; } $this->assertEquals($questiondata->options->trueanswer, $answerindexes['True']); $this->assertEquals($questiondata->options->falseanswer, $answerindexes['False']); } } PK\a7F F )type/truefalse/tests/walkthrough_test.phpnu[. namespace qtype_truefalse; use question_state; use question_display_options; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Walkthrough tests for the truefalse question type. * * @package qtype_truefalse * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_truefalse_question */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_false_right_does_not_show_feedback_when_not_answered(): void { // Create a true-false question with correct answer false. $tf = \test_question_maker::make_question('truefalse', 'false'); $this->start_attempt_at_question($tf, 'deferredfeedback', 1); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($tf), $this->get_does_not_contain_feedback_expectation(), new \question_contains_tag_with_contents('h4', get_string('questiontext', 'question'))); $this->assertEquals(get_string('false', 'qtype_truefalse'), $this->quba->get_right_answer_summary($this->slot)); $this->assertMatchesRegularExpression('/' . preg_quote($tf->questiontext, '/') . '/', $this->quba->get_question_summary($this->slot)); $this->assertNull($this->quba->get_response_summary($this->slot)); // Finish the attempt without answering. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(false, false), $this->get_contains_tf_false_radio_expectation(false, false), // In particular, check that the false feedback is not displayed. new \question_no_pattern_expectation('/' . preg_quote($tf->falsefeedback, '/') . '/')); } public function test_each_attempt_builds_on_last_and_regrade(): void { // Create a true-false question with correct answer false. $tf = \test_question_maker::make_question('truefalse', 'false'); $this->start_attempt_at_question(clone($tf), 'deferredfeedback', 1); // Submit the answer false (correct). $this->process_submission(['answer' => 0]); // Finish the attempt. $this->quba->finish_all_questions(); // Check the state. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(1); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(false, false), $this->get_contains_tf_false_radio_expectation(false, true)); // Start a new attempt based on the first one. $firstattemptqa = $this->quba->get_question_attempt($this->slot); $this->quba = \question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question(clone($tf), 1); $this->quba->start_question_based_on($this->slot, $firstattemptqa); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(true, false), $this->get_contains_tf_false_radio_expectation(true, true)); // Finish the attempt without changing the answer. $this->quba->finish_all_questions(); // Check the state. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(1); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(false, false), $this->get_contains_tf_false_radio_expectation(false, true)); // Regrade the attempt - code based on question_usage_by_activity::regrade_question. $oldqa = $this->quba->get_question_attempt($this->slot); $newqa = new \question_attempt(clone($tf), $oldqa->get_usage_id(), $this->quba->get_observer()); $newqa->set_database_id($oldqa->get_database_id()); $newqa->set_slot($oldqa->get_slot()); $newqa->regrade($oldqa, true); // Check the state. $this->assertEquals(question_state::$gradedright, $newqa->get_state()); $this->assertEquals(1, $newqa->get_mark()); } /** * @covers \qtype_truefalse_renderer::formulation_and_controls */ public function test_deferredfeedback_feedback_multichoice_single_showstandardinstruction_yes(): void { // Create a true-false question with correct answer false. $tf = \test_question_maker::make_question('truefalse', 'false'); $tf->showstandardinstruction = true; $this->start_attempt_at_question($tf, 'deferredfeedback', 1); $this->render(); // Check for 'Show standard instruction'. $standardinstruction = get_string('selectone', 'qtype_truefalse'); $this->assertStringContainsString($standardinstruction, $this->currentoutput); } /** * @covers \qtype_truefalse_renderer::formulation_and_controls */ public function test_deferredfeedback_feedback_multichoice_single_showstandardinstruction_no(): void { // Create a true-false question with correct answer false. $tf = \test_question_maker::make_question('truefalse', 'false'); $tf->showstandardinstruction = false; $this->start_attempt_at_question($tf, 'deferredfeedback', 1); $this->render(); // Check for 'Show standard instruction'. $standardinstruction = \html_writer::tag('legend', get_string('answer'), [ 'class' => 'prompt h6 fw-normal visually-hidden', ]); $this->assertStringContainsString($standardinstruction, $this->currentoutput); } /** * Tests that the general feedback box is not displayed when it is empty for a false answer. * * @covers ::format_generalfeedback */ public function test_false_right_does_not_show_empty_general_feedback_when_answer(): void { $tf = \test_question_maker::make_question('truefalse', 'false'); $this->start_attempt_at_question($tf, 'deferredfeedback', 1); $this->process_submission(data: ['answer' => 0]); $this->quba->finish_all_questions(); $displayoptions = new question_display_options(); $html = $this->quba->render_question(1, $displayoptions); $pattern = "/
    \s*
    \s*{$tf->generalfeedback}\s*<\/div>\s*<\/div>/"; $this->assertMatchesRegularExpression($pattern, $html); $tf->generalfeedback = ""; $this->start_attempt_at_question($tf, 'deferredfeedback', 1); $this->process_submission(data: ['answer' => 0]); $this->quba->finish_all_questions(); $html = $this->quba->render_question(1, $displayoptions); $pattern = "/
    \s*
    <\/div>\s*<\/div>/"; $this->assertDoesNotMatchRegularExpression($pattern, $html); } } PK\( __type/truefalse/tests/helper.phpnu[. /** * Test helpers for the truefalse question type. * * @package qtype * @subpackage truefalse * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the truefalse question type. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse_test_helper extends question_test_helper { public function get_test_questions() { return array('true', 'false'); } /** * Makes a truefalse question with correct answer true. * @return qtype_truefalse_question */ public function make_truefalse_question_true() { question_bank::load_question_definition_classes('truefalse'); $tf = new qtype_truefalse_question(); test_question_maker::initialise_a_question($tf); $tf->name = 'True/false question'; $tf->questiontext = 'The answer is true.'; $tf->generalfeedback = 'You should have selected true.'; $tf->penalty = 1; $tf->qtype = question_bank::get_qtype('truefalse'); $tf->rightanswer = true; $tf->truefeedback = 'This is the right answer.'; $tf->falsefeedback = 'This is the wrong answer.'; $tf->truefeedbackformat = FORMAT_HTML; $tf->falsefeedbackformat = FORMAT_HTML; $tf->trueanswerid = 13; $tf->falseanswerid = 14; return $tf; } public function get_truefalse_question_form_data_true() { $form = new stdClass(); $form->name = 'True/false question'; $form->questiontext = array(); $form->questiontext['format'] = '1'; $form->questiontext['text'] = 'The answer is true.'; $form->defaultmark = 1; $form->generalfeedback = array(); $form->generalfeedback['format'] = '1'; $form->generalfeedback['text'] = 'You should have selected true.'; $form->correctanswer = '1'; $form->feedbacktrue = array(); $form->feedbacktrue['format'] = '1'; $form->feedbacktrue['text'] = 'This is the right answer.'; $form->feedbackfalse = array(); $form->feedbackfalse['format'] = '1'; $form->feedbackfalse['text'] = 'This is the wrong answer.'; $form->penalty = 1; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } function get_truefalse_question_data_true() { $q = new stdClass(); $q->name = 'True/false question'; $q->questiontext = 'The answer is true.'; $q->questiontextformat = FORMAT_HTML; $q->generalfeedback = 'You should have selected true.'; $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 1; $q->penalty = 1; $q->qtype = 'truefalse'; $q->length = '1'; $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->createdby = '2'; $q->modifiedby = '2'; $q->options = new stdClass(); $q->options->trueanswer = '0'; $q->options->falseanswer = '1'; $q->options->answers = array(); $q->options->answers[0] = new stdClass(); $q->options->answers[0]->answer = 'True'; $q->options->answers[0]->fraction = 1.0; $q->options->answers[0]->feedback = 'This is the right answer.'; $q->options->answers[0]->feedbackformat = FORMAT_HTML; $q->options->answers[1] = new stdClass(); $q->options->answers[1]->answer = 'False'; $q->options->answers[1]->fraction = 0.0; $q->options->answers[1]->feedback = 'This is the wrong answer.'; $q->options->answers[1]->feedbackformat = FORMAT_HTML; return $q; } /** * Makes a truefalse question with correct answer false. * @return qtype_truefalse_question */ public function make_truefalse_question_false() { question_bank::load_question_definition_classes('truefalse'); $tf = new qtype_truefalse_question(); test_question_maker::initialise_a_question($tf); $tf->name = 'True/false question'; $tf->questiontext = 'The answer is false.'; $tf->generalfeedback = 'You should have selected false.'; $tf->penalty = 1; $tf->qtype = question_bank::get_qtype('truefalse'); $tf->rightanswer = false; $tf->truefeedback = 'This is the wrong answer.'; $tf->falsefeedback = 'This is the right answer.'; $tf->truefeedbackformat = FORMAT_HTML; $tf->falsefeedbackformat = FORMAT_HTML; $tf->trueanswerid = 13; $tf->falseanswerid = 14; return $tf; } } PK\0jG6type/truefalse/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_truefalse; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of truefalse question attempts. * * @package qtype_truefalse * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_truefalse_deferredfeedback_history620(): void { $quiz = (object) array( 'id' => '203', 'course' => '2359', 'name' => 'Quiz 1', 'intro' => '', 'introformat' => FORMAT_HTML, 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '1', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71760879', 'questionsperpage' => '2', 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '50', 'grade' => '50', 'timecreated' => '0', 'timemodified' => '1176461532', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '3795', 'uniqueid' => '3795', 'quiz' => '203', 'userid' => '1888', 'attempt' => '1', 'sumgrades' => '40', 'timestart' => '1177841172', 'timefinish' => '1177841409', 'timemodified' => '1177841394', 'layout' => '3859,3860,0,3861,3862,0,3863,3864,0,3865,3866,0,3867,3868,0', 'preview' => '0', ); $question = (object) array( 'id' => '3865', 'category' => '187', 'parent' => '0', 'name' => 'Question 7', 'questiontext' => '

    The term ‘integration server’ is another name for an application server, true or false?

    ', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => 'learn.open.ac.uk+070404143040+oLimmG', 'version' => 'learn.open.ac.uk+070405112705+DLhORU', 'hidden' => '0', 'generalfeedback' => '

    ', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '5', 'options' => (object) array( 'id' => '98', 'question' => '3865', 'trueanswer' => '11693', 'falseanswer' => '11694', 'answers' => array( 11693 => (object) array( 'question' => '3865', 'answer' => 'True', 'fraction' => '0', 'feedback' => '', 'id' => 11693, ), 11694 => (object) array( 'question' => '3865', 'answer' => 'False', 'fraction' => '1', 'feedback' => '', 'id' => 11694, ), ), ), 'hints' => false, ); $qsession = (object) array( 'id' => '35137', 'attemptid' => '3795', 'questionid' => '3865', 'newest' => '84791', 'newgraded' => '84791', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 84771 => (object) array( 'attempt' => '3795', 'question' => '3865', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1177841172', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 84771, ), 84785 => (object) array( 'attempt' => '3795', 'question' => '3865', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '11694', 'timestamp' => '1177841361', 'event' => '2', 'grade' => '0', 'raw_grade' => '5', 'penalty' => '5', 'id' => 84785, ), 84791 => (object) array( 'attempt' => '3795', 'question' => '3865', 'originalquestion' => '0', 'seq_number' => '2', 'answer' => '11694', 'timestamp' => '1177841361', 'event' => '6', 'grade' => '5', 'raw_grade' => '5', 'penalty' => '5', 'id' => 84791, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 3865, 'variant' => 1, 'maxmark' => 5, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'The term ‘integration server’ is another name for an application server, true or false?', 'rightanswer' => 'False', 'responsesummary' => 'False', 'timemodified' => 1177841361, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1177841172, 'userid' => 1888, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1177841361, 'userid' => 1888, 'data' => array('answer' => 0), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedright', 'fraction' => 1, 'timecreated' => 1177841361, 'userid' => 1888, 'data' => array('answer' => 0, '-finish' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_truefalse_deferredfeedback_history20(): void { $quiz = (object) array( 'id' => '551', 'course' => '2828', 'name' => 'Unit 4 Quiz', 'intro' => '', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71760879', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '4', 'grade' => '4', 'timecreated' => '0', 'timemodified' => '1190277883', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '23226', 'uniqueid' => '23226', 'quiz' => '551', 'userid' => '80300', 'attempt' => '2', 'sumgrades' => '0', 'timestart' => '1200326384', 'timefinish' => '0', 'timemodified' => '1200326384', 'layout' => '9043,0,9057,0,9062,0,9241,0', 'preview' => '0', ); $question = (object) array( 'id' => '9062', 'category' => '481', 'parent' => '0', 'name' => 'U04_NA_In viaggio_Q3', 'questiontext' => '

    my market

    What can you buy in this shop? Is this list accurate?


    Mark true or false (for the list as a whole).

    single tickets

    weekly season tickets

    monthly season tickets

    wine and grappa

    fruit and vegetables

    tobacco

    ', 'questiontextformat' => '1', 'defaultmark' => '1', 'penalty' => '0', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => 'learn.open.ac.uk+070820163735+PqUlDM', 'version' => 'learn.open.ac.uk+080304160318+owhQUb', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '1204646598', 'createdby' => null, 'modifiedby' => '97230', 'unlimited' => '0', 'maxmark' => '1', 'options' => (object) array( 'id' => '199', 'question' => '9062', 'trueanswer' => '28221', 'falseanswer' => '28222', 'answers' => array( 28221 => (object) array( 'question' => '9062', 'answer' => 'True', 'fraction' => '0', 'feedback' => '

    The correct answer is \'false\'. The only items on the list not sold at My Market are tobacco and weekly season tickets. It sells everything else!
    Biglietto unico is a single ticket for bus, funicular railway or metro (underground) while abbonamento mensile is a monthly season ticket.
    The shop also sells wine and grappa, as you can see them in the window, and fruit and vegetables as it says in the sign. Small shops in Naples often sell a variety of things, not always connected!

    ', 'id' => 28221, ), 28222 => (object) array( 'question' => '9062', 'answer' => 'False', 'fraction' => '1', 'feedback' => 'The correct anstwer is \'false\'. The only items on the list not sold at My Market are tobacco and weekly season tickets. It sells everything else! Biglietto unico is a single ticket for bus, funicular railway or metro (underground) while \'abbonamento mensile\' is a monthly season ticket. It also sells wine and grappa, as you can see them in the window, and fruit and vegetables as it says in the sign! Small shops in Naples often sell a variety of things, not always connected! ', 'id' => 28222, ), ), ), 'hints' => false, ); $qsession = (object) array( 'id' => '351032', 'attemptid' => '23226', 'questionid' => '9062', 'newest' => '848428', 'newgraded' => '848426', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 848426 => (object) array( 'attempt' => '23226', 'question' => '9062', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1200326384', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 848426, ), 848428 => (object) array( 'attempt' => '23226', 'question' => '9062', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '28221', 'timestamp' => '1200326384', 'event' => '2', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '1', 'id' => 848428, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 9062, 'variant' => 1, 'maxmark' => 1, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "[my market] \n\nWhat can you buy in this shop? Is this list accurate? \n\nMark true or false (for the list as a whole). \n\n_single tickets_ \n\n_weekly season tickets_ \n\n_monthly season tickets_ \n\n_wine and grappa_ \n\n_fruit and vegetables_ \n\n_tobacco _", 'rightanswer' => 'False', 'responsesummary' => 'True', 'timemodified' => 1200326384, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1200326384, 'userid' => 80300, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1200326384, 'userid' => 80300, 'data' => array('answer' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_truefalse_deferredfeedback_history90(): void { $quiz = (object) array( 'id' => '3', 'course' => '1095', 'name' => 'Introduction quiz', 'intro' => 'Use this self-assessment quiz after you have read the course introduction. ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '1150107000', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71760879', 'questionsperpage' => '0', 'shufflequestions' => '1', 'shuffleanswers' => '1', 'sumgrades' => '9', 'grade' => '9', 'timecreated' => '0', 'timemodified' => '1150127779', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '19', 'uniqueid' => '19', 'quiz' => '3', 'userid' => '49542', 'attempt' => '1', 'sumgrades' => '9', 'timestart' => '1150301292', 'timefinish' => '1150301347', 'timemodified' => '1150454872', 'layout' => '96,108,102,101,106,113,104,98,111,0', 'preview' => '0', ); $question = (object) array( 'id' => '111', 'category' => '5', 'parent' => '0', 'name' => 'Q7', 'questiontext' => 'Web services, integration servers, XML, application servers, message-oriented middleware and remote procedure call can be used to enable integrated systems?', 'questiontextformat' => '1', 'defaultgrade' => '1', 'penalty' => '0', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => 'learn.open.ac.uk+060612113518+uuFWow', 'version' => 'learn.open.ac.uk+060612154736+HeFOV0', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '1', 'options' => (object) array( 'id' => '24', 'question' => '111', 'trueanswer' => '312', 'falseanswer' => '313', 'answers' => array( 312 => (object) array( 'question' => '111', 'answer' => 'True', 'fraction' => '1', 'feedback' => 'They are all used in storing data or connecting together components of an integrated system.', 'id' => 312, ), 313 => (object) array( 'question' => '111', 'answer' => 'False', 'fraction' => '0', 'feedback' => 'They are all used in storing data or connecting together components of an integrated system.', 'id' => 313, ), ), ), 'hints' => false, ); $qsession = (object) array( 'id' => '169', 'attemptid' => '19', 'questionid' => '111', 'newest' => '252', 'newgraded' => '252', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 242 => (object) array( 'attempt' => '19', 'question' => '111', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1150301292', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 242, ), 252 => (object) array( 'attempt' => '19', 'question' => '111', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '', 'timestamp' => '1150454872', 'event' => '9', 'grade' => '1', 'raw_grade' => '1', 'penalty' => '0', 'id' => 252, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'deferredfeedback', 'questionid' => 111, 'variant' => 1, 'maxmark' => 1, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'Web services, integration servers, XML, application servers, message-oriented middleware and remote procedure call can be used to enable integrated systems?', 'rightanswer' => 'True', 'responsesummary' => '', 'timemodified' => 1150454872, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1150301292, 'userid' => 49542, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'gradedwrong', 'fraction' => null, 'timecreated' => 1150454872, 'userid' => 49542, 'data' => array('-finish' => 1), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'mangrright', 'fraction' => 1, 'timecreated' => 1150454872, 'userid' => null, 'data' => array('-comment' => '', '-maxmark' => 1, '-mark' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_truefalse_adaptive_qsession119(): void { $quiz = (object) array( 'id' => '6', 'course' => '3', 'name' => 'Simply quiz', 'intro' => '

    One quiz with 1 true/false Q

    ', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'review' => '4459503', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '10.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1309103209', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', ); $attempt = (object) array( 'id' => '20', 'uniqueid' => '20', 'quiz' => '6', 'userid' => '7', 'attempt' => '1', 'sumgrades' => '10.00000', 'timestart' => '1309103112', 'timefinish' => '1309103120', 'timemodified' => '1309103120', 'layout' => '30,0', 'preview' => '0', ); $question = (object) array( 'id' => '30', 'category' => '10', 'parent' => '0', 'name' => '1 + 1 = 2 ?', 'questiontext' => '

    1 +1 = 2 ?

    ', 'questiontextformat' => '1', 'generalfeedback' => '

    this is general feedback

    ', 'generalfeedbackformat' => '1', 'penalty' => '1.0000000', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => '127.0.0.1+110626154410+wFrWwP', 'version' => '127.0.0.1+110626154410+u7CoaA', 'hidden' => '0', 'timecreated' => '1309103050', 'timemodified' => '1309103050', 'createdby' => '6', 'modifiedby' => '6', 'maxmark' => '10.0000000', 'options' => (object) array( 'id' => '4', 'question' => '30', 'trueanswer' => '53', 'falseanswer' => '54', 'answers' => array( 53 => (object) array( 'id' => '53', 'question' => '30', 'answer' => 'True', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '

    this is correct (for true) feedback

    ', 'feedbackformat' => '1', ), 54 => (object) array( 'id' => '54', 'question' => '30', 'answer' => 'False', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '

    this is incorrect (for false) feedback

    ', 'feedbackformat' => '1', ), ), ), 'defaultmark' => '1.0000000', ); $qsession = (object) array( 'id' => '119', 'attemptid' => '20', 'questionid' => '30', 'newest' => '312', 'newgraded' => '312', 'sumpenalty' => '10.0000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 310 => (object) array( 'id' => '310', 'attempt' => '20', 'question' => '30', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1309103112', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 311 => (object) array( 'id' => '311', 'attempt' => '20', 'question' => '30', 'seq_number' => '1', 'answer' => '53', 'timestamp' => '1309103115', 'event' => '3', 'grade' => '10.0000000', 'raw_grade' => '10.0000000', 'penalty' => '10.0000000', ), 312 => (object) array( 'id' => '312', 'attempt' => '20', 'question' => '30', 'seq_number' => '1', 'answer' => '53', 'timestamp' => '1309103115', 'event' => '6', 'grade' => '10.0000000', 'raw_grade' => '10.0000000', 'penalty' => '10.0000000', ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 30, 'variant' => 1, 'maxmark' => 10.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => '1 +1 = 2 ?', 'rightanswer' => 'True', 'responsesummary' => 'True', 'timemodified' => 1309103115, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1309103112, 'userid' => 7, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => 1.0, 'timecreated' => 1309103115, 'userid' => 7, 'data' => array('answer' => '1', '-_try' => '1', '-_rawfraction' => '1', '-submit' => '1'), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedright', 'fraction' => 1.0, 'timecreated' => 1309103115, 'userid' => 7, 'data' => array('answer' => '1', '-_try' => '1', '-_rawfraction' => '1', '-finish' => '1'), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_truefalse_adaptive_qsession120(): void { $quiz = (object) array( 'id' => '6', 'course' => '3', 'name' => 'Simply quiz', 'intro' => '

    One quiz with 1 true/false Q

    ', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'review' => '4459503', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '10.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1309103209', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', ); $attempt = (object) array( 'id' => '21', 'uniqueid' => '21', 'quiz' => '6', 'userid' => '7', 'attempt' => '2', 'sumgrades' => '0.00000', 'timestart' => '1309103130', 'timefinish' => '1309103136', 'timemodified' => '1309103136', 'layout' => '30,0', 'preview' => '0', ); $question = (object) array( 'id' => '30', 'category' => '10', 'parent' => '0', 'name' => '1 + 1 = 2 ?', 'questiontext' => '

    1 +1 = 2 ?

    ', 'questiontextformat' => '1', 'generalfeedback' => '

    this is general feedback

    ', 'generalfeedbackformat' => '1', 'penalty' => '1.0000000', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => '127.0.0.1+110626154410+wFrWwP', 'version' => '127.0.0.1+110626154410+u7CoaA', 'hidden' => '0', 'timecreated' => '1309103050', 'timemodified' => '1309103050', 'createdby' => '6', 'modifiedby' => '6', 'maxmark' => '10.0000000', 'options' => (object) array( 'id' => '4', 'question' => '30', 'trueanswer' => '53', 'falseanswer' => '54', 'answers' => array( 53 => (object) array( 'id' => '53', 'question' => '30', 'answer' => 'True', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '

    this is correct (for true) feedback

    ', 'feedbackformat' => '1', ), 54 => (object) array( 'id' => '54', 'question' => '30', 'answer' => 'False', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '

    this is incorrect (for false) feedback

    ', 'feedbackformat' => '1', ), ), ), 'defaultmark' => '1.0000000', ); $qsession = (object) array( 'id' => '120', 'attemptid' => '21', 'questionid' => '30', 'newest' => '315', 'newgraded' => '315', 'sumpenalty' => '10.0000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 313 => (object) array( 'id' => '313', 'attempt' => '21', 'question' => '30', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1309103130', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 314 => (object) array( 'id' => '314', 'attempt' => '21', 'question' => '30', 'seq_number' => '1', 'answer' => '54', 'timestamp' => '1309103132', 'event' => '2', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '10.0000000', ), 315 => (object) array( 'id' => '315', 'attempt' => '21', 'question' => '30', 'seq_number' => '2', 'answer' => '54', 'timestamp' => '1309103132', 'event' => '6', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '10.0000000', ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 30, 'variant' => 1, 'maxmark' => 10.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => '1 +1 = 2 ?', 'rightanswer' => 'True', 'responsesummary' => 'False', 'timemodified' => 1309103132, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1309103130, 'userid' => 7, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1309103132, 'userid' => 7, 'data' => array('answer' => '0'), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedwrong', 'fraction' => 0.0, 'timecreated' => 1309103132, 'userid' => 7, 'data' => array('answer' => 0, '-finish' => 1, '-_try' => 1, '-_rawfraction' => 0), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_truefalse_adaptive_qsession3(): void { $quiz = (object) array( 'id' => '1', 'course' => '2', 'name' => 'Test Quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'adaptive', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '1.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1309441728', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', ); $attempt = (object) array( 'id' => '3', 'uniqueid' => '3', 'quiz' => '1', 'userid' => '4', 'attempt' => '2', 'sumgrades' => null, 'timestart' => '1309441460', 'timefinish' => '1309441471', 'timemodified' => '1309441969', 'layout' => '1,0', 'preview' => '0', ); $question = (object) array( 'id' => '1', 'category' => '2', 'parent' => '0', 'name' => 'Does 1 + 1 = 2?', 'questiontext' => '', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '1.0000000', 'qtype' => 'truefalse', 'length' => '1', 'stamp' => 'localhost:8888+110630134237+QzfsHZ', 'version' => 'localhost:8888+110630134237+IaYGE6', 'hidden' => '0', 'timecreated' => '1309441357', 'timemodified' => '1309441357', 'createdby' => '3', 'modifiedby' => '3', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '1', 'question' => '1', 'trueanswer' => '1', 'falseanswer' => '2', 'answers' => array( 1 => (object) array( 'id' => '1', 'question' => '1', 'answer' => 'True', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', ), 2 => (object) array( 'id' => '2', 'question' => '1', 'answer' => 'False', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', ), ), ), ); $qsession = (object) array( 'id' => '3', 'attemptid' => '3', 'questionid' => '1', 'newest' => '7', 'newgraded' => '7', 'sumpenalty' => '1.0000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 5 => (object) array( 'id' => '5', 'attempt' => '3', 'question' => '1', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1309441460', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 6 => (object) array( 'id' => '6', 'attempt' => '3', 'question' => '1', 'seq_number' => '1', 'answer' => '1', 'timestamp' => '1309441463', 'event' => '3', 'grade' => '1.0000000', 'raw_grade' => '1.0000000', 'penalty' => '1.0000000', ), 7 => (object) array( 'id' => '7', 'attempt' => '3', 'question' => '1', 'seq_number' => '1', 'answer' => '1', 'timestamp' => '1309441463', 'event' => '6', 'grade' => '1.0000000', 'raw_grade' => '1.0000000', 'penalty' => '1.0000000', ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 1, 'variant' => 1, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => '', 'rightanswer' => 'True', 'responsesummary' => 'True', 'timemodified' => 1309441463, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1309441460, 'userid' => 4, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => 1, 'timecreated' => 1309441463, 'userid' => 4, 'data' => array('answer' => 1, '-submit' => 1, '-_try' => 1, '-_rawfraction' => 1), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedright', 'fraction' => 1, 'timecreated' => 1309441463, 'userid' => 4, 'data' => array('answer' => 1, '-finish' => 1, '-_try' => 1, '-_rawfraction' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\/ &type/truefalse/tests/behat/add.featurenu[@qtype @qtype_truefalse Feature: Test creating a True/False question As a teacher In order to test my students I need to be able to create a True/False question Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | Scenario: Create a True/False question with Correct answer as False When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "True/False" question filling the form with: | Question name | true-false-001 | | Question text | Manchester is the capital city of England. | | Default mark | 1 | | General feedback | London is the capital city of England. | | Correct answer | False | | Feedback for the response 'True'. | Well done! | | Feedback for the response 'False'. | Read more about England. | Then I should see "true-false-001" @javascript Scenario: Create a True/False question with Correct answer as True When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "True/False" question filling the form with: | Question name | true-false-002 | | Question text | London is the capital city of England. | | Default mark | 1 | | General feedback | London is the capital city of England. | | Correct answer | True | | Show standard instructions | Yes | | Feedback for the response 'True'. | Well done! | | Feedback for the response 'False'. | Read more about England. | Then I should see "true-false-002" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "True/False" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | Show standard instructions | Yes | PK\#",,'type/truefalse/tests/behat/edit.featurenu[@qtype @qtype_truefalse Feature: Test editing a True/False question As a teacher In order to be able to update my True/False question I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | truefalse | true-false-001 | true | Scenario: Edit a True/False question When I am on the "true-false-001" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | | And I press "id_submitbutton" Then I should see "You must supply a value here." And I set the following fields to these values: | Question name | Edited true-false-001 name | And I press "id_submitbutton" And I should see "Edited true-false-001 name" PK\k--)type/truefalse/tests/behat/export.featurenu[@qtype @qtype_truefalse Feature: Test exporting True/False questions As a teacher In order to be able to reuse my True/false questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | truefalse | true-false-001 | true | Scenario: Export a True/False question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | true-false-001 | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\E4>>)type/truefalse/tests/behat/import.featurenu[@qtype @qtype_truefalse Feature: Test importing True/False questions As a teacher In order to reuse True/False questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import a True/False question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/truefalse/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "The answer is true." And I press "Continue" And I should see "true-false-001" PK\QL5type/truefalse/tests/behat/backup_and_restore.featurenu[@qtype @qtype_truefalse Feature: Test duplicating a quiz containing a True/False question As a teacher In order re-use my courses containing True/False questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | truefalse | true-false-001 | true | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | true-false-001 | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing a True/False question When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "true-false-001" in the question bank Then the following fields match these values: | Question name | true-false-001 | | Question text | The answer is true. | | Default mark | 1 | | General feedback | You should have selected true. | | Correct answer | True | | Feedback for the response 'True'. | This is the right answer. | | Feedback for the response 'False'. | This is the wrong answer. | PK\-!*type/truefalse/tests/behat/preview.featurenu[@qtype @qtype_truefalse Feature: Preview a Trtue/False question As a teacher In order to check my True/False questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | truefalse | true-false-001 | true | @javascript @_switch_window Scenario: Preview a True/False question and submit a correct response. When I am on the "true-false-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I click on "True" "radio" And I press "Check" And I should see "This is the right answer." And I should see "The correct answer is 'True'." @javascript @_switch_window Scenario: Preview a True/False question and submit an incorrect response. When I am on the "true-false-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I click on "False" "radio" And I press "Check" And I should see "This is the wrong answer." And I should see "You should have selected true." And I should see "The correct answer is 'True'." PK\ type/truefalse/questiontype.phpnu[. /** * Question type class for the true-false question type. * * @package qtype * @subpackage truefalse * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); /** * The true-false question type class. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse extends question_type { public function save_question_options($question) { global $DB; $result = new stdClass(); $context = $question->context; // Fetch old answer ids so that we can reuse them. $oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); // Save the true answer - update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer->answer = get_string('true', 'qtype_truefalse'); $answer->fraction = $question->correctanswer; $answer->feedback = $this->import_or_save_files($question->feedbacktrue, $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedbacktrue['format']; $DB->update_record('question_answers', $answer); $trueid = $answer->id; // Save the false answer - update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer->answer = get_string('false', 'qtype_truefalse'); $answer->fraction = 1 - (int)$question->correctanswer; $answer->feedback = $this->import_or_save_files($question->feedbackfalse, $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedbackfalse['format']; $DB->update_record('question_answers', $answer); $falseid = $answer->id; // Delete any left over old answer records. $fs = get_file_storage(); foreach ($oldanswers as $oldanswer) { $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); $DB->delete_records('question_answers', array('id' => $oldanswer->id)); } // Save question options in question_truefalse table. if ($options = $DB->get_record('question_truefalse', array('question' => $question->id))) { // No need to do anything, since the answer IDs won't have changed // But we'll do it anyway, just for robustness. $options->trueanswer = $trueid; $options->falseanswer = $falseid; $options->showstandardinstruction = !empty($question->showstandardinstruction); $DB->update_record('question_truefalse', $options); } else { $options = new stdClass(); $options->question = $question->id; $options->trueanswer = $trueid; $options->falseanswer = $falseid; $options->showstandardinstruction = !empty($question->showstandardinstruction); $DB->insert_record('question_truefalse', $options); } $this->save_hints($question); return true; } public function save_defaults_for_new_questions(stdClass $fromform): void { parent::save_defaults_for_new_questions($fromform); $this->set_default_value('showstandardinstruction', $fromform->showstandardinstruction); } /** * Loads the question type specific options for the question. */ public function get_question_options($question) { global $DB, $OUTPUT; parent::get_question_options($question); // Get additional information from database // and attach it to the question object. if (!$question->options = $DB->get_record('question_truefalse', array('question' => $question->id))) { echo $OUTPUT->notification('Error: Missing question options!'); return false; } // Load the answers. if (!$question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { echo $OUTPUT->notification('Error: Missing question answers for truefalse question ' . $question->id . '!'); return false; } return true; } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $answers = $questiondata->options->answers; /** @var qtype_truefalse_question $question */ $question->rightanswer = $answers[$questiondata->options->trueanswer]->fraction > 0.99; $question->truefeedback = $answers[$questiondata->options->trueanswer]->feedback; $question->falsefeedback = $answers[$questiondata->options->falseanswer]->feedback; $question->truefeedbackformat = $answers[$questiondata->options->trueanswer]->feedbackformat; $question->falsefeedbackformat = $answers[$questiondata->options->falseanswer]->feedbackformat; $question->trueanswerid = $questiondata->options->trueanswer; $question->falseanswerid = $questiondata->options->falseanswer; $question->showstandardinstruction = $questiondata->options->showstandardinstruction; } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('question_truefalse', array('question' => $questionid)); parent::delete_question($questionid, $contextid); } public function move_files($questionid, $oldcontextid, $newcontextid) { parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); } protected function delete_files($questionid, $contextid) { parent::delete_files($questionid, $contextid); $this->delete_files_in_answers($questionid, $contextid); } public function get_random_guess_score($questiondata) { return 0.5; } public function get_possible_responses($questiondata) { return array( $questiondata->id => array( 0 => new question_possible_response(get_string('false', 'qtype_truefalse'), $questiondata->options->answers[ $questiondata->options->falseanswer]->fraction), 1 => new question_possible_response(get_string('true', 'qtype_truefalse'), $questiondata->options->answers[ $questiondata->options->trueanswer]->fraction), null => question_possible_response::no_response() ) ); } } PK\ 44&type/truefalse/edit_truefalse_form.phpnu[. /** * Defines the editing form for the true-false question type. * * @package qtype * @subpackage truefalse * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot.'/question/type/edit_question_form.php'); /** * True-false question editing form definition. * * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_truefalse_edit_form extends question_edit_form { /** * Add question-type specific form fields. * * @param object $mform the form being built. */ protected function definition_inner($mform) { $mform->addElement('select', 'correctanswer', get_string('correctanswer', 'qtype_truefalse'), array( 0 => get_string('false', 'qtype_truefalse'), 1 => get_string('true', 'qtype_truefalse'))); $mform->addElement('selectyesno', 'showstandardinstruction', get_string('showstandardinstruction', 'qtype_truefalse')); $mform->addHelpButton('showstandardinstruction', 'showstandardinstruction', 'qtype_truefalse'); $mform->setDefault('showstandardinstruction', $this->get_default_value('showstandardinstruction', 0)); $mform->addElement('editor', 'feedbacktrue', get_string('feedbacktrue', 'qtype_truefalse'), array('rows' => 10), $this->editoroptions); $mform->setType('feedbacktrue', PARAM_RAW); $mform->addElement('editor', 'feedbackfalse', get_string('feedbackfalse', 'qtype_truefalse'), array('rows' => 10), $this->editoroptions); $mform->setType('feedbackfalse', PARAM_RAW); $mform->addElement('header', 'multitriesheader', get_string('settingsformultipletries', 'question')); $mform->addElement('hidden', 'penalty', 1); $mform->setType('penalty', PARAM_FLOAT); $mform->addElement('static', 'penaltymessage', get_string('penaltyforeachincorrecttry', 'question'), 1); $mform->addHelpButton('penaltymessage', 'penaltyforeachincorrecttry', 'question'); } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); if (!empty($question->options->trueanswer)) { $trueanswer = $question->options->answers[$question->options->trueanswer]; $question->correctanswer = ($trueanswer->fraction != 0); $draftid = file_get_submitted_draft_itemid('trueanswer'); $answerid = $question->options->trueanswer; $question->feedbacktrue = array(); $question->feedbacktrue['format'] = $trueanswer->feedbackformat; $question->feedbacktrue['text'] = file_prepare_draft_area( $draftid, // Draftid $this->context->id, // context 'question', // component 'answerfeedback', // filarea !empty($answerid) ? (int) $answerid : null, // itemid $this->fileoptions, // options $trueanswer->feedback // text. ); $question->feedbacktrue['itemid'] = $draftid; } if (!empty($question->options->falseanswer)) { $falseanswer = $question->options->answers[$question->options->falseanswer]; $draftid = file_get_submitted_draft_itemid('falseanswer'); $answerid = $question->options->falseanswer; $question->feedbackfalse = array(); $question->feedbackfalse['format'] = $falseanswer->feedbackformat; $question->feedbackfalse['text'] = file_prepare_draft_area( $draftid, // Draftid $this->context->id, // context 'question', // component 'answerfeedback', // filarea !empty($answerid) ? (int) $answerid : null, // itemid $this->fileoptions, // options $falseanswer->feedback // text. ); $question->feedbackfalse['itemid'] = $draftid; } if (!empty($question->options)) { $question->showstandardinstruction = !empty($question->options->showstandardinstruction); } return $question; } public function qtype() { return 'truefalse'; } } PK\&type/missingtype/renderer.phpnu[. /** * Defines the 'missingtype' question renderer class. * * @package qtype * @subpackage missingtype * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * This question renderer class is used when the actual question type of this * question cannot be found. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_missingtype_renderer extends qtype_renderer { } PK\JО.type/missingtype/lang/en/qtype_missingtype.phpnu[. /** * Strings for component 'qtype_missingtype', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage missingtype * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['answerno'] = 'Answer {$a}'; $string['cannotchangeamissingqtype'] = 'You cannot make any changes to a question of a missing type.'; $string['deletedquestion'] = 'Missing question'; $string['deletedquestiontext'] = 'This question is missing. Unable to display anything.'; $string['missingqtypewarning'] = 'This question is of a type that is not currently installed on this system. You will not be able to do anything with this question.'; $string['missing'] = 'Question of a type that is not installed on this system'; $string['pluginname'] = 'Missing question type placeholder'; $string['pluginnameadding'] = 'Adding a question of a type that is not installed on this system'; $string['pluginnameediting'] = 'Editing a question of a type that is not installed on this system'; $string['privacy:metadata'] = 'The Select missing words question type plugin does not store any personal data.'; $string['warningmissingtype'] = 'This question is of a type that has not been installed on your Moodle yet.
    Please alert your Moodle administrator.
    '; PK\'type/missingtype/lang/en/deprecated.txtnu[PK\1>ttype/missingtype/pix/icon.gifnu[GIF89a"""DDDfff333UUUwww!,?PK\?cFFtype/missingtype/version.phpnu[. /** * Version information for missing question type plugin. * * @package qtype * @subpackage missingtype * @copyright 2011 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_missingtype'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\_N *type/missingtype/edit_missingtype_form.phpnu[. /** * Defines the editing form for the 'missingtype' question type. * * @package qtype * @subpackage missingtype * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * This question renderer class is used when the actual question type of this * question cannot be found. * * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_missingtype_edit_form extends question_edit_form { public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { parent::__construct($submiturl, $question, $category, $contexts, false); } /** * Add question-type specific form fields. * * @param object $mform the form being built. */ protected function definition_inner($mform) { $this->add_per_answer_fields($mform, get_string('answerno', 'qtype_missingtype', '{no}'), question_bank::fraction_options_full()); } public function set_data($question) { if (isset($question->options) && is_array($question->options->answers)) { $answers = $question->options->answers; $default_values = array(); $key = 0; foreach ($answers as $answer) { $default_values['answer['.$key.']'] = $answer->answer; $default_values['fraction['.$key.']'] = $answer->fraction; $default_values['feedback['.$key.']'] = $answer->feedback; $key++; } $question = (object)((array)$question + $default_values); } parent::set_data($question); } public function qtype() { return 'missingtype'; } public function validation($data, $files) { $errors = parent::validation($data, $files); $errors['name'] = get_string('cannotchangeamissingqtype', 'qtype_missingtype'); return $errors; } } PK\;h type/missingtype/question.phpnu[. /** * Defines the 'qtype_missingtype' question definition class. * * @package qtype * @subpackage missingtype * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * This question definition class is used when the actual question type of this * question cannot be found. * * Why does this this class implement question_automatically_gradable? I am not * sure at the moment. Perhaps it is important for it to work with as many * behaviours as possible. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_missingtype_question extends question_definition implements question_automatically_gradable { public function get_expected_data() { return array(); } public function get_correct_response() { return array(); } public function is_complete_response(array $response) { return false; } public function is_gradable_response(array $response) { return false; } public function get_validation_error(array $response) { return ''; } public function is_same_response(array $prevresponse, array $newresponse) { return true; } public function get_right_answer_summary() { return ''; } public function summarise_response(array $response) { return null; } public function un_summarise_response(string $response) { return []; } public function classify_response(array $response) { return array(); } public function start_attempt(question_attempt_step $step, $variant) { throw new coding_exception('This question is of a type that is not installed ' . 'on your system. No processing is possible.'); } public function grade_response(array $response) { throw new coding_exception('This question is of a type that is not installed ' . 'on your system. No processing is possible.'); } public function get_hint($hintnumber, question_attempt $qa) { return null; } } PK\G  -type/missingtype/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_missingtype. * * @package qtype_missingtype * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_missingtype\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_missingtype implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\ݽ_!type/missingtype/tests/helper.phpnu[. /** * Test helpers for the missingtype question type. * * @package qtype_missingtype * @copyright 2025 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Conn Warwicker */ class qtype_missingtype_test_helper extends question_test_helper { #[\Override] public function get_test_questions(): array { return ['invalid']; } /** * Gets the question form data for the invalid question. * * @return stdClass */ public function get_missingtype_question_form_data_invalid(): stdClass { $form = new stdClass(); $form->name = 'Invalid question'; $form->questiontext = ['text' => 'You will never see this', 'format' => FORMAT_HTML]; $form->defaultmark = 1.0; $form->generalfeedback = ['text' => 'How did you even submit this?', 'format' => FORMAT_HTML]; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } } PK\spjj+type/missingtype/tests/missingtype_test.phpnu[. namespace qtype_missingtype; use qbehaviour_deferredfeedback; use qtype_missingtype; use qtype_missingtype_question; use question_attempt_step; use question_bank; use question_contains_tag_with_attribute; use question_display_options; use question_state; use testable_question_attempt; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/tests/helpers.php'); require_once(__DIR__ . '/../../../behaviour/deferredfeedback/behaviour.php'); require_once(__DIR__ . '/../question.php'); require_once($CFG->dirroot . '/question/type/missingtype/questiontype.php'); /** * Unit tests for the 'missing' question type. * * @package qtype_missingtype * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class missingtype_test extends \question_testcase { protected function get_unknown_questiondata() { $questiondata = new \stdClass(); $questiondata->id = 0; $questiondata->category = 0; $questiondata->contextid = 0; $questiondata->parent = 0; $questiondata->name = 'Test'; $questiondata->questiontext = 'This is the question text.'; $questiondata->questiontextformat = FORMAT_HTML; $questiondata->generalfeedback = 'This is the general feedback.'; $questiondata->generalfeedbackformat = FORMAT_HTML; $questiondata->defaultmark = 1; $questiondata->penalty = 0.3333333; $questiondata->qtype = 'strange_unknown'; $questiondata->length = 1; $questiondata->stamp = make_unique_id_code(); $questiondata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questiondata->version = 1; $questiondata->versionid = 0; $questiondata->questionbankentryid = 0; $questiondata->idnumber = null; $questiondata->timecreated = 0; $questiondata->timemodified = 0; $questiondata->createdby = 0; $questiondata->modifiedby = 0; return $questiondata; } public function test_cannot_grade(): void { $q = new qtype_missingtype_question(); $this->expectException(\moodle_exception::class); $q->grade_response(array()); } public function test_load_qtype_strict(): void { $this->expectException(\moodle_exception::class); $qtype = question_bank::get_qtype('strange_unknown'); } public function test_load_qtype_lax(): void { $qtype = question_bank::get_qtype('strange_unknown', false); $this->assertInstanceOf('qtype_missingtype', $qtype); } public function test_make_question(): void { $questiondata = $this->get_unknown_questiondata(); $q = question_bank::make_question($questiondata); $this->assertInstanceOf('qtype_missingtype_question', $q); $this->assertEquals($q->questiontext, \html_writer::tag('div', get_string('missingqtypewarning', 'qtype_missingtype'), array('class' => 'warning missingqtypewarning')) . $questiondata->questiontext); } public function test_render_missing(): void { $questiondata = $this->get_unknown_questiondata(); $q = question_bank::make_question($questiondata); $qa = new testable_question_attempt($q, 0); $step = new question_attempt_step(array('answer' => 'frog')); $step->set_state(question_state::$todo); $qa->add_step($step); $qa->set_behaviour(new qbehaviour_deferredfeedback($qa, 'deferredfeedback')); $output = $qa->render(new question_display_options(), '1'); $this->assertMatchesRegularExpression('/' . preg_quote($qa->get_question(false)->questiontext, '/') . '/', $output); $this->assertMatchesRegularExpression('/' . preg_quote(get_string('missingqtypewarning', 'qtype_missingtype'), '/') . '/', $output); $this->assert(new question_contains_tag_with_attribute( 'div', 'class', 'warning missingqtypewarning'), $output); } public function test_get_question_summary(): void { $q = new qtype_missingtype_question(); $q->questiontext = 'Test'; $this->assertEquals('TEST', $q->get_question_summary()); } public function test_summarise_response(): void { $q = new qtype_missingtype_question(); $this->assertNull($q->summarise_response(array('a' => 'irrelevant'))); } public function test_can_analyse_responses(): void { $qtype = new qtype_missingtype(); $this->assertFalse($qtype->can_analyse_responses()); } public function test_get_random_guess_score(): void { $qtype = new qtype_missingtype(); $this->assertNull($qtype->get_random_guess_score(null)); } public function test_get_possible_responses(): void { $qtype = new qtype_missingtype(); $this->assertEquals(array(), $qtype->get_possible_responses(null)); } /** * Test moving a question category from one context to another when it contains questions of an invalid type * * @covers ::question_move_category_to_context */ public function test_move_question_category_with_missing_question_types(): void { global $DB; $this->resetAfterTest(); // Create a course we can move the category to. $course = $this->getDataGenerator()->create_course(); // Create a quiz module to attach the question category to. $module1 = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); // And another one to move things to. $module2 = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); // Create a question category on the system context. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category([ 'contextid' => \core\context\module::instance($module1->cmid)->id, ]); // Create a question of an invalid type and put it in the category. $question = $generator->create_question('missingtype', null, ['category' => $category->id]); // Update the question to set an invalid qtype, as "missingtype" is actually installed and won't fail. $question->qtype = 'invalid'; $DB->update_record('question', $question); // We just want to assert that no exception is thrown. $this->expectNotToPerformAssertions(); // Try and move the categories. question_move_category_to_context( $category->id, \core\context\module::instance($module1->cmid)->id, \core\context\module::instance($module2->cmid)->id, ); } } PK\c__;type/missingtype/tests/behat/invalid_question_types.featurenu[@qtype @qtype_missingtype Feature: Questions with invalid types should be clear and any actions which won't work should be disabled As a teacher In order to manage my questions I want to be able to clearly see which are invalid Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Default for Qbank 1 | essay | Question 1 | teacher1 | A text | | Default for Qbank 1 | essay | Question 2 | teacher1 | B text | And question "Question 2" is changed to simulate being of an uninstalled type Scenario: Questions of invalid types should be highlighted and labelled as invalid Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" Then I should see "Invalid question type: invalidqtype" in the table row containing "Question 2" And "//tr[contains(., 'Question 2')][contains(@class, 'table-danger')]" "xpath_element" should exist PK\;DD!type/missingtype/questiontype.phpnu[. /** * Question type class for the 'missingtype' type. * * @package qtype * @subpackage missingtype * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questiontypebase.php'); /** * Missing question type class * * When we encounter a question of a type that is not currently installed, then * we use this question type class instead so that some of the information about * this question can be seen, and the rest of the system keeps working. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_missingtype extends question_type { public function menu_name() { return false; } public function is_usable_by_random() { return false; } public function can_analyse_responses() { return false; } public function make_question($questiondata) { $question = parent::make_question($questiondata); $question->questiontext = html_writer::tag('div', get_string('missingqtypewarning', 'qtype_missingtype'), array('class' => 'warning missingqtypewarning')) . $question->questiontext; return $question; } public function make_deleted_instance($questionid, $maxmark) { question_bank::load_question_definition_classes('missingtype'); $question = new qtype_missingtype_question(); $question->id = $questionid; $question->category = null; $question->parent = 0; $question->qtype = question_bank::get_qtype('missingtype'); $question->name = get_string('deletedquestion', 'qtype_missingtype'); $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); $question->questiontextformat = FORMAT_HTML; $question->generalfeedback = ''; $question->defaultmark = $maxmark; $question->length = 1; $question->penalty = 0; $question->stamp = ''; $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $question->timecreated = null; $question->timemodified = null; $question->createdby = null; $question->modifiedby = null; return $question; } public function get_random_guess_score($questiondata) { return null; } public function display_question_editing_page($mform, $question, $wizardnow) { global $OUTPUT; echo $OUTPUT->heading(get_string('warningmissingtype', 'qtype_missingtype')); $mform->display(); } #[\Override] public function move_files($questionid, $oldcontextid, $newcontextid) { parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); } } PK\btype/edit_question_form.phpnu[. /** * A base class for question editing forms. * * @package moodlecore * @subpackage questiontypes * @copyright 2006 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir.'/formslib.php'); abstract class question_wizard_form extends moodleform { /** * Add all the hidden form fields used by question/question.php. */ protected function add_hidden_fields() { $mform = $this->_form; $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'inpopup'); $mform->setType('inpopup', PARAM_INT); $mform->addElement('hidden', 'cmid'); $mform->setType('cmid', PARAM_INT); $mform->addElement('hidden', 'courseid'); $mform->setType('courseid', PARAM_INT); $mform->addElement('hidden', 'returnurl'); $mform->setType('returnurl', PARAM_LOCALURL); $mform->addElement('hidden', 'mdlscrollto'); $mform->setType('mdlscrollto', PARAM_INT); $mform->addElement('hidden', 'appendqnumstring'); $mform->setType('appendqnumstring', PARAM_ALPHA); } } /** * Form definition base class. This defines the common fields that * all question types need. Question types should define their own * class that inherits from this one, and implements the definition_inner() * method. * * @copyright 2006 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ abstract class question_edit_form extends question_wizard_form { const DEFAULT_NUM_HINTS = 2; /** * Question object with options and answers already loaded by get_question_options * Be careful how you use this it is needed sometimes to set up the structure of the * form in definition_inner but data is always loaded into the form with set_data. * @var object */ protected $question; protected $contexts; protected $category; protected $categorycontext; /** @var object current context */ public $context; /** @var array html editor options */ public $editoroptions; /** @var array options to preapre draft area */ public $fileoptions; /** @var object instance of question type */ public $instance; /** @var object instance of custom field */ protected $customfieldhandler; /** @var bool custom field plugin enabled or disabled*/ protected $customfieldpluginenabled = true; public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { global $DB; $this->question = $question; $this->contexts = $contexts; // Get the question category id. if (isset($question->id)) { $qcategory = $question->categoryobject->id ?? get_question_bank_entry($question->id)->questioncategoryid; } else { $qcategory = $question->category; } $record = $DB->get_record('question_categories', array('id' => $qcategory), 'contextid'); $this->context = context::instance_by_id($record->contextid); $this->editoroptions = array('subdirs' => 1, 'maxfiles' => EDITOR_UNLIMITED_FILES, 'context' => $this->context); $this->fileoptions = array('subdirs' => 1, 'maxfiles' => -1, 'maxbytes' => -1); $this->category = $category; $this->categorycontext = context::instance_by_id($category->contextid); if (!\core\plugininfo\qbank::is_plugin_enabled('qbank_customfields')) { $this->customfieldpluginenabled = false; } parent::__construct($submiturl, null, 'post', '', ['data-qtype' => $this->qtype()], $formeditable); } /** * Return default value for a given form element either from user_preferences table or $default. * * To make use of user_preferences in your qtype default settings, you need to replace * $mform->setDefault({elementname}, {defaultvalue}); in edit_{qtypename}_form.php with * $mform->setDefault({elementname}, $this->get_default_value({elementname}, {defaultvalue})); * * @param string $name the name of the form field. * @param mixed $default default value. * @return string|null default value for a given form element. */ protected function get_default_value(string $name, $default): ?string { global $CFG; if (!empty($CFG->questiondefaultssave)) { return question_bank::get_qtype($this->qtype())->get_default_value($name, $default); } return $default; } /** * Build the form definition. * * This adds all the form fields that the default question type supports. * If your question type does not support all these fields, then you can * override this method and remove the ones you don't want with $mform->removeElement(). */ protected function definition() { global $DB, $PAGE; $mform = $this->_form; // Standard fields at the start of the form. $mform->addElement('header', 'generalheader', get_string("general", 'form')); if (!isset($this->question->id)) { if (!empty($this->question->formoptions->mustbeusable)) { $contexts = $this->contexts->having_add_and_use(); } else { $contexts = $this->contexts->having_cap('moodle/question:add'); } // Adding question. $mform->addElement('questioncategory', 'category', get_string('category', 'question'), array('contexts' => $contexts)); } else if (!($this->question->formoptions->canmove || $this->question->formoptions->cansaveasnew)) { // Editing question with no permission to move from category. $mform->addElement('questioncategory', 'category', get_string('category', 'question'), array('contexts' => array($this->categorycontext))); $mform->addElement('hidden', 'usecurrentcat', 1); $mform->setType('usecurrentcat', PARAM_BOOL); $mform->setConstant('usecurrentcat', 1); } else { // Editing question with permission to move from category or save as new q. $currentgrp = array(); $currentgrp[0] = $mform->createElement('questioncategory', 'category', get_string('categorycurrent', 'question'), array('contexts' => array($this->categorycontext))); // Validate if the question is being duplicated. $beingcopied = false; if (isset($this->question->beingcopied)) { $beingcopied = $this->question->beingcopied; } if (($this->question->formoptions->canedit || $this->question->formoptions->cansaveasnew) && ($beingcopied)) { // Not move only form. $currentgrp[1] = $mform->createElement('checkbox', 'usecurrentcat', '', get_string('categorycurrentuse', 'question')); $mform->setDefault('usecurrentcat', 1); } $currentgrp[0]->freeze(); $currentgrp[0]->setPersistantFreeze(false); $mform->addGroup($currentgrp, 'currentgrp', get_string('categorycurrent', 'question'), null, false); if (($beingcopied)) { $mform->addElement('questioncategory', 'categorymoveto', get_string('categorymoveto', 'question'), array('contexts' => array($this->categorycontext))); if ($this->question->formoptions->canedit || $this->question->formoptions->cansaveasnew) { // Not move only form. $mform->disabledIf('categorymoveto', 'usecurrentcat', 'checked'); } } } if (!empty($this->question->id) && !$this->question->beingcopied) { // Add extra information from plugins when editing a question (e.g.: Authors, version control and usage). $functionname = 'edit_form_display'; $questiondata = []; $plugins = get_plugin_list_with_function('qbank', $functionname); foreach ($plugins as $componentname => $plugin) { $element = new StdClass(); $element->pluginhtml = component_callback($componentname, $functionname, [$this->question]); $questiondata['editelements'][] = $element; } $mform->addElement('static', 'versioninfo', get_string('versioninfo', 'qbank_editquestion'), $PAGE->get_renderer('qbank_editquestion')->render_question_info($questiondata)); } $mform->addElement('text', 'name', get_string('questionname', 'question'), array('size' => 50, 'maxlength' => 255)); $mform->setType('name', PARAM_TEXT); $mform->addRule('name', null, 'required', null, 'client'); $mform->addElement('editor', 'questiontext', get_string('questiontext', 'question'), array('rows' => 15), $this->editoroptions); $mform->setType('questiontext', PARAM_RAW); $mform->addRule('questiontext', null, 'required', null, 'client'); $mform->addElement('select', 'status', get_string('status', 'qbank_editquestion'), \qbank_editquestion\editquestion_helper::get_question_status_list()); $mform->addElement('float', 'defaultmark', get_string('defaultmark', 'question'), array('size' => 7)); $mform->setDefault('defaultmark', $this->get_default_value('defaultmark', 1)); $mform->addRule('defaultmark', null, 'required', null, 'client'); $mform->addElement('editor', 'generalfeedback', get_string('generalfeedback', 'question'), array('rows' => 10), $this->editoroptions); $mform->setType('generalfeedback', PARAM_RAW); $mform->addHelpButton('generalfeedback', 'generalfeedback', 'question'); $mform->addElement('text', 'idnumber', get_string('idnumber', 'question'), 'maxlength="100" size="10"'); $mform->addHelpButton('idnumber', 'idnumber', 'question'); $mform->setType('idnumber', PARAM_RAW); // Any questiontype specific fields. $this->definition_inner($mform); if (core_tag_tag::is_enabled('core_question', 'question') && \core\plugininfo\qbank::is_plugin_enabled('qbank_tagquestion')) { $this->add_tag_fields($mform); } if ($this->customfieldpluginenabled) { // Add custom fields to the form. $this->customfieldhandler = qbank_customfields\customfield\question_handler::create(); $this->customfieldhandler->set_parent_context($this->categorycontext); // For question handler only. $this->customfieldhandler->instance_form_definition($mform, empty($this->question->id) ? 0 : $this->question->id); } $this->add_hidden_fields(); $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_ALPHA); $mform->addElement('hidden', 'makecopy'); $mform->setType('makecopy', PARAM_INT); $buttonarray = array(); $buttonarray[] = $mform->createElement('submit', 'updatebutton', get_string('savechangesandcontinueediting', 'question')); if ($this->can_preview()) { if (\core\plugininfo\qbank::is_plugin_enabled('qbank_previewquestion')) { $previewlink = $PAGE->get_renderer('qbank_previewquestion')->question_preview_link( $this->question->id, $this->context, true); $buttonarray[] = $mform->createElement('static', 'previewlink', '', $previewlink); } } $mform->addGroup($buttonarray, 'updatebuttonar', '', array(' '), false); $mform->closeHeaderBefore('updatebuttonar'); $this->add_action_buttons(true, get_string('savechanges')); if ((!empty($this->question->id)) && (!($this->question->formoptions->canedit || $this->question->formoptions->cansaveasnew))) { $mform->hardFreezeAllVisibleExcept(array('categorymoveto', 'buttonar', 'currentgrp')); } } /** * Add any question-type specific form fields. * * @param object $mform the form being built. */ protected function definition_inner($mform) { // By default, do nothing. } /** * Tweak the form with values provided by custom fields in use. */ public function definition_after_data() { $mform = $this->_form; if ($this->customfieldpluginenabled) { $this->customfieldhandler->instance_form_definition_after_data($mform, empty($this->question->id) ? 0 : $this->question->id); } } /** * Is the question being edited in a state where it can be previewed? * @return bool whether to show the preview link. */ protected function can_preview() { return empty($this->question->beingcopied) && !empty($this->question->id) && $this->question->formoptions->canedit; } /** * Get the list of form elements to repeat, one for each answer. * @param object $mform the form being built. * @param $label the label to use for each option. * @param $gradeoptions the possible grades for each answer. * @param $repeatedoptions reference to array of repeated options to fill * @param $answersoption reference to return the name of $question->options * field holding an array of answers * @return array of form fields. */ protected function get_per_answer_fields($mform, $label, $gradeoptions, &$repeatedoptions, &$answersoption) { $repeated = array(); $answeroptions = array(); $answeroptions[] = $mform->createElement('text', 'answer', $label, array('size' => 40)); $answeroptions[] = $mform->createElement('select', 'fraction', get_string('gradenoun'), $gradeoptions); $repeated[] = $mform->createElement('group', 'answeroptions', $label, $answeroptions, null, false); $repeated[] = $mform->createElement('editor', 'feedback', get_string('feedback', 'question'), array('rows' => 5), $this->editoroptions); $repeatedoptions['answer']['type'] = PARAM_RAW; $repeatedoptions['fraction']['default'] = 0; $answersoption = 'answers'; return $repeated; } /** * Add the tag and course tag fields to the mform. * * If the form is being built in a course context then add the field * for course tags. * * If the question category doesn't belong to a course context or we * aren't editing in a course context then add the tags element to allow * tags to be added to the question category context. * * @param object $mform The form being built */ protected function add_tag_fields($mform) { global $CFG, $DB; $hastagcapability = question_has_capability_on($this->question, 'tag'); // Is the question category in a course context? $qcontext = $this->categorycontext; $qcoursecontext = $qcontext->get_course_context(false); $iscourseoractivityquestion = !empty($qcoursecontext); // Is the current context we're editing in a course context? $editingcontext = $this->contexts->lowest(); $editingcoursecontext = $editingcontext->get_course_context(false); $iseditingcontextcourseoractivity = !empty($editingcoursecontext); $mform->addElement('header', 'tagsheader', get_string('tags')); $tags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $this->contexts->all()); $tagstrings = []; foreach ($tags as $tag) { $tagstrings[$tag->name] = $tag->name; } $showstandard = core_tag_area::get_showstandard('core_question', 'question'); if ($showstandard != core_tag_tag::HIDE_STANDARD) { $namefield = empty($CFG->keeptagnamecase) ? 'name' : 'rawname'; $standardtags = $DB->get_records('tag', array('isstandard' => 1, 'tagcollid' => core_tag_area::get_collection('core', 'question')), $namefield, 'id,' . $namefield); foreach ($standardtags as $standardtag) { $tagstrings[$standardtag->$namefield] = $standardtag->$namefield; } } $options = [ 'tags' => true, 'multiple' => true, 'noselectionstring' => get_string('anytags', 'quiz'), ]; $mform->addElement('autocomplete', 'tags', get_string('tags'), $tagstrings, $options); if (!$hastagcapability) { $mform->hardFreeze('tags'); } if ($iseditingcontextcourseoractivity && !$iscourseoractivityquestion) { // If the question is being edited in a course or activity context // and the question isn't a course or activity level question then // allow course tags to be added to the course. $coursetagheader = get_string('questionformtagheader', 'core_question', $editingcoursecontext->get_context_name(true)); $mform->addElement('header', 'coursetagsheader', $coursetagheader); $mform->addElement('autocomplete', 'coursetags', get_string('tags'), $tagstrings, $options); if (!$hastagcapability) { $mform->hardFreeze('coursetags'); } } } /** * Add a set of form fields, obtained from get_per_answer_fields, to the form, * one for each existing answer, with some blanks for some new ones. * @param object $mform the form being built. * @param $label the label to use for each option. * @param $gradeoptions the possible grades for each answer. * @param $minoptions the minimum number of answer blanks to display. * Default QUESTION_NUMANS_START. * @param $addoptions the number of answer blanks to add. Default QUESTION_NUMANS_ADD. */ protected function add_per_answer_fields(&$mform, $label, $gradeoptions, $minoptions = QUESTION_NUMANS_START, $addoptions = QUESTION_NUMANS_ADD) { $mform->addElement('header', 'answerhdr', get_string('answers', 'question'), ''); $mform->setExpanded('answerhdr', 1); $answersoption = ''; $repeatedoptions = array(); $repeated = $this->get_per_answer_fields($mform, $label, $gradeoptions, $repeatedoptions, $answersoption); if (isset($this->question->options)) { $repeatsatstart = count($this->question->options->$answersoption); } else { $repeatsatstart = $minoptions; } $this->repeat_elements($repeated, $repeatsatstart, $repeatedoptions, 'noanswers', 'addanswers', $addoptions, $this->get_more_choices_string(), true); } /** * Language string to use for 'Add {no} more {whatever we call answers}'. */ protected function get_more_choices_string() { return get_string('addmorechoiceblanks', 'question'); } protected function add_combined_feedback_fields($withshownumpartscorrect = false) { $mform = $this->_form; $mform->addElement('header', 'combinedfeedbackhdr', get_string('combinedfeedback', 'question')); $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); foreach ($fields as $feedbackname) { $element = $mform->addElement('editor', $feedbackname, get_string($feedbackname, 'question'), array('rows' => 5), $this->editoroptions); $mform->setType($feedbackname, PARAM_RAW); // Using setValue() as setDefault() does not work for the editor class. $element->setValue(array('text' => get_string($feedbackname.'default', 'question'))); if ($withshownumpartscorrect && $feedbackname == 'partiallycorrectfeedback') { $mform->addElement('advcheckbox', 'shownumcorrect', '', get_string('shownumpartscorrectwhenfinished', 'question')); $mform->setDefault('shownumcorrect', true); } } } /** * Create the form elements required by one hint. * @param string $withclearwrong whether this quesiton type uses the 'Clear wrong' option on hints. * @param string $withshownumpartscorrect whether this quesiton type uses the 'Show num parts correct' option on hints. * @return array form field elements for one hint. */ protected function get_hint_fields($withclearwrong = false, $withshownumpartscorrect = false) { $mform = $this->_form; $repeatedoptions = array(); $repeated = array(); $repeated[] = $mform->createElement('editor', 'hint', get_string('hintn', 'question'), array('rows' => 5), $this->editoroptions); $repeatedoptions['hint']['type'] = PARAM_RAW; $optionelements = array(); if ($withclearwrong) { $optionelements[] = $mform->createElement('advcheckbox', 'hintclearwrong', '', get_string('clearwrongparts', 'question')); } if ($withshownumpartscorrect) { $optionelements[] = $mform->createElement('advcheckbox', 'hintshownumcorrect', '', get_string('shownumpartscorrect', 'question')); } if (count($optionelements)) { $repeated[] = $mform->createElement('group', 'hintoptions', get_string('hintnoptions', 'question'), $optionelements, null, false); } return array($repeated, $repeatedoptions); } protected function add_interactive_settings($withclearwrong = false, $withshownumpartscorrect = false) { $mform = $this->_form; $mform->addElement('header', 'multitriesheader', get_string('settingsformultipletries', 'question')); $penalties = array( 1.0000000, 0.5000000, 0.3333333, 0.2500000, 0.2000000, 0.1000000, 0.0000000 ); if (!empty($this->question->penalty) && !in_array($this->question->penalty, $penalties)) { $penalties[] = $this->question->penalty; sort($penalties); } $penaltyoptions = array(); foreach ($penalties as $penalty) { $penaltyoptions["{$penalty}"] = format_float(100 * $penalty, 5, true, true) . '%'; } $mform->addElement('select', 'penalty', get_string('penaltyforeachincorrecttry', 'question'), $penaltyoptions); $mform->addHelpButton('penalty', 'penaltyforeachincorrecttry', 'question'); $mform->setDefault('penalty', $this->get_default_value('penalty', 0.3333333)); if (isset($this->question->hints)) { $counthints = count($this->question->hints); } else { $counthints = 0; } if ($this->question->formoptions->repeatelements) { $repeatsatstart = max(self::DEFAULT_NUM_HINTS, $counthints); } else { $repeatsatstart = $counthints; } list($repeated, $repeatedoptions) = $this->get_hint_fields( $withclearwrong, $withshownumpartscorrect); $this->repeat_elements($repeated, $repeatsatstart, $repeatedoptions, 'numhints', 'addhint', 1, get_string('addanotherhint', 'question'), true); } public function set_data($question) { question_bank::get_qtype($question->qtype)->set_default_options($question); // Prepare question text. $draftid = file_get_submitted_draft_itemid('questiontext'); if (!empty($question->questiontext)) { $questiontext = $question->questiontext; } else { $questiontext = $this->_form->getElement('questiontext')->getValue(); $questiontext = $questiontext['text']; } $questiontext = file_prepare_draft_area($draftid, $this->context->id, 'question', 'questiontext', empty($question->id) ? null : (int) $question->id, $this->fileoptions, $questiontext); $question->questiontext = array(); $question->questiontext['text'] = $questiontext; $question->questiontext['format'] = empty($question->questiontextformat) ? editors_get_preferred_format() : $question->questiontextformat; $question->questiontext['itemid'] = $draftid; // Prepare general feedback. $draftid = file_get_submitted_draft_itemid('generalfeedback'); if (empty($question->generalfeedback)) { $generalfeedback = $this->_form->getElement('generalfeedback')->getValue(); $question->generalfeedback = $generalfeedback['text']; } $feedback = file_prepare_draft_area($draftid, $this->context->id, 'question', 'generalfeedback', empty($question->id) ? null : (int) $question->id, $this->fileoptions, $question->generalfeedback); $question->generalfeedback = array(); $question->generalfeedback['text'] = $feedback; $question->generalfeedback['format'] = empty($question->generalfeedbackformat) ? editors_get_preferred_format() : $question->generalfeedbackformat; $question->generalfeedback['itemid'] = $draftid; // Remove unnecessary trailing 0s form grade fields. if (isset($question->defaultgrade)) { $question->defaultgrade = 0 + $question->defaultgrade; } if (isset($question->penalty)) { $question->penalty = 0 + $question->penalty; } // Set any options. $extraquestionfields = question_bank::get_qtype($question->qtype)->extra_question_fields(); if (is_array($extraquestionfields) && !empty($question->options)) { array_shift($extraquestionfields); foreach ($extraquestionfields as $field) { if (property_exists($question->options, $field)) { $question->$field = $question->options->$field; } } } // Subclass adds data_preprocessing code here. $question = $this->data_preprocessing($question); parent::set_data($question); } /** * Perform an preprocessing needed on the data passed to {@link set_data()} * before it is used to initialise the form. * @param object $question the data being passed to the form. * @return object $question the modified data. */ protected function data_preprocessing($question) { return $question; } /** * Perform the necessary preprocessing for the fields added by * {@link add_per_answer_fields()}. * @param object $question the data being passed to the form. * @return object $question the modified data. */ protected function data_preprocessing_answers($question, $withanswerfiles = false) { if (empty($question->options->answers)) { return $question; } $key = 0; foreach ($question->options->answers as $answer) { if ($withanswerfiles) { // Prepare the feedback editor to display files in draft area. $draftitemid = file_get_submitted_draft_itemid('answer['.$key.']'); $question->answer[$key]['text'] = file_prepare_draft_area( $draftitemid, // Draftid. $this->context->id, // Context. 'question', // Component. 'answer', // Filarea. !empty($answer->id) ? (int) $answer->id : null, // Itemid. $this->fileoptions, // Options. $answer->answer // Text. ); $question->answer[$key]['itemid'] = $draftitemid; $question->answer[$key]['format'] = $answer->answerformat; } else { $question->answer[$key] = $answer->answer; } $question->fraction[$key] = 0 + $answer->fraction; $question->feedback[$key] = array(); // Evil hack alert. Formslib can store defaults in two ways for // repeat elements: // ->_defaultValues['fraction[0]'] and // ->_defaultValues['fraction'][0]. // The $repeatedoptions['fraction']['default'] = 0 bit above means // that ->_defaultValues['fraction[0]'] has already been set, but we // are using object notation here, so we will be setting // ->_defaultValues['fraction'][0]. That does not work, so we have // to unset ->_defaultValues['fraction[0]']. unset($this->_form->_defaultValues["fraction[{$key}]"]); // Prepare the feedback editor to display files in draft area. $draftitemid = file_get_submitted_draft_itemid('feedback['.$key.']'); $question->feedback[$key]['text'] = file_prepare_draft_area( $draftitemid, // Draftid. $this->context->id, // Context. 'question', // Component. 'answerfeedback', // Filarea. !empty($answer->id) ? (int) $answer->id : null, // Itemid. $this->fileoptions, // Options. $answer->feedback // Text. ); $question->feedback[$key]['itemid'] = $draftitemid; $question->feedback[$key]['format'] = $answer->feedbackformat; $key++; } // Now process extra answer fields. $extraanswerfields = question_bank::get_qtype($question->qtype)->extra_answer_fields(); if (is_array($extraanswerfields)) { // Omit table name. array_shift($extraanswerfields); $question = $this->data_preprocessing_extra_answer_fields($question, $extraanswerfields); } return $question; } /** * Perform the necessary preprocessing for the extra answer fields. * * Questions that do something not trivial when editing extra answer fields * will want to override this. * @param object $question the data being passed to the form. * @param array $extraanswerfields extra answer fields (without table name). * @return object $question the modified data. */ protected function data_preprocessing_extra_answer_fields($question, $extraanswerfields) { // Setting $question->$field[$key] won't work in PHP, so we need set an array of answer values to $question->$field. // As we may have several extra fields with data for several answers in each, we use an array of arrays. // Index in $extrafieldsdata is an extra answer field name, value - array of it's data for each answer. $extrafieldsdata = array(); // First, prepare an array if empty arrays for each extra answer fields data. foreach ($extraanswerfields as $field) { $extrafieldsdata[$field] = array(); } // Fill arrays with data from $question->options->answers. $key = 0; foreach ($question->options->answers as $answer) { foreach ($extraanswerfields as $field) { // See hack comment in {@link data_preprocessing_answers()}. unset($this->_form->_defaultValues["{$field}[{$key}]"]); $extrafieldsdata[$field][$key] = $this->data_preprocessing_extra_answer_field($answer, $field); } $key++; } // Set this data in the $question object. foreach ($extraanswerfields as $field) { $question->$field = $extrafieldsdata[$field]; } return $question; } /** * Perfmorm preprocessing for particular extra answer field. * * Questions with non-trivial DB - form element relationship will * want to override this. * @param object $answer an answer object to get extra field from. * @param string $field extra answer field name. * @return field value to be set to the form. */ protected function data_preprocessing_extra_answer_field($answer, $field) { return $answer->$field; } /** * Perform the necessary preprocessing for the fields added by * {@link add_combined_feedback_fields()}. * @param object $question the data being passed to the form. * @return object $question the modified data. */ protected function data_preprocessing_combined_feedback($question, $withshownumcorrect = false) { if (empty($question->options)) { return $question; } $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); foreach ($fields as $feedbackname) { $draftid = file_get_submitted_draft_itemid($feedbackname); $feedback = array(); $feedback['text'] = file_prepare_draft_area( $draftid, // Draftid. $this->context->id, // Context. 'question', // Component. $feedbackname, // Filarea. !empty($question->id) ? (int) $question->id : null, // Itemid. $this->fileoptions, // Options. $question->options->$feedbackname // Text. ); $feedbackformat = $feedbackname . 'format'; $feedback['format'] = $question->options->$feedbackformat; $feedback['itemid'] = $draftid; $question->$feedbackname = $feedback; } if ($withshownumcorrect) { $question->shownumcorrect = $question->options->shownumcorrect; } return $question; } /** * Perform the necessary preprocessing for the hint fields. * * @param object $question The data being passed to the form. * @param bool $withclearwrong Clear wrong hints. * @param bool $withshownumpartscorrect Show number correct. * @return stdClass The modified data. */ protected function data_preprocessing_hints($question, $withclearwrong = false, $withshownumpartscorrect = false) { if (empty($question->hints)) { return $question; } $key = 0; foreach ($question->hints as $hint) { $question->hint[$key] = array(); // Prepare feedback editor to display files in draft area. $draftitemid = file_get_submitted_draft_itemid('hint['.$key.']'); $question->hint[$key]['text'] = file_prepare_draft_area( $draftitemid, // Draftid. $this->context->id, // Context. 'question', // Component. 'hint', // Filarea. !empty($hint->id) ? (int) $hint->id : null, // Itemid. $this->fileoptions, // Options. $hint->hint // Text. ); $question->hint[$key]['itemid'] = $draftitemid; $question->hint[$key]['format'] = $hint->hintformat; $key++; if ($withclearwrong) { $question->hintclearwrong[] = $hint->clearwrong; } if ($withshownumpartscorrect) { $question->hintshownumcorrect[] = $hint->shownumcorrect; } } return $question; } public function validation($fromform, $files) { global $DB; $errors = parent::validation($fromform, $files); // Make sure that the user can edit the question. if (empty($fromform['makecopy']) && isset($this->question->id) && !$this->question->formoptions->canedit) { $errors['currentgrp'] = get_string('nopermissionedit', 'question'); } // Category. if (empty($fromform['category'])) { // User has provided an invalid category. $errors['category'] = get_string('required'); } // Default mark. if (array_key_exists('defaultmark', $fromform) && $fromform['defaultmark'] < 0) { $errors['defaultmark'] = get_string('defaultmarkmustbepositive', 'question'); } // Can only have one idnumber per category. if (strpos($fromform['category'], ',') !== false) { list($category, $categorycontextid) = explode(',', $fromform['category']); } else { $category = $fromform['category']; } if (isset($fromform['idnumber']) && ((string) $fromform['idnumber'] !== '')) { if (empty($fromform['usecurrentcat']) && !empty($fromform['categorymoveto'])) { $categoryinfo = $fromform['categorymoveto']; } else { $categoryinfo = $fromform['category']; } list($categoryid, $notused) = explode(',', $categoryinfo); $conditions = 'questioncategoryid = ? AND idnumber = ?'; $params = [$categoryid, $fromform['idnumber']]; if (!empty($this->question->id)) { // Get the question bank entry id to not check the idnumber for the same bank entry. $sql = "SELECT DISTINCT qbe.id FROM {question_versions} qv JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qv.questionid = ?"; $bankentry = $DB->get_record_sql($sql, ['id' => $this->question->id]); $conditions .= ' AND id <> ?'; $params[] = $bankentry->id; } if ($DB->record_exists_select('question_bank_entries', $conditions, $params)) { $errors['idnumber'] = get_string('idnumbertaken', 'error'); } } if ($this->customfieldpluginenabled) { // Add the custom field validation. $errors = array_merge($errors, $this->customfieldhandler->instance_form_validation($fromform, $files)); } return $errors; } /** * Override this in the subclass to question type name. * @return the question type name, should be the same as the name() method * in the question type class. */ abstract public function qtype(); /** * Returns an array of editor options with collapsed options turned off. * @deprecated since 2.6 * @return array */ protected function get_non_collabsible_editor_options() { debugging('get_non_collabsible_editor_options() is deprecated, use $this->editoroptions instead.', DEBUG_DEVELOPER); return $this->editoroptions; } } PK\&&type/rendererbase.phpnu[. /** * Defines the renderer base classes for question types. * * @package moodlecore * @subpackage questiontypes * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer base classes for question types. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_renderer extends plugin_renderer_base { /** * Generate the display of the formulation part of the question. This is the * area that contains the quetsion text, and the controls for students to * input their answers. Some question types also embed bits of feedback, for * example ticks and crosses, in this area. * * @param question_attempt $qa the question attempt to display. * @param question_display_options $options controls what should and should not be displayed. * @return string HTML fragment. */ public function formulation_and_controls(question_attempt $qa, question_display_options $options) { return $qa->get_question()->format_questiontext($qa); } /** * In the question output there are some class="accesshide" headers to help * screen-readers. This method returns the text to use for the heading above * the formulation_and_controls section. * @return string to use as the heading. */ public function formulation_heading() { return get_string('questiontext', 'question'); } /** * Output hidden form fields to clear any wrong parts of the student's response. * * This method will only be called if the question is in read-only mode. * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ public function clear_wrong(question_attempt $qa) { $response = $qa->get_last_qt_data(); if (!$response) { return ''; } $cleanresponse = $qa->get_question()->clear_wrong_from_response($response); $output = ''; foreach ($cleanresponse as $name => $value) { $attr = array( 'type' => 'hidden', 'name' => $qa->get_qt_field_name($name), 'value' => s($value), ); $output .= html_writer::empty_tag('input', $attr); } return $output; } /** * Generate the display of the outcome part of the question. This is the * area that contains the various forms of feedback. This function generates * the content of this area belonging to the question type. * * Subclasses will normally want to override the more specific methods * {specific_feedback()}, {general_feedback()} and {correct_response()} * that this method calls. * * @param question_attempt $qa the question attempt to display. * @param question_display_options $options controls what should and should not be displayed. * @return string HTML fragment. */ public function feedback(question_attempt $qa, question_display_options $options) { $output = ''; $hint = null; if ($options->feedback) { $output .= html_writer::nonempty_tag('div', $this->specific_feedback($qa), ['class' => 'specificfeedback clearfix']); $hint = $qa->get_applicable_hint(); } if ($options->numpartscorrect) { $output .= html_writer::nonempty_tag('div', $this->num_parts_correct($qa), array('class' => 'numpartscorrect')); } if ($hint) { $output .= $this->hint($qa, $hint); } if ($options->generalfeedback) { $output .= html_writer::nonempty_tag('div', $this->general_feedback($qa), array('class' => 'generalfeedback')); } if ($options->rightanswer) { $output .= html_writer::nonempty_tag('div', $this->correct_response($qa), array('class' => 'rightanswer')); } return $output; } /** * Generate the specific feedback. This is feedback that varies according to * the response the student gave. * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ protected function specific_feedback(question_attempt $qa) { return ''; } /** * Gereate a brief statement of how many sub-parts of this question the * student got right. * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ protected function num_parts_correct(question_attempt $qa) { $a = new stdClass(); list($a->num, $a->outof) = $qa->get_question()->get_num_parts_right( $qa->get_last_qt_data()); if (is_null($a->outof)) { return ''; } else { return get_string('yougotnright', 'question', $a); } } /** * Gereate the specific feedback. This is feedback that varies according to * the response the student gave. * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ protected function hint(question_attempt $qa, question_hint $hint) { return html_writer::nonempty_tag('div', $qa->get_question()->format_hint($hint, $qa), array('class' => 'hint')); } /** * Gereate the general feedback. This is feedback is shown ot all students. * * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ protected function general_feedback(question_attempt $qa) { return $qa->get_question()->format_generalfeedback($qa); } /** * Gereate an automatic description of the correct response to this question. * Not all question types can do this. If it is not possible, this method * should just return an empty string. * * @param question_attempt $qa the question attempt to display. * @return string HTML fragment. */ protected function correct_response(question_attempt $qa) { return ''; } /** * Display any extra question-type specific content that should be visible * when grading, if appropriate. * * @param question_attempt $qa a question attempt. * @param question_display_options $options controls what should and should not be displayed. * @return string HTML fragment. */ public function manual_comment(question_attempt $qa, question_display_options $options) { return ''; } /** * Return any HTML that needs to be included in the page's when this * question is used. * @param $qa the question attempt that will be displayed on the page. * @return string HTML fragment. */ public function head_code(question_attempt $qa) { // This method is used by the Opaque question type. The remote question // engine can send back arbitrary CSS that we have to link to in the // page header. If it was not for that, we might be able to eliminate // this method and load the required CSS and JS some other way. $qa->get_question()->qtype->find_standard_scripts(); } protected function feedback_class($fraction) { return question_state::graded_state_for_fraction($fraction)->get_feedback_class(); } /** * Return an appropriate icon (green tick, red cross, etc.) for a grade. * @param float $fraction grade on a scale 0..1. * @param bool $selected whether to show a big or small icon. (Deprecated) * @return string html fragment. */ protected function feedback_image($fraction, $selected = true) { $feedbackclass = question_state::graded_state_for_fraction($fraction)->get_feedback_class(); return $this->output->pix_icon('i/grade_' . $feedbackclass, get_string($feedbackclass, 'question')); } } /** * Renderer base classes for question types. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_with_combined_feedback_renderer extends qtype_renderer { protected function combined_feedback(question_attempt $qa) { $question = $qa->get_question(); $state = $qa->get_state(); if (!$state->is_finished()) { $response = $qa->get_last_qt_data(); if (!$qa->get_question()->is_gradable_response($response)) { return ''; } list($notused, $state) = $qa->get_question()->grade_response($response); } $feedback = ''; $field = $state->get_feedback_class() . 'feedback'; $format = $state->get_feedback_class() . 'feedbackformat'; if ($question->$field) { $feedback .= $question->format_text($question->$field, $question->$format, $qa, 'question', $field, $question->id); } return $feedback; } } PK\zBl9l92type/calculatedmulti/edit_calculatedmulti_form.phpnu[. /** * Defines the editing form for calculated multiple-choice questions. * * @package qtype * @subpackage calculatedmulti * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Calculated multiple-choice question editing form. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_edit_form extends question_edit_form { /** * Handle to the question type for this question. * * @var question_calculatedmulti_qtype */ public $qtypeobj; public $questiondisplay; public $initialname = ''; public $reload = false; public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { $this->question = $question; $this->qtypeobj = question_bank::get_qtype('calculatedmulti'); $this->reload = optional_param('reload', false, PARAM_BOOL); if (!$this->reload) { // Use database data as this is first pass. if (isset($this->question->id)) { // Remove prefix #{..}# if exists. $this->initialname = $question->name; $question->name = question_bank::get_qtype('calculated') ->clean_technical_prefix_from_question_name($question->name); } } parent::__construct($submiturl, $question, $category, $contexts, $formeditable); } protected function can_preview() { return false; // Generally not possible for calculated multi-choice questions on this page. } public function get_per_answer_fields($mform, $label, $gradeoptions, &$repeatedoptions, &$answersoption) { $repeated = array(); // Help button will be added in definition_inner() after called to add_per_answer_fields. $repeated[] = $mform->createElement('editor', 'answer', $label, null, $this->editoroptions); $repeatedoptions['answer']['type'] = PARAM_RAW; $repeated[] = $mform->createElement('select', 'fraction', get_string('gradenoun'), $gradeoptions); $repeatedoptions['fraction']['default'] = 0; $repeated[] = $mform->createElement('hidden', 'tolerance'); $repeated[] = $mform->createElement('hidden', 'tolerancetype', 1); $repeatedoptions['tolerance']['type'] = PARAM_FLOAT; $repeatedoptions['tolerance']['default'] = 0.01; $repeatedoptions['tolerancetype']['type'] = PARAM_INT; // Create display group. $answerdisplay = array(); $answerdisplay[] = $mform->createElement('select', 'correctanswerlength', get_string('answerdisplay', 'qtype_calculated'), range(0, 9)); $repeatedoptions['correctanswerlength']['default'] = 2; $answerlengthformats = array( '1' => get_string('decimalformat', 'qtype_numerical'), '2' => get_string('significantfiguresformat', 'qtype_calculated') ); $answerdisplay[] = $mform->createElement('select', 'correctanswerformat', get_string('correctanswershowsformat', 'qtype_calculated'), $answerlengthformats); $repeated[] = $mform->createElement('group', 'answerdisplay', get_string('answerdisplay', 'qtype_calculated'), $answerdisplay, null, false); // Add feedback. $repeated[] = $mform->createElement('editor', 'feedback', get_string('feedback', 'question'), null, $this->editoroptions); $answersoption = 'answers'; return $repeated; } protected function definition_inner($mform) { $label = get_string('sharedwildcards', 'qtype_calculated'); $mform->addElement('hidden', 'initialcategory', 1); $mform->addElement('hidden', 'reload', 1); $mform->setType('initialcategory', PARAM_INT); $mform->setType('reload', PARAM_BOOL); $html2 = ''; $mform->insertElementBefore( $mform->createElement('static', 'listcategory', $label, $html2), 'name'); if (isset($this->question->id)) { $mform->insertElementBefore($mform->createElement('static', 'initialname', get_string('questionstoredname', 'qtype_calculated'), format_string($this->initialname)), 'name'); }; $addfieldsname = 'updatecategory'; $addstring = get_string('updatecategory', 'qtype_calculated'); $mform->registerNoSubmitButton($addfieldsname); $mform->insertElementBefore( $mform->createElement('submit', $addfieldsname, $addstring), 'listcategory'); $mform->registerNoSubmitButton('createoptionbutton'); $mform->addElement('hidden', 'multichoice', 1); $mform->setType('multichoice', PARAM_INT); $menu = array(get_string('answersingleno', 'qtype_multichoice'), get_string('answersingleyes', 'qtype_multichoice')); $mform->addElement('select', 'single', get_string('answerhowmany', 'qtype_multichoice'), $menu); $mform->setDefault('single', 1); $mform->addElement('advcheckbox', 'shuffleanswers', get_string('shuffleanswers', 'qtype_multichoice'), null, null, array(0, 1)); $mform->addHelpButton('shuffleanswers', 'shuffleanswers', 'qtype_multichoice'); $mform->setDefault('shuffleanswers', 1); $numberingoptions = question_bank::get_qtype('multichoice')->get_numbering_styles(); $mform->addElement('select', 'answernumbering', get_string('answernumbering', 'qtype_multichoice'), $numberingoptions); $mform->setDefault('answernumbering', 'abc'); $this->add_per_answer_fields($mform, get_string('choiceno', 'qtype_multichoice', '{no}'), question_bank::fraction_options_full(), max(5, QUESTION_NUMANS_START)); $mform->addHelpButton('answer[0]', 'answeroptions', 'qtype_calculatedmulti'); $nounits = optional_param('nounits', 1, PARAM_INT); $mform->addElement('hidden', 'nounits', $nounits); $mform->setType('nounits', PARAM_INT); $mform->setConstants(array('nounits'=>$nounits)); for ($i = 0; $i < $nounits; $i++) { $mform->addElement('hidden', 'unit'."[{$i}]", optional_param("unit[{$i}]", '', PARAM_NOTAGS)); $mform->setType('unit'."[{$i}]", PARAM_NOTAGS); $mform->addElement('hidden', 'multiplier'."[{$i}]", optional_param("multiplier[{$i}]", '', PARAM_FLOAT)); $mform->setType("multiplier[{$i}]", PARAM_FLOAT); } $this->add_combined_feedback_fields(true); $mform->disabledIf('shownumcorrect', 'single', 'eq', 1); $this->add_interactive_settings(true, true); // Hidden elements. $mform->addElement('hidden', 'synchronize', ''); $mform->setType('synchronize', PARAM_INT); if (isset($this->question->options) && isset($this->question->options->synchronize)) { $mform->setDefault('synchronize', $this->question->options->synchronize); } else { $mform->setDefault('synchronize', 0); } $mform->addElement('hidden', 'wizard', 'datasetdefinitions'); $mform->setType('wizard', PARAM_ALPHA); } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_answers($question, true); $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, true, true); if (isset($question->options)) { $question->synchronize = $question->options->synchronize; $question->single = $question->options->single; $question->answernumbering = $question->options->answernumbering; $question->shuffleanswers = $question->options->shuffleanswers; } return $question; } protected function data_preprocessing_answers($question, $withanswerfiles = false) { $question = parent::data_preprocessing_answers($question, $withanswerfiles); if (empty($question->options->answers)) { return $question; } $key = 0; foreach ($question->options->answers as $answer) { // See comment in the parent method about this hack. unset($this->_form->_defaultValues["tolerance[{$key}]"]); unset($this->_form->_defaultValues["tolerancetype[{$key}]"]); unset($this->_form->_defaultValues["correctanswerlength[{$key}]"]); unset($this->_form->_defaultValues["correctanswerformat[{$key}]"]); $question->tolerance[$key] = $answer->tolerance; $question->tolerancetype[$key] = $answer->tolerancetype; $question->correctanswerlength[$key] = $answer->correctanswerlength; $question->correctanswerformat[$key] = $answer->correctanswerformat; $key++; } return $question; } /** * Validate the equations in the some question content. * @param array $errors where errors are being accumulated. * @param string $field the field being validated. * @param string $text the content of that field. * @return array the updated $errors array. */ protected function validate_text($errors, $field, $text) { $problems = qtype_calculated_find_formula_errors_in_text($text); if ($problems) { $errors[$field] = $problems; } return $errors; } public function validation($data, $files) { $errors = parent::validation($data, $files); // Verifying for errors in {=...} in question text. $errors = $this->validate_text($errors, 'questiontext', $data['questiontext']['text']); $errors = $this->validate_text($errors, 'generalfeedback', $data['generalfeedback']['text']); $errors = $this->validate_text($errors, 'correctfeedback', $data['correctfeedback']['text']); $errors = $this->validate_text($errors, 'partiallycorrectfeedback', $data['partiallycorrectfeedback']['text']); $errors = $this->validate_text($errors, 'incorrectfeedback', $data['incorrectfeedback']['text']); $answers = $data['answer']; $answercount = 0; $maxgrade = false; $possibledatasets = $this->qtypeobj->find_dataset_names($data['questiontext']['text']); $mandatorydatasets = array(); foreach ($answers as $key => $answer) { $mandatorydatasets += $this->qtypeobj->find_dataset_names($answer['text']); } if (count($mandatorydatasets) == 0) { foreach ($answers as $key => $answer) { $errors['answer['.$key.']'] = get_string('atleastonewildcard', 'qtype_calculated'); } } $totalfraction = 0; $maxfraction = -1; foreach ($answers as $key => $answer) { $trimmedanswer = trim($answer['text']); $fraction = (float) $data['fraction'][$key]; if (empty($trimmedanswer) && $trimmedanswer != '0' && empty($fraction)) { continue; } if (empty($trimmedanswer)) { $errors['answer['.$key.']'] = get_string('errgradesetanswerblank', 'qtype_multichoice'); } if ($trimmedanswer != '' || $answercount == 0) { // Verifying for errors in {=...} in answer text. $errors = $this->validate_text($errors, 'answer[' . $key . ']', $answer['text']); $errors = $this->validate_text($errors, 'feedback[' . $key . ']', $data['feedback'][$key]['text']); } if ($trimmedanswer != '') { if ('2' == $data['correctanswerformat'][$key] && '0' == $data['correctanswerlength'][$key]) { $errors['correctanswerlength['.$key.']'] = get_string('zerosignificantfiguresnotallowed', 'qtype_calculated'); } if (!is_numeric($data['tolerance'][$key])) { $errors['tolerance['.$key.']'] = get_string('xmustbenumeric', 'qtype_numerical', get_string('acceptederror', 'qtype_numerical')); } if ($data['fraction'][$key] > 0) { $totalfraction += $data['fraction'][$key]; } if ($data['fraction'][$key] > $maxfraction) { $maxfraction = $data['fraction'][$key]; } $answercount++; } } if ($answercount == 0) { $errors['answer[0]'] = get_string('notenoughanswers', 'qtype_multichoice', 2); $errors['answer[1]'] = get_string('notenoughanswers', 'qtype_multichoice', 2); } else if ($answercount == 1) { $errors['answer[1]'] = get_string('notenoughanswers', 'qtype_multichoice', 2); } // Perform sanity checks on fractional grades. if ($data['single']== 1 ) { if ($maxfraction != 1) { $errors['answer[0]'] = get_string('errfractionsnomax', 'qtype_multichoice', $maxfraction * 100); } } else { $totalfraction = round($totalfraction, 2); if ($totalfraction != 1) { $totalfraction = $totalfraction * 100; $errors['answer[0]'] = get_string('errfractionsaddwrong', 'qtype_multichoice', $totalfraction); } } return $errors; } public function qtype() { return 'calculatedmulti'; } } PK\Z. /** * Calculated multiple-choice question renderer class. * * @package qtype * @subpackage calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/multichoice/renderer.php'); /** * Generates the output for calculated multiple-choice questions. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_renderer extends qtype_numerical_renderer { } PK\x"}h: : #type/calculatedmulti/db/upgrade.phpnu[. /** * Calculated multiple-choice question type upgrade code. * * @package qtype_calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the calculatedmulti question type. * @param int $oldversion the version we are upgrading from. * @return bool */ function xmldb_qtype_calculatedmulti_upgrade($oldversion) { global $DB; if ($oldversion < 2024011700) { // In earlier versions, the answer options (choices) for a calculated multiple choice // question could not use HTML and all text was rendered verbatim. However, the texts // were stored in the DB with answerformat == FORMAT_HTML. This value was then overridden // during initialisation of the question. // From this version on, answer options may use HTML, so the answerformat does now have a // meaning. For backwards compatibility, all existing answer options for this question // type must have their answerformat set to FORMAT_PLAIN. $DB->execute("UPDATE {question_answers} SET answerformat = '" . FORMAT_PLAIN . "' WHERE question IN ( SELECT id FROM {question} WHERE qtype = 'calculatedmulti' )" ); // Calculatedmulti savepoint reached. upgrade_plugin_savepoint(true, 2024011700, 'qtype', 'calculatedmulti'); } // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\nC'22&type/calculatedmulti/db/upgradelib.phpnu[. /** * Class for converting attempt data for calculated multiple-choice questions * when upgrading attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @package qtype_calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_qe2_attempt_updater extends question_qtype_attempt_updater { protected $selecteditem = null; /** @var array variable name => value */ protected $values = []; /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */ protected $search = []; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $safevalue = []; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $prettyvalue = []; protected $order; public function question_summary() { return ''; // Done later, after we know which dataset is used. } public function right_answer() { if ($this->question->options->single) { foreach ($this->question->options->answers as $ans) { if ($ans->fraction > 0.999) { return $this->to_text($this->replace_expressions_in_text($ans->answer)); } } } else { $rightbits = []; foreach ($this->question->options->answers as $ans) { if ($ans->fraction >= 0.000001) { $rightbits[] = $this->to_text($this->replace_expressions_in_text($ans->answer)); } } return implode('; ', $rightbits); } } protected function explode_answer($state) { if (strpos($state->answer, '-') < 7) { // Broken state, skip it. throw new coding_exception("Brokes state {$state->id} for calcluatedmulti question {$state->question}. (It did not specify a dataset."); } [$datasetbit, $answer] = explode('-', $state->answer, 2); $selecteditem = substr($datasetbit, 7); if (is_null($this->selecteditem)) { $this->load_dataset($selecteditem); } else if ($this->selecteditem != $selecteditem) { $this->logger->log_assumption("Different states for calcluatedmulti question {$state->question} used different dataset items. Ignoring the change in state {$state->id} and coninuting to use item {$this->selecteditem}."); } if (strpos($answer, ':') !== false) { [$order, $responses] = explode(':', $answer); return $responses; } else { // Sometimes, a bug means that a state is missing the : bit, // We need to deal with that. $this->logger->log_assumption("Dealing with missing order information in attempt at multiple choice question {$this->question->id}"); return $answer; } } public function response_summary($state) { $responses = $this->explode_answer($state); if ($this->question->options->single) { if (is_numeric($responses)) { if (array_key_exists($responses, $this->question->options->answers)) { return $this->to_text($this->replace_expressions_in_text( $this->question->options->answers[$responses]->answer )); } else { $this->logger->log_assumption("Dealing with a place where the student selected a choice that was later deleted for multiple choice question {$this->question->id}"); return '[CHOICE THAT WAS LATER DELETED]'; } } else { return null; } } else { if (!empty($responses)) { $responses = explode(',', $responses); $bits = []; foreach ($responses as $response) { if (array_key_exists($response, $this->question->options->answers)) { $bits[] = $this->to_text($this->replace_expressions_in_text( $this->question->options->answers[$response]->answer )); } else { $this->logger->log_assumption("Dealing with a place where the student selected a choice that was later deleted for multiple choice question {$this->question->id}"); $bits[] = '[CHOICE THAT WAS LATER DELETED]'; } } return implode('; ', $bits); } else { return null; } } } public function was_answered($state) { $responses = $this->explode_answer($state); if ($this->question->options->single) { return is_numeric($responses); } else { return !empty($responses); } } public function set_first_step_data_elements($state, &$data) { $this->explode_answer($state); $this->updater->qa->questionsummary = $this->to_text( $this->replace_expressions_in_text($this->question->questiontext) ); $this->updater->qa->rightanswer = $this->right_answer($this->question); foreach ($this->values as $name => $value) { $data['_var_' . $name] = $value; } [$datasetbit, $answer] = explode('-', $state->answer, 2); [$order, $responses] = explode(':', $answer); $data['_order'] = $order; $this->order = explode(',', $order); } public function supply_missing_first_step_data(&$data) { $data['_order'] = implode(',', array_keys($this->question->options->answers)); } public function set_data_elements_for_step($state, &$data) { $responses = $this->explode_answer($state); if ($this->question->options->single) { if (is_numeric($responses)) { $flippedorder = array_combine(array_values($this->order), array_keys($this->order)); if (array_key_exists($responses, $flippedorder)) { $data['answer'] = $flippedorder[$responses]; } else { $data['answer'] = '-1'; } } } else { $responses = explode(',', $responses); foreach ($this->order as $key => $ansid) { if (in_array($ansid, $responses)) { $data['choice' . $key] = 1; } else { $data['choice' . $key] = 0; } } } } public function load_dataset($selecteditem) { $this->selecteditem = $selecteditem; $this->updater->qa->variant = $selecteditem; $this->values = $this->qeupdater->load_dataset( $this->question->id, $selecteditem ); // Prepare an array for {@link substitute_values()}. $this->search = []; $this->safevalue = []; $this->prettyvalue = []; foreach ($this->values as $name => $value) { if (!is_numeric($value)) { $a = new stdClass(); $a->name = '{' . $name . '}'; $a->value = $value; throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); } $this->search[] = '{' . $name . '}'; $this->safevalue[] = '(' . $value . ')'; $this->prettyvalue[] = $this->format_float($value); } } /** * This function should be identical to * {@link qtype_calculated_variable_substituter::format_float()}. Except that * we do not try to do locale-aware replacement of the decimal point. * * Having to copy it here is a pain, but it is the standard rule about not * using library code (which may change in future) in upgrade code, which * exists at a point in time. * * Display a float properly formatted with a certain number of decimal places. * @param number $x the number to format * @param int $length restrict to this many decimal places or significant * figures. If null, the number is not rounded. * @param int format 1 => decimalformat, 2 => significantfigures. * @return string formtted number. */ public function format_float($x, $length = null, $format = null) { if (!is_null($length) && !is_null($format)) { if ($format == 1) { // Decimal places. $x = sprintf('%.' . $length . 'F', $x); } else if ($format == 2) { // Significant figures. $x = sprintf('%.' . $length . 'g', $x); } } return $x; } /** * Evaluate an expression using the variable values. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ public function calculate($expression) { return $this->calculate_raw($this->substitute_values_for_eval($expression)); } /** * Evaluate an expression after the variable values have been substituted. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ protected function calculate_raw($expression) { try { // In older PHP versions this this is a way to validate code passed to eval. // The trick came from http://php.net/manual/en/function.eval.php. if (@eval('return true; $result = ' . $expression . ';')) { return eval('return ' . $expression . ';'); } } catch (Throwable $e) { // PHP7 and later now throws ParseException and friends from eval(), // which is much better. } // In either case of an invalid $expression, we end here. return '[Invalid expression ' . $expression . ']'; } /** * Substitute variable placehodlers like {a} with their value wrapped in (). * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_for_eval($expression) { return str_replace($this->search, $this->safevalue, $expression ?? ''); } /** * Substitute variable placehodlers like {a} with their value without wrapping * the value in anything. * @param string $text some content with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_pretty($text) { return str_replace($this->search, $this->prettyvalue, $text ?? ''); } /** * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) * in some text with the corresponding values. * @param string $text the text to process. * @return string the text with values substituted. */ public function replace_expressions_in_text($text, $length = null, $format = null) { if ($text === null || $text === '') { return $text; } $vs = $this; // Can't see to use $this in a PHP closure. $text = preg_replace_callback( qtype_calculated::FORMULAS_IN_TEXT_REGEX, function ($matches) use ($vs, $format, $length) { return $vs->format_float($vs->calculate($matches[1]), $length, $format); }, $text ); return $this->substitute_values_pretty($text); } } PK\zz6type/calculatedmulti/lang/en/qtype_calculatedmulti.phpnu[. /** * Strings for component 'qtype_calculatedmulti', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage calculatedmulti * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['answeroptions'] = 'Choice options'; $string['answeroptions_help'] = 'The suggested choice formula is ...{={x}+..}...'; $string['pluginname'] = 'Calculated multichoice'; $string['pluginname_help'] = 'Calculated multichoice questions are like multichoice questions which in their choice elements can be included numerical formula results using wildcards in curly brackets that are substituted with individual values when the quiz is taken. For example, if the question "What is the area of a rectangle of length {l} and width {w}?" one of the choice is {={l}*{w}} (where * denotes multiplication). '; $string['pluginname_link'] = 'question/type/calculatedmulti'; $string['pluginnameadding'] = 'Adding a Calculated multichoice question'; $string['pluginnameediting'] = 'Editing a Calculated multichoice question'; $string['pluginnamesummary'] = 'Calculated multichoice questions are like multichoice questions which choice elements can include formula results from numeric values that are selected randomly from a set when the quiz is taken.'; $string['privacy:metadata'] = 'The Calculated multichoice question type plugin does not store any personal data.'; PK\u.BeeQtype/calculatedmulti/backup/moodle2/backup_qtype_calculatedmulti_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/backup/moodle2/backup_qtype_calculated_plugin.class.php'); /** * Provides the information to backup calculatedmulti questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_calculatedmulti_plugin extends backup_qtype_calculated_plugin { } PK\i Rtype/calculatedmulti/backup/moodle2/restore_qtype_calculatedmulti_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/calculated/backup/moodle2/restore_qtype_calculated_plugin.class.php'); /** * restore plugin class that provides the necessary information * needed to restore one calculatedmulti qtype plugin. * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_calculatedmulti_plugin extends restore_qtype_calculated_plugin { public function recode_response($questionid, $sequencenumber, array $response) { return $this->step->questions_recode_response_data('multichoice', $questionid, $sequencenumber, $response); } /** * Given one question_states record, return the answer * recoded pointing to all the restored stuff for calculatedmulti questions * * answer format is datasetxx-yy:zz, where xx is the itemnumber in the dataset * (doesn't need conversion), and both yy and zz are two (hypen speparated) * lists of comma separated question_answers, the first to specify the order * of the answers and the second to specify the responses. * * in fact, this qtype behaves exactly like the multichoice one, so we'll delegate * recoding of those yy:zz to it */ public function recode_legacy_state_answer($state) { $answer = $state->answer; $result = ''; // Datasetxx-yy:zz format. if (preg_match('~^dataset([0-9]+)-(.*)$~', $answer, $matches)) { $itemid = $matches[1]; $subanswer = $matches[2]; // Delegate subanswer recode to multichoice qtype, faking one question_states record. $substate = new stdClass(); $substate->answer = $subanswer; $newanswer = $this->step->restore_recode_legacy_answer($substate, 'multichoice'); $result = 'dataset' . $itemid . '-' . $newanswer; } return $result ? $result : $answer; } #[\Override] protected function define_excluded_identity_hash_fields(): array { return []; } } PK\R?!type/calculatedmulti/pix/icon.gifnu[GIF89a  ---...000222999@@@GGGIIINNNQQQRRRVVV[[[]]]kkkzzz{{{}}}!,j H@&Ç.xaC!RxqcDC dpO HP3r*hя]ZPh 4lA &(! ;PK\r9!type/calculatedmulti/pix/icon.svgnu[PK\type/calculatedmulti/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_calculatedmulti * @copyright Dongsheng Cai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for calculated multiple-choice questions. * * @package qtype_calculatedmulti * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_calculatedmulti_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $DB, $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_calculatedmulti', $filearea, $args, $forcedownload, $options); } PK\ type/calculatedmulti/version.phpnu[. /** * Version information for the calculated multiple-choice question type. * * @package qtype * @subpackage calculatedmulti * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_calculatedmulti'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->dependencies = [ 'qtype_numerical' => 2025040800, 'qtype_calculated' => 2025040800, 'qtype_multichoice' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\o!type/calculatedmulti/question.phpnu[. /** * Calculated multiple-choice question definition class. * * @package qtype * @subpackage calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/multichoice/question.php'); require_once($CFG->dirroot . '/question/type/calculated/question.php'); /** * Represents a calculated multiple-choice multiple-response question. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_single_question extends qtype_multichoice_single_question implements qtype_calculated_question_with_expressions { /** @var qtype_calculated_dataset_loader helper for loading the dataset. */ public $datasetloader; /** @var qtype_calculated_variable_substituter stores the dataset we are using. */ public $vs; /** * @var bool wheter the dataset item to use should be chose based on attempt * start time, rather than randomly. */ public $synchronised; public function start_attempt(question_attempt_step $step, $variant) { qtype_calculated_question_helper::start_attempt($this, $step, $variant); parent::start_attempt($step, $variant); } public function apply_attempt_state(question_attempt_step $step) { qtype_calculated_question_helper::apply_attempt_state($this, $step); parent::apply_attempt_state($step); } public function calculate_all_expressions() { qtype_calculatedmulti_calculate_helper::calculate_all_expressions($this); } public function get_num_variants() { return $this->datasetloader->get_number_of_items(); } public function get_variants_selection_seed() { if (!empty($this->synchronised) && $this->datasetloader->datasets_are_synchronised($this->category)) { return 'category' . $this->category; } else { return parent::get_variants_selection_seed(); } } } /** * Represents a calculated multiple-choice multiple-response question. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_multi_question extends qtype_multichoice_multi_question implements qtype_calculated_question_with_expressions { /** @var qtype_calculated_dataset_loader helper for loading the dataset. */ public $datasetloader; /** @var qtype_calculated_variable_substituter stores the dataset we are using. */ public $vs; /** * @var bool wheter the dataset item to use should be chose based on attempt * start time, rather than randomly. */ public $synchronised; public function start_attempt(question_attempt_step $step, $variant) { qtype_calculated_question_helper::start_attempt($this, $step, $variant); parent::start_attempt($step, $variant); } public function apply_attempt_state(question_attempt_step $step) { qtype_calculated_question_helper::apply_attempt_state($this, $step); parent::apply_attempt_state($step); } public function calculate_all_expressions() { qtype_calculatedmulti_calculate_helper::calculate_all_expressions($this); } public function get_num_variants() { return $this->datasetloader->get_number_of_items(); } public function get_variants_selection_seed() { if (!empty($this->synchronised) && $this->datasetloader->datasets_are_synchronised($this->category)) { return 'category' . $this->category; } else { return parent::get_variants_selection_seed(); } } } /** * Helper to abstract common code between qtype_calculatedmulti_single_question * and qtype_calculatedmulti_multi_question. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_calculatedmulti_calculate_helper { /** * Calculate all the exressions in a qtype_calculatedmulti_single_question * or qtype_calculatedmulti_multi_question. * @param unknown_type $question */ public static function calculate_all_expressions( qtype_calculated_question_with_expressions $question) { $question->questiontext = $question->vs->replace_expressions_in_text( $question->questiontext); $question->generalfeedback = $question->vs->replace_expressions_in_text( $question->generalfeedback); foreach ($question->answers as $ans) { if ($ans->answer && $ans->answer !== '*') { $ans->answer = $question->vs->replace_expressions_in_text($ans->answer, $ans->correctanswerlength, $ans->correctanswerformat); } $ans->feedback = $question->vs->replace_expressions_in_text($ans->feedback, $ans->correctanswerlength, $ans->correctanswerformat); } } }PK\ b1type/calculatedmulti/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_calculatedmulti. * * @package qtype_calculatedmulti * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_calculatedmulti\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_calculatedmulti implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\ ))=type/calculatedmulti/classes/qtype_calculatedmulti_answer.phpnu[. declare(strict_types=1); namespace qtype_calculatedmulti; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * Class to represent a calculated question answer. * * @package qtype_calculatedmulti * @copyright 2023 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_answer extends \question_answer { /** @var int The length of the correct answer. */ public $correctanswerlength; /** @var int The format of the correct answer. */ public $correctanswerformat; } PK\c:hh type/calculatedmulti/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This file describes API changes in /question/type/calculatedmulti/*. === 4.4 === * Calculated multichoice question options now have the possibility to have full HTML (and MathJax) support in choices the same as normal multichoice question options.PK\͒,type/calculatedmulti/tests/question_test.phpnu[. namespace qtype_calculatedmulti; use question_attempt_step; use question_classified_response; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for qtype_calculatedmulti_definition. * * @package qtype_calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \advanced_testcase { public function test_is_complete_response(): void { $question = \test_question_maker::make_question('calculated'); $this->assertFalse($question->is_complete_response(array())); $this->assertTrue($question->is_complete_response(array('answer' => '0'))); $this->assertTrue($question->is_complete_response(array('answer' => 0))); $this->assertFalse($question->is_complete_response(array('answer' => 'test'))); } public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('calculated'); $this->assertFalse($question->is_gradable_response(array())); $this->assertTrue($question->is_gradable_response(array('answer' => '0'))); $this->assertTrue($question->is_gradable_response(array('answer' => 0))); $this->assertTrue($question->is_gradable_response(array('answer' => 'test'))); } public function test_grading(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array(0, question_state::$gradedwrong), $question->grade_response(array('answer' => $values['a'] - $values['b']))); $this->assertEquals(array(1, question_state::$gradedright), $question->grade_response(array('answer' => $values['a'] + $values['b']))); } public function test_get_correct_response(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array('answer' => $values['a'] + $values['b']), $question->get_correct_response()); } public function test_get_question_summary(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $qsummary = $question->get_question_summary(); $this->assertEquals('What is ' . $values['a'] . ' + ' . $values['b'] . '?', $qsummary); } public function test_summarise_response(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals('3.1', $question->summarise_response(array('answer' => '3.1'))); } public function test_classify_response(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals(array( new question_classified_response(13, $values['a'] + $values['b'], 1.0)), $question->classify_response(array('answer' => $values['a'] + $values['b']))); $this->assertEquals(array( new question_classified_response(14, $values['a'] - $values['b'], 0.0)), $question->classify_response(array('answer' => $values['a'] - $values['b']))); $this->assertEquals(array( new question_classified_response(17, 7 * $values['a'], 0.0)), $question->classify_response(array('answer' => 7 * $values['a']))); $this->assertEquals(array( question_classified_response::no_response()), $question->classify_response(array('answer' => ''))); } } PK\Tԟ/type/calculatedmulti/tests/walkthrough_test.phpnu[. namespace qtype_calculatedmulti; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the calculated multiple-choice question type. * * @package qtype_calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_interactive_single_response(): void { // Create a gapselect question. $q = \test_question_maker::make_question('calculatedmulti', 'singleresponse'); $q->shuffleanswers = false; $q->hints = array( new question_hint_with_parts(1, 'This is the first hint.', FORMAT_HTML, true, false), new question_hint_with_parts(2, 'This is the second hint.', FORMAT_HTML, true, false), ); $this->start_attempt_at_question($q, 'interactive', 3, 2); $values = $q->vs->get_values(); $this->assertEquals($values, $q->datasetloader->load_values(2)); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Submit a wrong answer. $this->process_submission(array('-submit' => 1, 'answer' => '1')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_contains_hint_expectation('This is the first hint.')); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'answer' => '0')); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_mark_summary(2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_no_hint_visible_expectation()); } public function test_interactive_multi_response(): void { // Create a gapselect question. $q = \test_question_maker::make_question('calculatedmulti', 'multiresponse'); $q->shuffleanswers = false; $q->hints = array( new question_hint_with_parts(1, 'This is the first hint.', FORMAT_HTML, true, false), new question_hint_with_parts(2, 'This is the second hint.', FORMAT_HTML, true, false), ); $this->start_attempt_at_question($q, 'interactive', 3, 2); $values = $q->vs->get_values(); $this->assertEquals($values, $q->datasetloader->load_values(2)); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Submit all boxes ticked. $this->process_submission(array('-submit' => 1, 'choice0' => '1', 'choice1' => '1', 'choice2' => '1')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_contains_hint_expectation('This is the first hint.')); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'choice0' => '1', 'choice1' => '1', 'choice2' => '0')); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_mark_summary(2), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_no_hint_visible_expectation()); } } PK\ >##%type/calculatedmulti/tests/helper.phpnu[. /** * Test helpers for the calculated question type. * * @package qtype * @subpackage calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculatedmulti/question.php'); require_once($CFG->dirroot . '/question/type/calculated/tests/helper.php'); /** * Test helper class for the calculated multiple-choice question type. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti_test_helper extends question_test_helper { public function get_test_questions() { return array('singleresponse', 'multiresponse'); } /** * Makes a calculated multiple-choice question about summing two numbers. * @return qtype_calculatedmulti_single_question */ public function make_calculatedmulti_question_singleresponse() { question_bank::load_question_definition_classes('calculated'); $q = new qtype_calculatedmulti_single_question(); test_question_maker::initialise_a_question($q); $q->name = 'Simple sum'; $q->questiontext = 'What is {a} + {b}?'; $q->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $q->shuffleanswers = 0; $q->answernumbering = 'abc'; $q->layout = 1; $q->correctfeedback = test_question_maker::STANDARD_OVERALL_CORRECT_FEEDBACK; $q->correctfeedbackformat = FORMAT_HTML; $q->partiallycorrectfeedback = test_question_maker::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK; $q->partiallycorrectfeedbackformat = FORMAT_HTML; $q->shownumcorrect = 1; $q->incorrectfeedback = test_question_maker::STANDARD_OVERALL_INCORRECT_FEEDBACK; $q->incorrectfeedbackformat = FORMAT_HTML; $q->shownumcorrect = 1; $q->answers = array( 13 => new \qtype_calculated\qtype_calculated_answer(13, '{={a} + {b}}', 1.0, 'Very good.', FORMAT_HTML, 0), 14 => new \qtype_calculated\qtype_calculated_answer(14, '{={a} - {b}}', 0.0, 'Add. not subtract!', FORMAT_HTML, 0), 17 => new \qtype_calculated\qtype_calculated_answer(17, '{={a} + 2 * {b}}', 0.0, 'Just add.', FORMAT_HTML, 0), ); $q->answers[13]->correctanswerlength = 2; $q->answers[13]->correctanswerformat = 1; $q->answers[14]->correctanswerlength = 2; $q->answers[14]->correctanswerformat = 1; $q->answers[17]->correctanswerlength = 2; $q->answers[17]->correctanswerformat = 1; $q->qtype = question_bank::get_qtype('calculatedmulti'); $q->datasetloader = new qtype_calculated_test_dataset_loader(0, array( array('a' => 1, 'b' => 5), array('a' => 3, 'b' => 4), )); return $q; } /** * Makes a calculated multiple-choice question with multiple right answers. * @return qtype_calculatedmulti_multi_question */ public function make_calculatedmulti_question_multiresponse() { question_bank::load_question_definition_classes('calculated'); $q = new qtype_calculatedmulti_multi_question(); test_question_maker::initialise_a_question($q); $q->name = 'Simple sum'; $q->questiontext = 'What is {a} + {b}?'; $q->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $q->shuffleanswers = 0; $q->answernumbering = 'abc'; $q->layout = 1; $q->correctfeedback = test_question_maker::STANDARD_OVERALL_CORRECT_FEEDBACK; $q->correctfeedbackformat = FORMAT_HTML; $q->partiallycorrectfeedback = test_question_maker::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK; $q->partiallycorrectfeedbackformat = FORMAT_HTML; $q->shownumcorrect = 1; $q->incorrectfeedback = test_question_maker::STANDARD_OVERALL_INCORRECT_FEEDBACK; $q->incorrectfeedbackformat = FORMAT_HTML; $q->shownumcorrect = 1; $q->answers = array( 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} + {b}!', 0.5, 'Good', FORMAT_HTML, 0), 14 => new \qtype_calculated\qtype_calculated_answer(14, '{={a} + {b}}', 0.5, 'Good', FORMAT_HTML, 0), 17 => new \qtype_calculated\qtype_calculated_answer(17, '{={a} - {b}}', -0.5, 'Wrong.', FORMAT_HTML, 0), ); $q->answers[13]->correctanswerlength = 2; $q->answers[13]->correctanswerformat = 1; $q->answers[14]->correctanswerlength = 2; $q->answers[14]->correctanswerformat = 1; $q->answers[17]->correctanswerlength = 2; $q->answers[17]->correctanswerformat = 1; $q->qtype = question_bank::get_qtype('calculatedmulti'); $q->datasetloader = new qtype_calculated_test_dataset_loader(0, array( array('a' => 1, 'b' => 5), array('a' => 3, 'b' => 4), )); return $q; } /** * Return the form data for a question with a single response. * * @return stdClass */ public function get_calculatedmulti_question_form_data_singleresponse(): stdClass { question_bank::load_question_definition_classes('calculated'); $fromform = new stdClass(); $fromform->name = 'Simple sum'; $fromform->questiontext['text'] = 'What is {a} + {b}?'; $fromform->questiontext['format'] = FORMAT_HTML; $fromform->defaultmark = 1.0; $fromform->generalfeedback['text'] = 'Generalfeedback: {={a} + {b}} is the right answer.'; $fromform->generalfeedback['format'] = FORMAT_HTML; $fromform->unitrole = '3'; $fromform->unitpenalty = 0.1; $fromform->unitgradingtypes = '1'; $fromform->unitsleft = '0'; $fromform->nounits = 1; $fromform->multiplier = []; $fromform->multiplier[0] = '1.0'; $fromform->synchronize = 0; $fromform->answernumbering = 0; $fromform->shuffleanswers = 0; $fromform->single = 1; $fromform->correctfeedback['text'] = 'Very good'; $fromform->correctfeedback['format'] = FORMAT_HTML; $fromform->partiallycorrectfeedback['text'] = 'Mostly good'; $fromform->partiallycorrectfeedback['format'] = FORMAT_HTML; $fromform->incorrectfeedback['text'] = 'Completely Wrong'; $fromform->incorrectfeedback['format'] = FORMAT_HTML; $fromform->shownumcorrect = 1; $fromform->noanswers = 6; $fromform->answer = []; $fromform->answer[0]['text'] = '{a} + {b}'; $fromform->answer[0]['format'] = FORMAT_HTML; $fromform->answer[1]['text'] = '{a} - {b}'; $fromform->answer[1]['format'] = FORMAT_HTML; $fromform->answer[2]['text'] = '*'; $fromform->answer[2]['format'] = FORMAT_HTML; $fromform->fraction = []; $fromform->fraction[0] = '1.0'; $fromform->fraction[1] = '0.0'; $fromform->fraction[2] = '0.0'; $fromform->tolerance = []; $fromform->tolerance[0] = 0.001; $fromform->tolerance[1] = 0.001; $fromform->tolerance[2] = 0; $fromform->tolerancetype[0] = 1; $fromform->tolerancetype[1] = 1; $fromform->tolerancetype[2] = 1; $fromform->correctanswerlength[0] = 2; $fromform->correctanswerlength[1] = 2; $fromform->correctanswerlength[2] = 2; $fromform->correctanswerformat[0] = 1; $fromform->correctanswerformat[1] = 1; $fromform->correctanswerformat[2] = 1; $fromform->feedback = []; $fromform->feedback[0] = []; $fromform->feedback[0]['format'] = FORMAT_HTML; $fromform->feedback[0]['text'] = 'Very good.'; $fromform->feedback[1] = []; $fromform->feedback[1]['format'] = FORMAT_HTML; $fromform->feedback[1]['text'] = 'Add. not subtract!'; $fromform->feedback[2] = []; $fromform->feedback[2]['format'] = FORMAT_HTML; $fromform->feedback[2]['text'] = 'Completely wrong.'; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $fromform->hint = [ [ 'text' => 'Add', 'format' => FORMAT_HTML, ], ]; return $fromform; } } PK\-CTxTx<type/calculatedmulti/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_calculatedmulti; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of calculated multiple-choice question attempts. * * @package qtype_calculatedmulti * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_calculatedmulti_adaptive_qsession96(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '13', 'uniqueid' => '13', 'quiz' => '4', 'userid' => '4', 'attempt' => '1', 'sumgrades' => '0.00000', 'timestart' => '1305830650', 'timefinish' => '1305830656', 'timemodified' => '1305830656', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '17', 'category' => '2', 'parent' => '0', 'name' => 'Calculated multiple-choice', 'questiontext' => '

    What is {a} + {b}?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedmulti', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184120+gpbLCv', 'version' => 'tjh238.vledev2.open.ac.uk+110519184120+P5jpWJ', 'hidden' => '0', 'timecreated' => '1305830480', 'timemodified' => '1305830480', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '1', 'question' => '17', 'synchronize' => '0', 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '1', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '1', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '1', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 24 => (object) array( 'id' => '24', 'question' => '17', 'answer' => '{={a} - {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 25 => (object) array( 'id' => '25', 'question' => '17', 'answer' => '{={a} + {b}}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 26 => (object) array( 'id' => '26', 'question' => '17', 'answer' => '{={a} / {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 27 => (object) array( 'id' => '27', 'question' => '17', 'answer' => '{={a} * {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '96', 'attemptid' => '13', 'questionid' => '17', 'newest' => '257', 'newgraded' => '257', 'sumpenalty' => '0.1000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 254 => (object) array( 'id' => '254', 'attempt' => '13', 'question' => '17', 'seq_number' => '0', 'answer' => 'dataset3-24,26,27,25:', 'timestamp' => '1305830650', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 257 => (object) array( 'id' => '257', 'attempt' => '13', 'question' => '17', 'seq_number' => '1', 'answer' => 'dataset3-24,26,27,25:', 'timestamp' => '1305830650', 'event' => '6', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 3, array('a' => '4.3', 'b' => '5.4')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 17, 'variant' => 3, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 4.3 + 5.4?', 'rightanswer' => '9.7', 'responsesummary' => '', 'timemodified' => 1305830650, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('_order' => '24,26,27,25', '_var_a' => '4.3', '_var_b' => '5.4'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'gradedwrong', 'fraction' => null, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('-finish' => 1, '-_try' => 1, '-_rawfraction' => 0), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculatedmulti_adaptive_qsession99(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '14', 'uniqueid' => '14', 'quiz' => '4', 'userid' => '4', 'attempt' => '2', 'sumgrades' => '2.80000', 'timestart' => '1305830661', 'timefinish' => '1305830729', 'timemodified' => '1305830729', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '17', 'category' => '2', 'parent' => '0', 'name' => 'Calculated multiple-choice', 'questiontext' => '

    What is {a} + {b}?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedmulti', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184120+gpbLCv', 'version' => 'tjh238.vledev2.open.ac.uk+110519184120+P5jpWJ', 'hidden' => '0', 'timecreated' => '1305830480', 'timemodified' => '1305830480', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '1', 'question' => '17', 'synchronize' => '0', 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '1', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '1', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '1', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 24 => (object) array( 'id' => '24', 'question' => '17', 'answer' => '{={a} - {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 25 => (object) array( 'id' => '25', 'question' => '17', 'answer' => '{={a} + {b}}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 26 => (object) array( 'id' => '26', 'question' => '17', 'answer' => '{={a} / {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 27 => (object) array( 'id' => '27', 'question' => '17', 'answer' => '{={a} * {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '99', 'attemptid' => '14', 'questionid' => '17', 'newest' => '268', 'newgraded' => '268', 'sumpenalty' => '0.2000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 260 => (object) array( 'id' => '260', 'attempt' => '14', 'question' => '17', 'seq_number' => '0', 'answer' => 'dataset8-25,24,27,26:', 'timestamp' => '1305830661', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 264 => (object) array( 'id' => '264', 'attempt' => '14', 'question' => '17', 'seq_number' => '1', 'answer' => 'dataset8-25,24,27,26:25', 'timestamp' => '1305830699', 'event' => '3', 'grade' => '1.0000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), 268 => (object) array( 'id' => '268', 'attempt' => '14', 'question' => '17', 'seq_number' => '2', 'answer' => 'dataset8-25,24,27,26:25', 'timestamp' => '1305830699', 'event' => '6', 'grade' => '1.0000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 8, array('a' => '3.7', 'b' => '6.0')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 17, 'variant' => 8, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 3.7 + 6.0?', 'rightanswer' => '9.7', 'responsesummary' => '9.7', 'timemodified' => 1305830699, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830661, 'userid' => 4, 'data' => array('_order' => '25,24,27,26', '_var_a' => '3.7', '_var_b' => '6.0'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => 1, 'timecreated' => 1305830699, 'userid' => 4, 'data' => array('answer' => '0', '-submit' => 1, '-_try' => 1, '-_rawfraction' => 1), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'gradedright', 'fraction' => 1, 'timecreated' => 1305830699, 'userid' => 4, 'data' => array('answer' => '0', '-finish' => 1, '-_try' => 1, '-_rawfraction' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculatedmulti_adaptive_qsession102(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '15', 'uniqueid' => '15', 'quiz' => '4', 'userid' => '3', 'attempt' => '1', 'sumgrades' => '0.70000', 'timestart' => '1305830744', 'timefinish' => '0', 'timemodified' => '1305830792', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '17', 'category' => '2', 'parent' => '0', 'name' => 'Calculated multiple-choice', 'questiontext' => '

    What is {a} + {b}?

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculatedmulti', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184120+gpbLCv', 'version' => 'tjh238.vledev2.open.ac.uk+110519184120+P5jpWJ', 'hidden' => '0', 'timecreated' => '1305830480', 'timemodified' => '1305830480', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '1', 'question' => '17', 'synchronize' => '0', 'single' => '1', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '1', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '1', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '1', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 24 => (object) array( 'id' => '24', 'question' => '17', 'answer' => '{={a} - {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 25 => (object) array( 'id' => '25', 'question' => '17', 'answer' => '{={a} + {b}}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 26 => (object) array( 'id' => '26', 'question' => '17', 'answer' => '{={a} / {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), 27 => (object) array( 'id' => '27', 'question' => '17', 'answer' => '{={a} * {b}}', 'answerformat' => '0', 'fraction' => '0.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '102', 'attemptid' => '15', 'questionid' => '17', 'newest' => '278', 'newgraded' => '278', 'sumpenalty' => '0.5000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 271 => (object) array( 'id' => '271', 'attempt' => '15', 'question' => '17', 'seq_number' => '0', 'answer' => 'dataset7-26,24,25,27:', 'timestamp' => '1305830744', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 274 => (object) array( 'id' => '274', 'attempt' => '15', 'question' => '17', 'seq_number' => '1', 'answer' => 'dataset7-26,24,25,27:27', 'timestamp' => '1305830759', 'event' => '3', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), 275 => (object) array( 'id' => '275', 'attempt' => '15', 'question' => '17', 'seq_number' => '2', 'answer' => 'dataset7-26,24,25,27:24', 'timestamp' => '1305830761', 'event' => '3', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), 276 => (object) array( 'id' => '276', 'attempt' => '15', 'question' => '17', 'seq_number' => '3', 'answer' => 'dataset7-26,24,25,27:26', 'timestamp' => '1305830764', 'event' => '3', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), 277 => (object) array( 'id' => '277', 'attempt' => '15', 'question' => '17', 'seq_number' => '4', 'answer' => 'dataset7-26,24,25,27:25', 'timestamp' => '1305830766', 'event' => '3', 'grade' => '0.7000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), 278 => (object) array( 'id' => '278', 'attempt' => '15', 'question' => '17', 'seq_number' => '5', 'answer' => 'dataset7-26,24,25,27:24', 'timestamp' => '1305830768', 'event' => '3', 'grade' => '0.7000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 7, array('a' => '4.4', 'b' => '8.2')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 17, 'variant' => 7, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 4.4 + 8.2?', 'rightanswer' => '12.6', 'responsesummary' => '-3.8', 'timemodified' => 1305830768, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830744, 'userid' => 3, 'data' => array('_order' => '26,24,25,27', '_var_a' => '4.4', '_var_b' => '8.2'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'todo', 'fraction' => 0, 'timecreated' => 1305830759, 'userid' => 3, 'data' => array('answer' => '3', '-submit' => 1, '-_try' => 1, '-_rawfraction' => 0), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'todo', 'fraction' => 0, 'timecreated' => 1305830761, 'userid' => 3, 'data' => array('answer' => '1', '-submit' => 1, '-_try' => 2, '-_rawfraction' => 0), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'todo', 'fraction' => 0, 'timecreated' => 1305830764, 'userid' => 3, 'data' => array('answer' => '0', '-submit' => 1, '-_try' => 3, '-_rawfraction' => 0), ), 4 => (object) array( 'sequencenumber' => 4, 'state' => 'todo', 'fraction' => 0.7, 'timecreated' => 1305830766, 'userid' => 3, 'data' => array('answer' => '2', '-submit' => 1, '-_try' => 4, '-_rawfraction' => 1), ), 5 => (object) array( 'sequencenumber' => 5, 'state' => 'todo', 'fraction' => 0.7, 'timecreated' => 1305830768, 'userid' => 3, 'data' => array('answer' => '1', '-submit' => 1, '-_try' => 5, '-_rawfraction' => 0), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\̉]RR,type/calculatedmulti/tests/behat/add.featurenu[@qtype @qtype_calculatedmulti Feature: Test creating a Calculated multichoice question As a teacher In order to test my students I need to be able to create Calculated multichoice questions Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | Scenario: Create a Calculated question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Calculated multichoice" to "1" And I press "Add" And I set the following fields to these values: | Question name | calculatedmulti-001 | | Question text | What is the sum of those two numbers: {a} and {b} | | Choice 1 | {a}+{b} | | Grade | 100% | | Choice 2 | {a}*{b} | | Choice 3 | {a}-{b} | And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" And I should see "Edit the wildcards datasets" And I press "id_addbutton" And I set the following fields to these values: | id_number_2 | 3.0 | | id_number_1 | 4.0 | And I press "id_savechanges" # Checking that the wildcard values are there And I am on the "calculatedmulti-001" "core_question > edit" page logged in as teacher And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" Then I should see "3+4" PK\q\-type/calculatedmulti/tests/behat/edit.featurenu[@qtype @qtype_calculatedmulti Feature: Test editing a Calculated multichoice question As a teacher In order to be able to update my Calculated multichoice questions I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "user preferences" exist: | user | preference | value | | teacher | htmleditor | textarea | Scenario: Add, edit and preview a Calculated multichoice question with HTML in answers When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Calculated multichoice" to "1" And I click on "Add" "button" And I set the following fields to these values: | Question name | calculatedmulti-001 | | Question text | Multiply those two: s^{A} and s{B} | | Choice 1 | s{={A}*{B}} | | Choice 1 format | 1 | | Grade | 100% | | Choice 2 | s{={A}+{B}} | | Choice 2 format | 1 | And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" And I should see "Edit the wildcards datasets" And I press "id_addbutton" And I set the following fields to these values: | id_number_2 | 6 | | id_number_1 | 4 | And I press "id_savechanges" # Checking that the wildcard values are there And I am on the "calculatedmulti-001" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Edited question name | And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" And I press "id_savechanges" And I should see "Edited question name" # Preview it. And I choose "Preview" action for "Edited question name" in the question bank Then I should not see "" Scenario: Add, edit and preview a Calculated multichoice question with plain-text answers When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Calculated multichoice" to "1" And I click on "Add" "button" And I set the following fields to these values: | Question name | calculatedmulti-001 | | Question text | Multiply those two: s^{A} and s{B} | | Choice 1 | s{={A}*{B}} | | Choice 1 format | 2 | | Grade | 100% | | Choice 2 | s{={A}+{B}} | | Choice 2 format | 2 | And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" And I should see "Edit the wildcards datasets" And I press "id_addbutton" And I set the following fields to these values: | id_number_2 | 6 | | id_number_1 | 4 | And I press "id_savechanges" # Checking that the wildcard values are there And I am on the "calculatedmulti-001" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Edited question name | And I press "id_submitbutton" And I should see "Choose wildcards dataset properties" And I press "id_submitbutton" And I press "id_savechanges" And I should see "Edited question name" # Preview it. And I choose "Preview" action for "Edited question name" in the question bank Then I should see "" PK\߾{u44%type/calculatedmulti/questiontype.phpnu[. /** * Question type class for the calculated multiple-choice question type. * * @package qtype * @subpackage calculatedmulti * @copyright 2009 Pierre Pichet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use qtype_calculatedmulti\qtype_calculatedmulti_answer; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/multichoice/questiontype.php'); require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); /** * The calculated multiple-choice question type. * * @copyright 2009 Pierre Pichet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculatedmulti extends qtype_calculated { public function save_question_options($question) { global $DB; $context = $question->context; // During validation, we will call the parent's validation method. It will check // the answer options (choices) for formula errors. This method is shared with // calculated and calculatedsimple types for which the text is just a formula and // thus plain text from a regular text field. Therefore, we must transform the answers // before sending them upstream. We save the original data for later. $question->originalanswer = []; foreach ($question->answer as $key => $answerdata) { $question->originalanswer[$key] = $answerdata; if (is_array($answerdata)) { $question->answer[$key] = $answerdata['text']; } else { $question->answer[$key] = $answerdata; } } // Make it impossible to save bad formulas anywhere. $this->validate_question_data($question); // Calculated options. $options = $DB->get_record('question_calculated_options', array('question' => $question->id)); if (!$options) { $options = new stdClass(); $options->question = $question->id; $options->correctfeedback = ''; $options->partiallycorrectfeedback = ''; $options->incorrectfeedback = ''; $options->id = $DB->insert_record('question_calculated_options', $options); } $options->synchronize = $question->synchronize; $options->single = $question->single; $options->answernumbering = $question->answernumbering; $options->shuffleanswers = $question->shuffleanswers; $options = $this->save_combined_feedback_helper($options, $question, $context, true); $DB->update_record('question_calculated_options', $options); // Get old versions of the objects. if (!$oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { $oldanswers = array(); } if (!$oldoptions = $DB->get_records('question_calculated', array('question' => $question->id), 'answer ASC')) { $oldoptions = array(); } // Insert all the new answers. foreach ($question->originalanswer as $key => $answerdata) { if (is_array($answerdata)) { $answertext = $answerdata['text']; $answerformat = $answerdata['format']; } else { $answertext = $answerdata; // If no format is set, assume it is a legacy question and use FORMAT_PLAIN. $answerformat = FORMAT_PLAIN; } if (trim($answertext) == '') { continue; } // Update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer->answer = $this->import_or_save_files($question->originalanswer[$key], $context, 'question', 'answer', $answer->id); $answer->answerformat = $answerformat; $answer->fraction = $question->fraction[$key]; $answer->feedback = $this->import_or_save_files($question->feedback[$key], $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedback[$key]['format']; $DB->update_record("question_answers", $answer); // Set up the options object. if (!$options = array_shift($oldoptions)) { $options = new stdClass(); } $options->question = $question->id; $options->answer = $answer->id; $options->tolerance = trim($question->tolerance[$key]); $options->tolerancetype = trim($question->tolerancetype[$key]); $options->correctanswerlength = trim($question->correctanswerlength[$key]); $options->correctanswerformat = trim($question->correctanswerformat[$key]); // Save options. if (isset($options->id)) { // Reusing existing record. $DB->update_record('question_calculated', $options); } else { // New options. $DB->insert_record('question_calculated', $options); } } // Delete old answer records. if (!empty($oldanswers)) { foreach ($oldanswers as $oa) { $DB->delete_records('question_answers', array('id' => $oa->id)); } } if (!empty($oldoptions)) { foreach ($oldoptions as $oo) { $DB->delete_records('question_calculated', array('id' => $oo->id)); } } $this->save_hints($question, true); if (isset($question->import_process) && $question->import_process) { $this->import_datasets($question); } return true; } protected function validate_answer($answer) { $error = qtype_calculated_find_formula_errors_in_text($answer); if ($error) { throw new coding_exception($error); } } protected function validate_question_data($question) { parent::validate_question_data($question); $this->validate_text($question->correctfeedback['text']); $this->validate_text($question->partiallycorrectfeedback['text']); $this->validate_text($question->incorrectfeedback['text']); } protected function make_question_instance($questiondata) { question_bank::load_question_definition_classes($this->name()); if ($questiondata->options->single) { $class = 'qtype_calculatedmulti_single_question'; } else { $class = 'qtype_calculatedmulti_multi_question'; } return new $class(); } protected function initialise_question_instance(question_definition $question, $questiondata) { question_type::initialise_question_instance($question, $questiondata); $question->shuffleanswers = $questiondata->options->shuffleanswers; $question->answernumbering = $questiondata->options->answernumbering; if (!empty($questiondata->options->layout)) { $question->layout = $questiondata->options->layout; } else { $question->layout = qtype_multichoice_single_question::LAYOUT_VERTICAL; } $question->synchronised = $questiondata->options->synchronize; $this->initialise_combined_feedback($question, $questiondata, true); $this->initialise_question_answers($question, $questiondata, false); foreach ($questiondata->options->answers as $a) { $question->answers[$a->id]->correctanswerlength = $a->correctanswerlength; $question->answers[$a->id]->correctanswerformat = $a->correctanswerformat; } $question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id); } /** * Public override method, created so that make_answer will be available * for use by classes which extend qtype_multichoice_base. * * @param stdClass $answer Moodle DB question_answers object. * @return question_answer */ public function make_answer($answer) { return new qtype_calculatedmulti_answer($answer->id, $answer->answer, $answer->fraction, $answer->feedback, $answer->feedbackformat); } protected function make_hint($hint) { return question_hint_with_parts::load_from_record($hint); } public function comment_header($question) { $strheader = ''; $delimiter = ''; $answers = $question->options->answers; foreach ($answers as $key => $answer) { $ans = shorten_text($answer->answer, 17, true); $strheader .= $delimiter.$ans; $delimiter = '

    '; } return $strheader; } public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext, $answers, $data, $number) { $comment = new stdClass(); $comment->stranswers = array(); $comment->outsidelimit = false; $comment->answers = array(); $answers = fullclone($answers); foreach ($answers as $key => $answer) { // Evaluate the equations i.e {=5+4). $anssubstituted = $this->substitute_variables($answer->answer, $data); $formulas = $this->find_formulas($anssubstituted); $replaces = []; foreach ($formulas as $formula) { if ($formulaerrors = qtype_calculated_find_formula_errors($formula)) { $str = $formulaerrors; } else { eval('$str = ' . $formula . ';'); } $replaces[$formula] = $str; } $anstext = str_replace(array_keys($replaces), array_values($replaces), $anssubstituted); $comment->stranswers[$key] = $anssubstituted.'
    '.$anstext; } return fullclone($comment); } public function get_virtual_qtype() { return question_bank::get_qtype('multichoice'); } public function get_possible_responses($questiondata) { if ($questiondata->options->single) { $responses = array(); foreach ($questiondata->options->answers as $aid => $answer) { $responses[$aid] = new question_possible_response($answer->answer, $answer->fraction); } $responses[null] = question_possible_response::no_response(); return array($questiondata->id => $responses); } else { $parts = array(); foreach ($questiondata->options->answers as $aid => $answer) { $parts[$aid] = array($aid => new question_possible_response($answer->answer, $answer->fraction)); } return $parts; } } public function move_files($questionid, $oldcontextid, $newcontextid) { $fs = get_file_storage(); parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid, true); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_calculatedmulti', 'correctfeedback', $questionid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_calculatedmulti', 'partiallycorrectfeedback', $questionid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_calculatedmulti', 'incorrectfeedback', $questionid); } protected function delete_files($questionid, $contextid) { $fs = get_file_storage(); parent::delete_files($questionid, $contextid); $this->delete_files_in_answers($questionid, $contextid, true); $this->delete_files_in_hints($questionid, $contextid); $fs->delete_area_files($contextid, 'qtype_calculatedmulti', 'correctfeedback', $questionid); $fs->delete_area_files($contextid, 'qtype_calculatedmulti', 'partiallycorrectfeedback', $questionid); $fs->delete_area_files($contextid, 'qtype_calculatedmulti', 'incorrectfeedback', $questionid); } } PK\BV!type/tests/question_type_test.phpnu[. namespace core_question; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/questiontypebase.php'); /** * Tests for some of ../questionbase.php * * @package core_question * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @covers \question_type */ final class question_type_test extends \advanced_testcase { public function test_save_question_name(): void { $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(array()); $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id, 'name' => 'Test question')); $actual = question_bank::load_question_data($saq->id); $this->assertSame('Test question', $actual->name); } public function test_save_question_zero_name(): void { $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(array()); $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id, 'name' => '0')); $actual = question_bank::load_question_data($saq->id); $this->assertSame('0', $actual->name); } } PK\^5 Ctype/tests/question_first_matching_answer_grading_strategy_test.phpnu[. /** * Unit tests for the question_first_matching_answer_grading_strategy class. * * @package core_question * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question; use question_answer; use question_first_matching_answer_grading_strategy; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/questiontypebase.php'); /** * Helper used by the testcases in this file. * * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_response_answer_comparer implements \question_response_answer_comparer { protected $answers = array(); public function __construct($answers) { $this->answers = $answers; } public function get_answers() { return $this->answers; } public function compare_response_with_answer(array $response, question_answer $answer) { return $response['answer'] == $answer->answer; } } /** * Tests for {@link question_first_matching_answer_grading_strategy}. * * @package core_question * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_first_matching_answer_grading_strategy_test extends \advanced_testcase { protected function setUp(): void { parent::setUp(); } protected function tearDown(): void { parent::tearDown(); } public function test_no_answers_gives_null(): void { $question = new test_response_answer_comparer(array()); $strategy = new question_first_matching_answer_grading_strategy($question); $this->assertNull($strategy->grade(array())); } public function test_matching_answer_returned1(): void { $answer = new question_answer(0, 'frog', 1, '', FORMAT_HTML); $question = new test_response_answer_comparer(array($answer)); $strategy = new question_first_matching_answer_grading_strategy($question); $this->assertSame($answer, $strategy->grade(array('answer' => 'frog'))); } public function test_matching_answer_returned2(): void { $answer = new question_answer(0, 'frog', 1, '', FORMAT_HTML); $answer2 = new question_answer(0, 'frog', 0.5, '', FORMAT_HTML); $question = new test_response_answer_comparer(array($answer, $answer2)); $strategy = new question_first_matching_answer_grading_strategy($question); $this->assertSame($answer, $strategy->grade(array('answer' => 'frog'))); } public function test_no_matching_answer_gives_null(): void { $answer = new question_answer(0, 'frog', 1, '', FORMAT_HTML); $answer2 = new question_answer(0, 'frog', 0.5, '', FORMAT_HTML); $question = new test_response_answer_comparer(array($answer, $answer2)); $strategy = new question_first_matching_answer_grading_strategy($question); $this->assertNull($strategy->grade(array('answer' => 'toad'))); } } PK\ʿmm!type/tests/question_hint_test.phpnu[. namespace core_question; use question_hint; use question_hint_with_parts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/questiontypebase.php'); /** * Test for question_hint and subclasses. * * @package core_question * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \question_hint */ final class question_hint_test extends \advanced_testcase { public function test_basic(): void { $row = new \stdClass(); $row->id = 123; $row->hint = 'A hint'; $row->hintformat = FORMAT_HTML; $hint = question_hint::load_from_record($row); $this->assertEquals($row->id, $hint->id); $this->assertEquals($row->hint, $hint->hint); $this->assertEquals($row->hintformat, $hint->hintformat); } public function test_with_parts(): void { $row = new \stdClass(); $row->id = 123; $row->hint = 'A hint'; $row->hintformat = FORMAT_HTML; $row->shownumcorrect = 1; $row->clearwrong = 1; $hint = question_hint_with_parts::load_from_record($row); $this->assertEquals($row->id, $hint->id); $this->assertEquals($row->hint, $hint->hint); $this->assertEquals($row->hintformat, $hint->hintformat); $this->assertNotEmpty($hint->shownumcorrect); $this->assertNotEmpty($hint->clearwrong); } } PK\י 'type/tests/question_definition_test.phpnu[. namespace core_question; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Test for question_definition base classes. * * @package core_question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \question_definition */ final class question_definition_test extends \advanced_testcase { public function test_make_html_inline(): void { // Base class is abstract, so we need to pick one qusetion type to test this method. $mc = \test_question_maker::make_a_multichoice_single_question(); $this->assertEquals('Frog', $mc->make_html_inline('

    Frog

    ')); $this->assertEquals('Frog', $mc->make_html_inline('

    Frog

    ')); $this->assertEquals('Frog
    Toad', $mc->make_html_inline("

    Frog

    \n

    Toad

    ")); $this->assertEquals('Graph', $mc->make_html_inline( '

    Graph

    ')); $this->assertEquals("Frog
    XXX Graph", $mc->make_html_inline("

    Frog

    \n\r

    XXX Graph

    ")); $this->assertEquals('Frog', $mc->make_html_inline('

    Frog

    ')); $this->assertEquals('Frog
    †', $mc->make_html_inline('

    Frog

    ')); } public function test_check_file_access_hints(): void { // Prepare a shortanswer question with a hint plus default display options. $question = \test_question_maker::make_question('shortanswer', 'frogtoad'); $question->id = 42; $question->hints[] = new \question_hint_with_parts(12, 'foo', FORMAT_HTML, false, false); $options = new \question_display_options(); // Prepare and start an interactive question attempt. $quba = new \question_usage_by_activity('qtype_shortanswer', \context_system::instance()); $qa = new \question_attempt($question, $quba->get_id()); $qa->start('interactive', 1); // No answer has been submitted, so we should not have access to files from the 'hint' area. $args = [$question->hints[0]->id, 'foo.jpg']; $checkresult = $question->check_file_access($qa, $options, 'question', 'hint', $args, false); $this->assertFalse($checkresult); } } PK\Mtype/questiontypebase.phpnu[. /** * The default questiontype class. * * @package moodlecore * @subpackage questiontypes * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); require_once($CFG->libdir . '/questionlib.php'); use core_question\versions; /** * This is the base class for Moodle question types. * * There are detailed comments on each method, explaining what the method is * for, and the circumstances under which you might need to override it. * * Note: the questiontype API should NOT be considered stable yet. Very few * question types have been produced yet, so we do not yet know all the places * where the current API is insufficient. I would rather learn from the * experiences of the first few question type implementors, and improve the * interface to meet their needs, rather the freeze the API prematurely and * condem everyone to working round a clunky interface for ever afterwards. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_type { protected $fileoptions = array( 'subdirs' => true, 'maxfiles' => -1, 'maxbytes' => 0, ); public function __construct() { } /** * @return string the name of this question type. */ public function name() { return substr(get_class($this), 6); } /** * @return string the full frankenstyle name for this plugin. */ public function plugin_name() { return get_class($this); } /** * @return string the name of this question type in the user's language. * You should not need to override this method, the default behaviour should be fine. */ public function local_name() { return get_string('pluginname', $this->plugin_name()); } /** * The name this question should appear as in the create new question * dropdown. Override this method to return false if you don't want your * question type to be createable, for example if it is an abstract base type, * otherwise, you should not need to override this method. * * @return mixed the desired string, or false to hide this question type in the menu. */ public function menu_name() { return $this->local_name(); } /** * @return bool override this to return false if this is not really a * question type, for example the description question type is not * really a question type. */ public function is_real_question_type() { return true; } /** * @return bool true if this question type sometimes requires manual grading. */ public function is_manual_graded() { return false; } /** * @param object $question a question of this type. * @param string $otherquestionsinuse comma-separate list of other question ids in this attempt. * @return bool true if a particular instance of this question requires manual grading. */ public function is_question_manual_graded($question, $otherquestionsinuse) { return $this->is_manual_graded(); } /** * @return bool true if this question type can be used by the random question type. */ public function is_usable_by_random() { return true; } /** * Whether this question type can perform a frequency analysis of student * responses. * * If this method returns true, you must implement the get_possible_responses * method, and the question_definition class must implement the * classify_response method. * * @return bool whether this report can analyse all the student responses * for things like the quiz statistics report. */ public function can_analyse_responses() { // This works in most cases. return !$this->is_manual_graded(); } /** * @return whether the question_answers.answer field needs to have * restore_decode_content_links_worker called on it. */ public function has_html_answers() { return false; } /** * If your question type has a table that extends the question table, and * you want the base class to automatically save, backup and restore the extra fields, * override this method to return an array wherer the first element is the table name, * and the subsequent entries are the column names (apart from id and questionid). * * @return mixed array as above, or null to tell the base class to do nothing. */ public function extra_question_fields() { return null; } /** * If you use extra_question_fields, overload this function to return question id field name * in case you table use another name for this column */ public function questionid_column_name() { return 'questionid'; } /** * If your question type has a table that extends the question_answers table, * make this method return an array wherer the first element is the table name, * and the subsequent entries are the column names (apart from id and answerid). * * @return mixed array as above, or null to tell the base class to do nothing. */ public function extra_answer_fields() { return null; } /** * If the quetsion type uses files in responses, then this method should * return an array of all the response variables that might have corresponding * files. For example, the essay qtype returns array('attachments', 'answers'). * * @return array response variable names that may have associated files. */ public function response_file_areas() { return array(); } /** * Return an instance of the question editing form definition. This looks for a * class called edit_{$this->name()}_question_form in the file * question/type/{$this->name()}/edit_{$this->name()}_question_form.php * and if it exists returns an instance of it. * * @param string $submiturl passed on to the constructor call. * @return object an instance of the form definition, or null if one could not be found. */ public function create_editing_form($submiturl, $question, $category, $contexts, $formeditable) { global $CFG; require_once($CFG->dirroot . '/question/type/edit_question_form.php'); $definitionfile = $CFG->dirroot . '/question/type/' . $this->name() . '/edit_' . $this->name() . '_form.php'; if (!is_readable($definitionfile) || !is_file($definitionfile)) { throw new coding_exception($this->plugin_name() . ' is missing the definition of its editing formin file ' . $definitionfile . '.'); } require_once($definitionfile); $classname = $this->plugin_name() . '_edit_form'; if (!class_exists($classname)) { throw new coding_exception($this->plugin_name() . ' does not define the class ' . $this->plugin_name() . '_edit_form.'); } return new $classname($submiturl, $question, $category, $contexts, $formeditable); } /** * @return string the full path of the folder this plugin's files live in. */ public function plugin_dir() { global $CFG; return $CFG->dirroot . '/question/type/' . $this->name(); } /** * @return string the URL of the folder this plugin's files live in. */ public function plugin_baseurl() { global $CFG; return $CFG->wwwroot . '/question/type/' . $this->name(); } /** * Get extra actions for a question of this type to add to the question bank edit menu. * * This method is called if the {@link edit_menu_column} is being used in the * question bank, which it is by default since Moodle 3.8. If applicable for * your question type, you can return arn array of {@link action_menu_link}s. * These will be added at the end of the Edit menu for this question. * * The $question object passed in will have a hard-to-predict set of fields, * because the fields present depend on which columns are included in the * question bank view. However, you can rely on 'id', 'createdby', * 'contextid', 'hidden' and 'category' (id) being present, and so you * can call question_has_capability_on without causing performance problems. * * @param stdClass $question the available information about the particular question the action is for. * @return action_menu_link[] any actions you want to add to the Edit menu for this question. */ public function get_extra_question_bank_actions(stdClass $question): array { return []; } /** * This method should be overriden if you want to include a special heading or some other * html on a question editing page besides the question editing form. * * @param question_edit_form $mform a child of question_edit_form * @param object $question * @param string $wizardnow is '' for first page. */ public function display_question_editing_page($mform, $question, $wizardnow) { global $OUTPUT; $heading = $this->get_heading(empty($question->id)); echo $OUTPUT->heading_with_help($heading, 'pluginname', $this->plugin_name()); $mform->display(); } /** * Method called by display_question_editing_page and by question.php to get * heading for breadcrumbs. * * @return string the heading */ public function get_heading($adding = false) { if ($adding) { $string = 'pluginnameadding'; } else { $string = 'pluginnameediting'; } return get_string($string, $this->plugin_name()); } /** * Set any missing settings for this question to the default values. This is * called before displaying the question editing form. * * @param object $questiondata the question data, loaded from the databsae, * or more likely a newly created question object that is only partially * initialised. */ public function set_default_options($questiondata) { } /** * Return default value for a given form element either from user_preferences table or $default. * * @param string $name the name of the form element. * @param mixed $default default value. * @return string|null default value for a given form element. */ public function get_default_value(string $name, $default): ?string { return get_user_preferences($this->plugin_name() . '_' . $name, $default ?? '0'); } /** * Save the default value for a given form element in user_preferences table. * * @param string $name the name of the value to set. * @param string $value the setting value. */ public function set_default_value(string $name, string $value): void { set_user_preference($this->plugin_name() . '_' . $name, $value); } /** * Save question defaults when creating new questions. * * @param stdClass $fromform data from the form. */ public function save_defaults_for_new_questions(stdClass $fromform): void { // Some question types may not make use of the certain form elements, so // we need to do a check on the following generic form elements. For instance, // 'defaultmark' is not use in qtype_multianswer and 'penalty' in not used in // qtype_essay and qtype_recordrtc. if (isset($fromform->defaultmark)) { $this->set_default_value('defaultmark', $fromform->defaultmark); } if (isset($fromform->penalty)) { $this->set_default_value('penalty', $fromform->penalty); } } /** * Saves (creates or updates) a question. * * Given some question info and some data about the answers * this function parses, organises and saves the question * It is used by {@link question.php} when saving new data from * a form, and also by {@link import.php} when importing questions * This function in turn calls {@link save_question_options} * to save question-type specific data. * * Whether we are saving a new question or updating an existing one can be * determined by testing !empty($question->id). If it is not empty, we are updating. * * The question will be saved in category $form->category. * * @param object $question the question object which should be updated. For a * new question will be mostly empty. * @param object $form the object containing the information to save, as if * from the question editing form. * @param object $course not really used any more. * @return object On success, return the new question object. On failure, * return an object as follows. If the error object has an errors field, * display that as an error message. Otherwise, the editing form will be * redisplayed with validation errors, from validation_errors field, which * is itself an object, shown next to the form fields. (I don't think this * is accurate any more.) */ public function save_question($question, $form) { global $USER, $DB; // The actual update/insert done with multiple DB access, so we do it in a transaction. $transaction = $DB->start_delegated_transaction (); list($form->category) = explode(',', $form->category); $context = $this->get_context_by_category_id($form->category); $question->category = $form->category; // This default implementation is suitable for most // question types. // First, save the basic question itself. $question->name = trim($form->name); $question->parent = isset($form->parent) ? $form->parent : 0; $question->length = $this->actual_number_of_questions($question); $question->penalty = isset($form->penalty) ? $form->penalty : 0; // The trim call below has the effect of casting any strange values received, // like null or false, to an appropriate string, so we only need to test for // missing values. Be careful not to break the value '0' here. if (!isset($form->questiontext['text'])) { $question->questiontext = ''; } else { $question->questiontext = trim($form->questiontext['text']); } $question->questiontextformat = !empty($form->questiontext['format']) ? $form->questiontext['format'] : 0; if (empty($form->generalfeedback['text'])) { $question->generalfeedback = ''; } else { $question->generalfeedback = trim($form->generalfeedback['text']); } $question->generalfeedbackformat = !empty($form->generalfeedback['format']) ? $form->generalfeedback['format'] : 0; if ($question->name === '') { $question->name = shorten_text(strip_tags($form->questiontext['text']), 15); if ($question->name === '') { $question->name = '-'; } } if ($question->penalty > 1 or $question->penalty < 0) { $question->errors['penalty'] = get_string('invalidpenalty', 'question'); } if (isset($form->defaultmark)) { $question->defaultmark = $form->defaultmark; } // Only create a new bank entry if the question is not a new version (New question or duplicating a question). $questionbankentry = null; if (isset($question->id)) { $oldparent = $question->id; if (!empty($question->id)) { // Get the bank entry record where the question is referenced. $questionbankentry = get_question_bank_entry($question->id); } } // Get the bank entry old id (this is when there are questions related with a parent, e.g.: qtype_multianswers). if (isset($question->oldid)) { if (!empty($question->oldid)) { $questionbankentry = get_question_bank_entry($question->oldid); } } // Always creates a new question and version record. // Set the unique code. $question->stamp = make_unique_id_code(); $question->createdby = $USER->id; $question->timecreated = time(); // Idnumber validation. $question->idnumber = null; if (isset($form->idnumber)) { if ((string) $form->idnumber === '') { $question->idnumber = null; } else { // While this check already exists in the form validation, // this is a backstop preventing unnecessary errors. // Only set the idnumber if it has changed and will not cause a unique index violation. if (strpos($form->category, ',') !== false) { list($category, $categorycontextid) = explode(',', $form->category); } else { $category = $form->category; } $params = ['idnumber' => $form->idnumber, 'categoryid' => $category]; $andcondition = ''; if (isset($question->id) && isset($questionbankentry->id)) { $andcondition = 'AND qbe.id != :notid'; $params['notid'] = $questionbankentry->id; } $sql = "SELECT qbe.id FROM {question_bank_entries} qbe WHERE qbe.idnumber = :idnumber AND qbe.questioncategoryid = :categoryid $andcondition"; if (!$DB->record_exists_sql($sql, $params)) { $question->idnumber = $form->idnumber; } } } // Create the question. $question->id = $DB->insert_record('question', $question); if (!$questionbankentry) { // Create a record for question_bank_entries, question_versions and question_references. $questionbankentry = new \stdClass(); $questionbankentry->questioncategoryid = $form->category; $questionbankentry->idnumber = $question->idnumber; $questionbankentry->ownerid = $question->createdby; $questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry); } else { $questionbankentryold = new \stdClass(); $questionbankentryold->id = $questionbankentry->id; $questionbankentryold->idnumber = $question->idnumber; $DB->update_record('question_bank_entries', $questionbankentryold); } // Create question_versions records. $questionversion = new \stdClass(); $questionversion->questionbankentryid = $questionbankentry->id; $questionversion->questionid = $question->id; // Get the version and status from the parent question if parent is set. if (!$question->parent) { // Get the status field. It comes from the form, but for testing we can. $status = $form->status ?? $question->status ?? \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questionversion->version = versions::get_next_version($questionbankentry->id); $questionversion->status = $status; } else { $parentversion = get_question_version($form->parent); $questionversion->version = $parentversion[array_key_first($parentversion)]->version; $questionversion->status = $parentversion[array_key_first($parentversion)]->status; } $questionversion->id = $DB->insert_record('question_versions', $questionversion); // Now, whether we are updating a existing question, or creating a new // one, we have to do the files processing and update the record. // Question already exists, update. $question->modifiedby = $USER->id; $question->timemodified = time(); if (!empty($question->questiontext) && !empty($form->questiontext['itemid'])) { $question->questiontext = file_save_draft_area_files($form->questiontext['itemid'], $context->id, 'question', 'questiontext', (int)$question->id, $this->fileoptions, $question->questiontext); } if (!empty($question->generalfeedback) && !empty($form->generalfeedback['itemid'])) { $question->generalfeedback = file_save_draft_area_files( $form->generalfeedback['itemid'], $context->id, 'question', 'generalfeedback', (int)$question->id, $this->fileoptions, $question->generalfeedback); } $DB->update_record('question', $question); // Now to save all the answers and type-specific options. $form->id = $question->id; $form->qtype = $question->qtype; $form->questiontext = $question->questiontext; $form->questiontextformat = $question->questiontextformat; // Current context. $form->context = $context; // Old parent question id is used when there are questions related with a parent, e.g.: qtype_multianswers). if (isset($oldparent)) { $form->oldparent = $oldparent; } else { $form->oldparent = $question->parent; } $result = $this->save_question_options($form); if (!empty($result->error)) { throw new \moodle_exception($result->error); } if (!empty($result->notice)) { notice($result->notice, "question.php?id={$question->id}"); } if (!empty($result->noticeyesno)) { throw new coding_exception( '$result->noticeyesno no longer supported in save_question.'); } // Log the creation of this question. $event = \core\event\question_created::create_from_question_instance($question, $context); $event->trigger(); $transaction->allow_commit(); return $question; } /** * Saves question-type specific options * * This is called by {@see save_question()} to save the question-type specific data * * @param object $question This holds the information from the editing form, * it is not a standard question object. * @return bool|stdClass $result->error or $result->notice */ public function save_question_options($question) { global $DB; $extraquestionfields = $this->extra_question_fields(); if (is_array($extraquestionfields)) { $question_extension_table = array_shift($extraquestionfields); $function = 'update_record'; $questionidcolname = $this->questionid_column_name(); $options = $DB->get_record($question_extension_table, array($questionidcolname => $question->id)); if (!$options) { $function = 'insert_record'; $options = new stdClass(); $options->$questionidcolname = $question->id; } foreach ($extraquestionfields as $field) { if (property_exists($question, $field)) { $options->$field = $question->$field; } } $DB->{$function}($question_extension_table, $options); } } /** * Save the answers, with any extra data. * * Questions that use answers will call it from {@link save_question_options()}. * @param object $question This holds the information from the editing form, * it is not a standard question object. * @return object $result->error or $result->notice */ public function save_question_answers($question) { global $DB; $context = $question->context; $oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); // We need separate arrays for answers and extra answer data, so no JOINS there. $extraanswerfields = $this->extra_answer_fields(); $isextraanswerfields = is_array($extraanswerfields); $extraanswertable = ''; $oldanswerextras = array(); if ($isextraanswerfields) { $extraanswertable = array_shift($extraanswerfields); if (!empty($oldanswers)) { $oldanswerextras = $DB->get_records_sql("SELECT * FROM {{$extraanswertable}} WHERE " . 'answerid IN (SELECT id FROM {question_answers} WHERE question = ' . $question->id . ')' ); } } // Insert all the new answers. foreach ($question->answer as $key => $answerdata) { // Check for, and ignore, completely blank answer from the form. if ($this->is_answer_empty($question, $key)) { continue; } // Update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer = $this->fill_answer_fields($answer, $question, $key, $context); $DB->update_record('question_answers', $answer); if ($isextraanswerfields) { // Check, if this answer contains some extra field data. if ($this->is_extra_answer_fields_empty($question, $key)) { continue; } $answerextra = array_shift($oldanswerextras); if (!$answerextra) { $answerextra = new stdClass(); $answerextra->answerid = $answer->id; // Avoid looking for correct default for any possible DB field type // by setting real values. $answerextra = $this->fill_extra_answer_fields($answerextra, $question, $key, $context, $extraanswerfields); $answerextra->id = $DB->insert_record($extraanswertable, $answerextra); } else { // Update answerid, as record may be reused from another answer. $answerextra->answerid = $answer->id; $answerextra = $this->fill_extra_answer_fields($answerextra, $question, $key, $context, $extraanswerfields); $DB->update_record($extraanswertable, $answerextra); } } } if ($isextraanswerfields) { // Delete any left over extra answer fields records. $oldanswerextraids = array(); foreach ($oldanswerextras as $oldextra) { $oldanswerextraids[] = $oldextra->id; } $DB->delete_records_list($extraanswertable, 'id', $oldanswerextraids); } // Delete any left over old answer records. $fs = get_file_storage(); foreach ($oldanswers as $oldanswer) { $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); $DB->delete_records('question_answers', array('id' => $oldanswer->id)); } } /** * Returns true is answer with the $key is empty in the question data and should not be saved in DB. * * The questions using question_answers table may want to overload this. Default code will work * for shortanswer and similar question types. * @param object $questiondata This holds the information from the question editing form or import. * @param int $key A key of the answer in question. * @return bool True if answer shouldn't be saved in DB. */ protected function is_answer_empty($questiondata, $key) { return trim($questiondata->answer[$key]) == '' && $questiondata->fraction[$key] == 0 && html_is_blank($questiondata->feedback[$key]['text']); } /** * Return $answer, filling necessary fields for the question_answers table. * * The questions using question_answers table may want to overload this. Default code will work * for shortanswer and similar question types. * @param stdClass $answer Object to save data. * @param object $questiondata This holds the information from the question editing form or import. * @param int $key A key of the answer in question. * @param object $context needed for working with files. * @return $answer answer with filled data. */ protected function fill_answer_fields($answer, $questiondata, $key, $context) { $answer->answer = $questiondata->answer[$key]; $answer->fraction = $questiondata->fraction[$key]; $answer->feedback = $this->import_or_save_files($questiondata->feedback[$key], $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $questiondata->feedback[$key]['format']; return $answer; } /** * Returns true if extra answer fields for answer with the $key is empty * in the question data and should not be saved in DB. * * Questions where extra answer fields are optional will want to overload this. * @param object $questiondata This holds the information from the question editing form or import. * @param int $key A key of the answer in question. * @return bool True if extra answer data shouldn't be saved in DB. */ protected function is_extra_answer_fields_empty($questiondata, $key) { // No extra answer data in base class. return true; } /** * Return $answerextra, filling necessary fields for the extra answer fields table. * * The questions may want to overload it to save files or do other data processing. * @param stdClass $answerextra Object to save data. * @param object $questiondata This holds the information from the question editing form or import. * @param int $key A key of the answer in question. * @param object $context needed for working with files. * @param array $extraanswerfields extra answer fields (without table name). * @return $answer answerextra with filled data. */ protected function fill_extra_answer_fields($answerextra, $questiondata, $key, $context, $extraanswerfields) { foreach ($extraanswerfields as $field) { // The $questiondata->$field[$key] won't work in PHP, break it down to two strings of code. $fieldarray = $questiondata->$field; $answerextra->$field = $fieldarray[$key]; } return $answerextra; } public function save_hints($formdata, $withparts = false) { global $DB; $context = $formdata->context; $oldhints = $DB->get_records('question_hints', array('questionid' => $formdata->id), 'id ASC'); $numhints = $this->count_hints_on_form($formdata, $withparts); for ($i = 0; $i < $numhints; $i += 1) { if (html_is_blank($formdata->hint[$i]['text'])) { $formdata->hint[$i]['text'] = ''; } if ($withparts) { $clearwrong = !empty($formdata->hintclearwrong[$i]); $shownumcorrect = !empty($formdata->hintshownumcorrect[$i]); } if ($this->is_hint_empty_in_form_data($formdata, $i, $withparts)) { continue; } // Update an existing hint if possible. $hint = array_shift($oldhints); if (!$hint) { $hint = new stdClass(); $hint->questionid = $formdata->id; $hint->hint = ''; $hint->id = $DB->insert_record('question_hints', $hint); } $hint->hint = $this->import_or_save_files($formdata->hint[$i], $context, 'question', 'hint', $hint->id); $hint->hintformat = $formdata->hint[$i]['format']; if ($withparts) { $hint->clearwrong = $clearwrong; $hint->shownumcorrect = $shownumcorrect; } $hint->options = $this->save_hint_options($formdata, $i, $withparts); $DB->update_record('question_hints', $hint); } // Delete any remaining old hints. $fs = get_file_storage(); foreach ($oldhints as $oldhint) { $fs->delete_area_files($context->id, 'question', 'hint', $oldhint->id); $DB->delete_records('question_hints', array('id' => $oldhint->id)); } } /** * Count number of hints on the form. * Overload if you use custom hint controls. * @param object $formdata the data from the form. * @param bool $withparts whether to take into account clearwrong and shownumcorrect options. * @return int count of hints on the form. */ protected function count_hints_on_form($formdata, $withparts) { if (!empty($formdata->hint)) { $numhints = max(array_keys($formdata->hint)) + 1; } else { $numhints = 0; } if ($withparts) { if (!empty($formdata->hintclearwrong)) { $numclears = max(array_keys($formdata->hintclearwrong)) + 1; } else { $numclears = 0; } if (!empty($formdata->hintshownumcorrect)) { $numshows = max(array_keys($formdata->hintshownumcorrect)) + 1; } else { $numshows = 0; } $numhints = max($numhints, $numclears, $numshows); } return $numhints; } /** * Determine if the hint with specified number is not empty and should be saved. * Overload if you use custom hint controls. * @param object $formdata the data from the form. * @param int $number number of hint under question. * @param bool $withparts whether to take into account clearwrong and shownumcorrect options. * @return bool is this particular hint data empty. */ protected function is_hint_empty_in_form_data($formdata, $number, $withparts) { if ($withparts) { return empty($formdata->hint[$number]['text']) && empty($formdata->hintclearwrong[$number]) && empty($formdata->hintshownumcorrect[$number]); } else { return empty($formdata->hint[$number]['text']); } } /** * Save additional question type data into the hint optional field. * Overload if you use custom hint information. * @param object $formdata the data from the form. * @param int $number number of hint to get options from. * @param bool $withparts whether question have parts. * @return string value to save into the options field of question_hints table. */ protected function save_hint_options($formdata, $number, $withparts) { return null; // By default, options field is unused. } /** * Can be used to {@link save_question_options()} to transfer the combined * feedback fields from $formdata to $options. * @param object $options the $question->options object being built. * @param object $formdata the data from the form. * @param object $context the context the quetsion is being saved into. * @param bool $withparts whether $options->shownumcorrect should be set. */ protected function save_combined_feedback_helper($options, $formdata, $context, $withparts = false) { $options->correctfeedback = $this->import_or_save_files($formdata->correctfeedback, $context, 'question', 'correctfeedback', $formdata->id); $options->correctfeedbackformat = $formdata->correctfeedback['format']; $options->partiallycorrectfeedback = $this->import_or_save_files( $formdata->partiallycorrectfeedback, $context, 'question', 'partiallycorrectfeedback', $formdata->id); $options->partiallycorrectfeedbackformat = $formdata->partiallycorrectfeedback['format']; $options->incorrectfeedback = $this->import_or_save_files($formdata->incorrectfeedback, $context, 'question', 'incorrectfeedback', $formdata->id); $options->incorrectfeedbackformat = $formdata->incorrectfeedback['format']; if ($withparts) { $options->shownumcorrect = !empty($formdata->shownumcorrect); } return $options; } /** * Loads the question type specific options for the question. * * This function loads any question type specific options for the * question from the database into the question object. This information * is placed in the $question->options field. A question type is * free, however, to decide on a internal structure of the options field. * @return bool Indicates success or failure. * @param object $question The question object for the question. This object * should be updated to include the question type * specific information (it is passed by reference). */ public function get_question_options($question) { global $DB; if (!isset($question->options)) { $question->options = new stdClass(); } $extraquestionfields = $this->extra_question_fields(); if (is_array($extraquestionfields)) { $question_extension_table = array_shift($extraquestionfields); $extra_data = $DB->get_record($question_extension_table, array($this->questionid_column_name() => $question->id), implode(', ', $extraquestionfields)); if ($extra_data) { foreach ($extraquestionfields as $field) { $question->options->$field = $extra_data->$field; } } else { debugging('Failed to load question options from the table ' . $question_extension_table . ' for questionid ' . $question->id); } } $extraanswerfields = $this->extra_answer_fields(); if (is_array($extraanswerfields)) { $answerextensiontable = array_shift($extraanswerfields); // Use LEFT JOIN in case not every answer has extra data. $answers = $DB->get_records_sql(" SELECT qa.*, qax." . implode(', qax.', $extraanswerfields) . ' FROM {question_answers} qa ' . " LEFT JOIN {{$answerextensiontable}} qax ON qa.id = qax.answerid WHERE qa.question = ? ORDER BY qa.id", array($question->id)); if (!$answers) { debugging('Failed to load question answers from the table ' . $answerextensiontable . ' for questionid ' . $question->id); } } else { // Don't check for success or failure because some question types do // not use the answers table. $answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); } // Store the answers into the question object. $question->options->answers = array_map(function($answer) { // Some database engines return floats as strings like '1.0000000'. Cast to float for consistency. $answer->fraction = (float) $answer->fraction; return $answer; }, $answers); $question->hints = $DB->get_records('question_hints', array('questionid' => $question->id), 'id ASC'); return true; } /** * Create an appropriate question_definition for the question of this type * using data loaded from the database. * @param object $questiondata the question data loaded from the database. * @return question_definition the corresponding question_definition. */ public function make_question($questiondata) { $question = $this->make_question_instance($questiondata); $this->initialise_question_instance($question, $questiondata); return $question; } /** * Create an appropriate question_definition for the question of this type * using data loaded from the database. * @param object $questiondata the question data loaded from the database. * @return question_definition an instance of the appropriate question_definition subclass. * Still needs to be initialised. */ protected function make_question_instance($questiondata) { question_bank::load_question_definition_classes($this->name()); $class = 'qtype_' . $this->name() . '_question'; return new $class(); } /** * Initialise the common question_definition fields. * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. */ protected function initialise_question_instance(question_definition $question, $questiondata) { $question->id = $questiondata->id; $question->category = $questiondata->category; $question->contextid = $questiondata->contextid; $question->parent = $questiondata->parent; $question->qtype = $this; $question->name = $questiondata->name; $question->questiontext = $questiondata->questiontext; $question->questiontextformat = $questiondata->questiontextformat; $question->generalfeedback = $questiondata->generalfeedback; $question->generalfeedbackformat = $questiondata->generalfeedbackformat; $question->defaultmark = $questiondata->defaultmark + 0; $question->length = $questiondata->length; $question->penalty = $questiondata->penalty; $question->stamp = $questiondata->stamp; $question->timecreated = $questiondata->timecreated; $question->timemodified = $questiondata->timemodified; $question->createdby = $questiondata->createdby; $question->modifiedby = $questiondata->modifiedby; $this->initialise_core_question_metadata($question, $questiondata); // Fill extra question fields values. $extraquestionfields = $this->extra_question_fields(); if (is_array($extraquestionfields)) { // Omit table name. array_shift($extraquestionfields); foreach ($extraquestionfields as $field) { $question->$field = $questiondata->options->$field; } } $this->initialise_question_hints($question, $questiondata); // Add the custom fields. $this->initialise_custom_fields($question, $questiondata); } /** * Initialise the question metadata. * * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. */ protected function initialise_core_question_metadata(question_definition $question, $questiondata) { $fields = [ 'status', 'versionid', 'version', 'questionbankentryid', 'idnumber', ]; foreach ($fields as $field) { if (isset($questiondata->{$field})) { $question->{$field} = $questiondata->{$field}; } } } /** * Initialise question_definition::hints field. * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. */ protected function initialise_question_hints(question_definition $question, $questiondata) { if (empty($questiondata->hints)) { return; } foreach ($questiondata->hints as $hint) { $question->hints[] = $this->make_hint($hint); } } /** * Create a question_hint, or an appropriate subclass for this question, * from a row loaded from the database. * @param object $hint the DB row from the question hints table. * @return question_hint */ protected function make_hint($hint) { return question_hint::load_from_record($hint); } /** * Initialise question custom fields. * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. */ protected function initialise_custom_fields(question_definition $question, $questiondata) { if (!empty($questiondata->customfields)) { $question->customfields = $questiondata->customfields; } } /** * Initialise the combined feedback fields. * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. * @param bool $withparts whether to set the shownumcorrect field. */ protected function initialise_combined_feedback(question_definition $question, $questiondata, $withparts = false) { $question->correctfeedback = $questiondata->options->correctfeedback; $question->correctfeedbackformat = $questiondata->options->correctfeedbackformat; $question->partiallycorrectfeedback = $questiondata->options->partiallycorrectfeedback; $question->partiallycorrectfeedbackformat = $questiondata->options->partiallycorrectfeedbackformat; $question->incorrectfeedback = $questiondata->options->incorrectfeedback; $question->incorrectfeedbackformat = $questiondata->options->incorrectfeedbackformat; if ($withparts) { $question->shownumcorrect = $questiondata->options->shownumcorrect; } } /** * Initialise question_definition::answers field. * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. * @param bool $forceplaintextanswers most qtypes assume that answers are * FORMAT_PLAIN, and dont use the answerformat DB column (it contains * the default 0 = FORMAT_MOODLE). Therefore, by default this method * ingores answerformat. Pass false here to use answerformat. For example * multichoice does this. */ protected function initialise_question_answers(question_definition $question, $questiondata, $forceplaintextanswers = true) { $question->answers = array(); if (empty($questiondata->options->answers)) { return; } foreach ($questiondata->options->answers as $a) { $question->answers[$a->id] = $this->make_answer($a); if (!$forceplaintextanswers) { $question->answers[$a->id]->answerformat = $a->answerformat; } } } /** * Create a question_answer, or an appropriate subclass for this question, * from a row loaded from the database. * @param object $answer the DB row from the question_answers table plus extra answer fields. * @return question_answer */ protected function make_answer($answer) { return new question_answer($answer->id, $answer->answer, $answer->fraction, $answer->feedback, $answer->feedbackformat); } /** * Deletes the question-type specific data when a question is deleted. * @param int $question the question being deleted. * @param int $contextid the context this quesiotn belongs to. */ public function delete_question($questionid, $contextid) { global $DB; $this->delete_files($questionid, $contextid); $extraquestionfields = $this->extra_question_fields(); if (is_array($extraquestionfields)) { $question_extension_table = array_shift($extraquestionfields); $DB->delete_records($question_extension_table, array($this->questionid_column_name() => $questionid)); } $extraanswerfields = $this->extra_answer_fields(); if (is_array($extraanswerfields)) { $answer_extension_table = array_shift($extraanswerfields); $DB->delete_records_select($answer_extension_table, 'answerid IN (SELECT qa.id FROM {question_answers} qa WHERE qa.question = ?)', array($questionid)); } $DB->delete_records('question_answers', array('question' => $questionid)); $DB->delete_records('question_hints', array('questionid' => $questionid)); } /** * Returns the number of question numbers which are used by the question * * This function returns the number of question numbers to be assigned * to the question. Most question types will have length one; they will be * assigned one number. The 'description' type, however does not use up a * number and so has a length of zero. Other question types may wish to * handle a bundle of questions and hence return a number greater than one. * @return int The number of question numbers which should be * assigned to the question. * @param object $question The question whose length is to be determined. * Question type specific information is included. */ public function actual_number_of_questions($question) { // By default, each question is given one number. return 1; } /** * Calculate the score a monkey would get on a question by clicking randomly. * * Some question types have significant non-zero average expected score * of the response is just selected randomly. For example 50% for a * true-false question. It is useful to know what this is. For example * it gets shown in the quiz statistics report. * * For almost any open-ended question type (E.g. shortanswer or numerical) * this should be 0. * * For selective response question types (e.g. multiple choice), you can probably compute this. * * For particularly complicated question types the may be impossible or very * difficult to compute. In this case return null. (Or, if the expected score * is very tiny even though the exact value is unknown, it may appropriate * to return 0.) * * @param stdClass $questiondata data defining a question, as returned by * question_bank::load_question_data(). * @return number|null either a fraction estimating what the student would * score by guessing, or null, if it is not possible to estimate. */ public function get_random_guess_score($questiondata) { return 0; } /** * Whether or not to break down question stats and response analysis, for a question defined by $questiondata. * * @param object $questiondata The full question definition data. * @return bool */ public function break_down_stats_and_response_analysis_by_variant($questiondata) { return true; } /** * This method should return all the possible types of response that are * recognised for this question. * * The question is modelled as comprising one or more subparts. For each * subpart, there are one or more classes that that students response * might fall into, each of those classes earning a certain score. * * For example, in a shortanswer question, there is only one subpart, the * text entry field. The response the student gave will be classified according * to which of the possible $question->options->answers it matches. * * For the matching question type, there will be one subpart for each * question stem, and for each stem, each of the possible choices is a class * of student's response. * * A response is an object with two fields, ->responseclass is a string * presentation of that response, and ->fraction, the credit for a response * in that class. * * Array keys have no specific meaning, but must be unique, and must be * the same if this function is called repeatedly. * * @param object $question the question definition data. * @return array keys are subquestionid, values are arrays of possible * responses to that subquestion. */ public function get_possible_responses($questiondata) { return array(); } /** * Utility method used by {@link qtype_renderer::head_code()}. It looks * for any of the files script.js or script.php that exist in the plugin * folder and ensures they get included. */ public function find_standard_scripts() { global $PAGE; $plugindir = $this->plugin_dir(); $plugindirrel = 'question/type/' . $this->name(); if (file_exists($plugindir . '/script.js')) { $PAGE->requires->js('/' . $plugindirrel . '/script.js'); } if (file_exists($plugindir . '/script.php')) { $PAGE->requires->js('/' . $plugindirrel . '/script.php'); } } /** * Returns true if the editing wizard is finished, false otherwise. * * The default implementation returns true, which is suitable for all question- * types that only use one editing form. This function is used in * question.php to decide whether we can regrade any states of the edited * question and redirect to edit.php. * * The dataset dependent question-type, which is extended by the calculated * question-type, overwrites this method because it uses multiple pages (i.e. * a wizard) to set up the question and associated datasets. * * @param object $form The data submitted by the previous page. * * @return bool Whether the wizard's last page was submitted or not. */ public function finished_edit_wizard($form) { // In the default case there is only one edit page. return true; } // IMPORT/EXPORT FUNCTIONS --------------------------------- . /* * Imports question from the Moodle XML format * * Imports question using information from extra_question_fields function * If some of you fields contains id's you'll need to reimplement this */ public function import_from_xml($data, $question, qformat_xml $format, $extra=null) { $question_type = $data['@']['type']; if ($question_type != $this->name()) { return false; } $extraquestionfields = $this->extra_question_fields(); if (!is_array($extraquestionfields)) { return false; } // Omit table name. array_shift($extraquestionfields); $qo = $format->import_headers($data); $qo->qtype = $question_type; foreach ($extraquestionfields as $field) { $qo->$field = $format->getpath($data, array('#', $field, 0, '#'), ''); } // Run through the answers. $answers = $data['#']['answer']; $a_count = 0; $extraanswersfields = $this->extra_answer_fields(); if (is_array($extraanswersfields)) { array_shift($extraanswersfields); } foreach ($answers as $answer) { $ans = $format->import_answer($answer); if (!$this->has_html_answers()) { $qo->answer[$a_count] = $ans->answer['text']; } else { $qo->answer[$a_count] = $ans->answer; } $qo->fraction[$a_count] = $ans->fraction; $qo->feedback[$a_count] = $ans->feedback; if (is_array($extraanswersfields)) { foreach ($extraanswersfields as $field) { $qo->{$field}[$a_count] = $format->getpath($answer, array('#', $field, 0, '#'), ''); } } ++$a_count; } return $qo; } /* * Export question to the Moodle XML format * * Export question using information from extra_question_fields function * If some of you fields contains id's you'll need to reimplement this */ public function export_to_xml($question, qformat_xml $format, $extra=null) { $extraquestionfields = $this->extra_question_fields(); if (!is_array($extraquestionfields)) { return false; } // Omit table name. array_shift($extraquestionfields); $expout=''; foreach ($extraquestionfields as $field) { $exportedvalue = $format->xml_escape($question->options->$field); $expout .= " <{$field}>{$exportedvalue}\n"; } $extraanswersfields = $this->extra_answer_fields(); if (is_array($extraanswersfields)) { array_shift($extraanswersfields); } foreach ($question->options->answers as $answer) { $extra = ''; if (is_array($extraanswersfields)) { foreach ($extraanswersfields as $field) { $exportedvalue = $format->xml_escape($answer->$field); $extra .= " <{$field}>{$exportedvalue}\n"; } } $expout .= $format->write_answer($answer, $extra); } return $expout; } /** * Abstract function implemented by each question type. It runs all the code * required to set up and save a question of any type for testing purposes. * Alternate DB table prefix may be used to facilitate data deletion. */ #[\core\attribute\deprecated(replacement: null, since: '5.0', mdl: 'MDL-71378')] public function generate_test($name, $courseid=null) { \core\deprecation::emit_deprecation([$this, __FUNCTION__]); $form = new stdClass(); $form->name = $name; $form->questiontextformat = 1; $form->questiontext = 'test question, generated by script'; $form->defaultmark = 1; $form->penalty = 0.3333333; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $form->generalfeedback = "Well done"; $context = context_course::instance($courseid); $newcategory = question_make_default_categories(array($context)); $form->category = $newcategory->id . ',1'; $question = new stdClass(); $question->courseid = $courseid; $question->qtype = $this->name(); return array($form, $question); } /** * Get question context by category id * @param int $category * @return object $context */ protected function get_context_by_category_id($category) { global $DB; $contextid = $DB->get_field('question_categories', 'contextid', array('id'=>$category)); $context = context::instance_by_id($contextid, IGNORE_MISSING); return $context; } /** * Save the file belonging to one text field. * * @param array $field the data from the form (or from import). This will * normally have come from the formslib editor element, so it will be an * array with keys 'text', 'format' and 'itemid'. However, when we are * importing, it will be an array with keys 'text', 'format' and 'files' * @param object $context the context the question is in. * @param string $component indentifies the file area question. * @param string $filearea indentifies the file area questiontext, * generalfeedback, answerfeedback, etc. * @param int $itemid identifies the file area. * * @return string the text for this field, after files have been processed. */ protected function import_or_save_files($field, $context, $component, $filearea, $itemid) { if (!empty($field['itemid'])) { // This is the normal case. We are safing the questions editing form. $result = file_save_draft_area_files($field['itemid'], $context->id, $component, $filearea, $itemid, $this->fileoptions, trim($field['text'])); file_clear_draft_area($field['itemid']); return $result; } else if (!empty($field['files'])) { // This is the case when we are doing an import. foreach ($field['files'] as $file) { $this->import_file($context, $component, $filearea, $itemid, $file); } } return trim($field['text']); } /** * Move all the files belonging to this question from one context to another. * @param int $questionid the question being moved. * @param int $oldcontextid the context it is moving from. * @param int $newcontextid the context it is moving to. */ public function move_files($questionid, $oldcontextid, $newcontextid) { $fs = get_file_storage(); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'questiontext', $questionid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'generalfeedback', $questionid); } /** * Move all the files belonging to this question's answers when the question * is moved from one context to another. * @param int $questionid the question being moved. * @param int $oldcontextid the context it is moving from. * @param int $newcontextid the context it is moving to. * @param bool $answerstoo whether there is an 'answer' question area, * as well as an 'answerfeedback' one. Default false. */ protected function move_files_in_answers($questionid, $oldcontextid, $newcontextid, $answerstoo = false) { global $DB; $fs = get_file_storage(); $answerids = $DB->get_records_menu('question_answers', array('question' => $questionid), 'id', 'id,1'); foreach ($answerids as $answerid => $notused) { if ($answerstoo) { $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'answer', $answerid); } $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'answerfeedback', $answerid); } } /** * Move all the files belonging to this question's hints when the question * is moved from one context to another. * @param int $questionid the question being moved. * @param int $oldcontextid the context it is moving from. * @param int $newcontextid the context it is moving to. * @param bool $answerstoo whether there is an 'answer' question area, * as well as an 'answerfeedback' one. Default false. */ protected function move_files_in_hints($questionid, $oldcontextid, $newcontextid) { global $DB; $fs = get_file_storage(); $hintids = $DB->get_records_menu('question_hints', array('questionid' => $questionid), 'id', 'id,1'); foreach ($hintids as $hintid => $notused) { $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'hint', $hintid); } } /** * Move all the files belonging to this question's answers when the question * is moved from one context to another. * @param int $questionid the question being moved. * @param int $oldcontextid the context it is moving from. * @param int $newcontextid the context it is moving to. * @param bool $answerstoo whether there is an 'answer' question area, * as well as an 'answerfeedback' one. Default false. */ protected function move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid) { global $DB; $fs = get_file_storage(); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'correctfeedback', $questionid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'partiallycorrectfeedback', $questionid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'question', 'incorrectfeedback', $questionid); } /** * Delete all the files belonging to this question. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. */ protected function delete_files($questionid, $contextid) { $fs = get_file_storage(); $fs->delete_area_files($contextid, 'question', 'questiontext', $questionid); $fs->delete_area_files($contextid, 'question', 'generalfeedback', $questionid); } /** * Delete all the files belonging to this question's answers. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. * @param bool $answerstoo whether there is an 'answer' question area, * as well as an 'answerfeedback' one. Default false. */ protected function delete_files_in_answers($questionid, $contextid, $answerstoo = false) { global $DB; $fs = get_file_storage(); $answerids = $DB->get_records_menu('question_answers', array('question' => $questionid), 'id', 'id,1'); foreach ($answerids as $answerid => $notused) { if ($answerstoo) { $fs->delete_area_files($contextid, 'question', 'answer', $answerid); } $fs->delete_area_files($contextid, 'question', 'answerfeedback', $answerid); } } /** * Delete all the files belonging to this question's hints. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. */ protected function delete_files_in_hints($questionid, $contextid) { global $DB; $fs = get_file_storage(); $hintids = $DB->get_records_menu('question_hints', array('questionid' => $questionid), 'id', 'id,1'); foreach ($hintids as $hintid => $notused) { $fs->delete_area_files($contextid, 'question', 'hint', $hintid); } } /** * Delete all the files belonging to this question's answers. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. * @param bool $answerstoo whether there is an 'answer' question area, * as well as an 'answerfeedback' one. Default false. */ protected function delete_files_in_combined_feedback($questionid, $contextid) { global $DB; $fs = get_file_storage(); $fs->delete_area_files($contextid, 'question', 'correctfeedback', $questionid); $fs->delete_area_files($contextid, 'question', 'partiallycorrectfeedback', $questionid); $fs->delete_area_files($contextid, 'question', 'incorrectfeedback', $questionid); } public function import_file($context, $component, $filearea, $itemid, $file) { $fs = get_file_storage(); $record = new stdClass(); if (is_object($context)) { $record->contextid = $context->id; } else { $record->contextid = $context; } $record->component = $component; $record->filearea = $filearea; $record->itemid = $itemid; $record->filename = $file->name; $record->filepath = '/'; return $fs->create_file_from_string($record, $this->decode_file($file)); } protected function decode_file($file) { switch ($file->encoding) { case 'base64': default: return base64_decode($file->content); } } } /** * This class is used in the return value from * {@link question_type::get_possible_responses()}. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_possible_response { /** * @var string the classification of this response the student gave to this * part of the question. Must match one of the responseclasses returned by * {@link question_type::get_possible_responses()}. */ public $responseclass; /** @var string the (partial) credit awarded for this responses. */ public $fraction; /** * Constructor, just an easy way to set the fields. * @param string $responseclassid see the field descriptions above. * @param string $response see the field descriptions above. * @param number $fraction see the field descriptions above. */ public function __construct($responseclass, $fraction) { $this->responseclass = $responseclass; $this->fraction = $fraction; } public static function no_response() { return new question_possible_response(get_string('noresponse', 'question'), 0); } } PK\i]]*type/description/edit_description_form.phpnu[. /** * Defines the editing form for the description question type. * * @package qtype * @subpackage description * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Description editing form definition. * * @copyright 2007 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description_edit_form extends question_edit_form { /** * Add question-type specific form fields. * * @param MoodleQuickForm $mform the form being built. */ protected function definition_inner($mform) { // We don't need this default element. $mform->removeElement('defaultmark'); $mform->addElement('hidden', 'defaultmark', 0); $mform->setType('defaultmark', PARAM_RAW); } public function qtype() { return 'description'; } } PK\>type/description/renderer.phpnu[. /** * Description 'question' renderer class. * * @package qtype * @subpackage description * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Generates the output for description 'question's. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description_renderer extends qtype_renderer { public function formulation_and_controls(question_attempt $qa, question_display_options $options) { return html_writer::tag('div', $qa->get_question()->format_questiontext($qa), array('class' => 'qtext')); } public function formulation_heading() { return get_string('informationtext', 'qtype_description'); } } PK\q"type/description/db/upgradelib.phpnu[. /** * Upgrade library code for the description question type. * * @package qtype * @subpackage description * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Class for converting attempt data for description questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description_qe2_attempt_updater extends question_qtype_attempt_updater { public function right_answer() { return ''; } public function was_answered($state) { return false; } public function response_summary($state) { return ''; } public function set_first_step_data_elements($state, &$data) { } public function supply_missing_first_step_data(&$data) { } public function set_data_elements_for_step($state, &$data) { } } PK\ߍ88.type/description/lang/en/qtype_description.phpnu[. /** * Strings for component 'qtype_description', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage description * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['informationtext'] = 'Information text'; $string['pluginname'] = 'Description'; $string['pluginname_help'] = 'A description is not really a question type. It simply enables text to be displayed without requiring any answers, similar to a label on the course page. The question text is displayed both during the attempt and on the review page. Any general feedback is displayed on the review page only.'; $string['pluginnameadding'] = 'Adding a description'; $string['pluginnameediting'] = 'Editing a Description'; $string['pluginnamesummary'] = 'This is not actually a question. Instead it is a way to add some instructions, rubric or other content to the activity. This is similar to the way that labels can be used to add content to the course page.'; $string['privacy:metadata'] = 'The Description question type plugin does not store any personal data.'; PK\[Ouutype/description/pix/icon.gifnu[GIF89aN]#q!,:x02Cʐ!FFH[xh]h\&(@[$ge.PdJM;PK\ \type/description/pix/icon.svgnu[PK\wX1TTtype/description/version.phpnu[. /** * Version information for the description 'question' type. * * @package qtype * @subpackage description * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_description'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\.~type/description/question.phpnu[. /** * Description 'question' definition class. * * @package qtype * @subpackage description * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * Represents a description 'question'. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description_question extends question_information_item { } PK\9U'  -type/description/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_description. * * @package qtype_description * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_description\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_description implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\R7type/description/tests/fixtures/testquestion.moodle.xmlnu[ $course$/Default for C1 description-001 Instructions about the following questions. Why actually the field 'General feedback' used in this qytype? 0.0000000 0.0000000 0 PK\ȕ -type/description/tests/question_type_test.phpnu[. namespace qtype_description; use qtype_description; use qtype_description_edit_form; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/description/questiontype.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/description/edit_description_form.php'); /** * Unit tests for the description question type class. * * @package qtype_description * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \advanced_testcase { protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_description(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'description'); } public function test_actual_number_of_questions(): void { $this->assertEquals(0, $this->qtype->actual_number_of_questions(null)); } public function test_can_analyse_responses(): void { $this->assertFalse($this->qtype->can_analyse_responses()); } public function test_get_random_guess_score(): void { $this->assertNull($this->qtype->get_random_guess_score(null)); } public function test_get_possible_responses(): void { $this->assertEquals(array(), $this->qtype->get_possible_responses(null)); } public function test_question_saving(): void { $this->resetAfterTest(true); $this->setAdminUser(); $questiondata = \test_question_maker::get_question_data('description'); $formdata = \test_question_maker::get_question_form_data('description'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(array()); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_description_edit_form::mock_submit((array)$formdata); $form = \qtype_description_test_helper::get_question_editing_form($cat, $questiondata); $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, array('id', 'timemodified', 'timecreated', 'idnumber'))) { $this->assertEquals($value, $actualquestiondata->$property); } } } } PK\#+type/description/tests/walkthrough_test.phpnu[. namespace qtype_description; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * This file contains tests that walks a description question through its interaction model. * * @package qtype_description * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_informationitem_feedback_description(): void { // Create a description question. $description = \test_question_maker::make_question('description'); $this->start_attempt_at_question($description, 'deferredfeedback'); // Check the initial state. $this->assertEquals('informationitem', $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); $this->check_current_output( new \question_contains_tag_with_contents('h4', get_string('informationtext', 'qtype_description')) ); // Further tests of the description qtype are in // question/behaviour/informationitem/tests/testwalkthrough.php. } } PK\jk[[!type/description/tests/helper.phpnu[. /** * Test helper code for the description question type. * * @package qtype_description * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the description question type. * * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description_test_helper extends question_test_helper { public function get_test_questions() { return array('info'); } /** * @return qtype_description_question */ public static function make_description_question_info() { question_bank::load_question_definition_classes('description'); $q = new qtype_description_question(); test_question_maker::initialise_a_question($q); $q->defaultmark = 0; $q->penalty = 0; $q->length = 0; $q->name = 'Description'; $q->questiontext = 'Here is some information about the questions you are about to attempt.'; $q->generalfeedback = 'And here is some more text shown only on the review page.'; $q->qtype = question_bank::get_qtype('description'); return $q; } /** * Get the question data, as it would be loaded by get_question_options. * @return object */ public static function get_description_question_data_info() { global $USER; $qdata = new stdClass(); $qdata->id = 0; $qdata->contextid = 0; $qdata->category = 0; $qdata->parent = 0; $qdata->stamp = make_unique_id_code(); $qdata->timecreated = time(); $qdata->timemodified = time(); $qdata->createdby = $USER->id; $qdata->modifiedby = $USER->id; $qdata->qtype = 'description'; $qdata->name = 'Description'; $qdata->questiontext = 'Here is some information about the questions you are about to attempt.'; $qdata->questiontextformat = FORMAT_HTML; $qdata->generalfeedback = 'And here is some more text shown only on the review page.'; $qdata->generalfeedbackformat = FORMAT_HTML; $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->hints = array(); $qdata->options = new stdClass(); $qdata->options->answers = array(); return $qdata; } /** * Get the question form data. * @return object */ public static function get_description_question_form_data_info() { $form = new stdClass(); $form->name = 'Description'; $form->questiontext = array('text' => 'Here is some information about the questions you are about to attempt.', 'format' => FORMAT_HTML); $form->generalfeedback = array('text' => 'And here is some more text shown only on the review page.', 'format' => FORMAT_HTML); $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } } PK\?d[RR8type/description/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_description; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of description question attempts. * * @package qtype_description * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_description_deferredfeedback_history80(): void { $quiz = (object) array( 'id' => '278', 'course' => '2950', 'name' => 'test quiz 1', 'intro' => 'my demonstration quiz ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71727591', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '5', 'grade' => '10', 'timecreated' => '0', 'timemodified' => '1178101987', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '6802', 'uniqueid' => '6802', 'quiz' => '278', 'userid' => '13', 'attempt' => '1', 'sumgrades' => '2.33333', 'timestart' => '1185289572', 'timefinish' => '1185289637', 'timemodified' => '1185289590', 'layout' => '4940,0,5043,0,4945,0,4942,0,5566,0', 'preview' => '1', ); $question = (object) array( 'id' => '4940', 'category' => '247', 'parent' => '0', 'name' => 'Northampton Gallery Case Study', 'questiontext' => 'The following questions are based on the Northampton Art Gallery case study and associated web links. The questions cover artists and works that were available during the study weeks for Block 3. Some items may no longer be available on websites but will be available in the readings for Block 3.', 'questiontextformat' => '1', 'defaultmark' => '0', 'penalty' => '0', 'qtype' => 'description', 'length' => '1', 'stamp' => 'learn.open.ac.uk+070501114616+rtsfKk', 'version' => 'learn.open.ac.uk+070501114616+ZY94d5', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '1', ); $qsession = (object) array( 'id' => '130459', 'attemptid' => '6802', 'questionid' => '4940', 'newest' => '297740', 'newgraded' => '297730', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 297730 => (object) array( 'attempt' => '6802', 'question' => '4940', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1185289572', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 297730, ), 297740 => (object) array( 'attempt' => '6802', 'question' => '4940', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '', 'timestamp' => '1185289572', 'event' => '8', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 297740, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'informationitem', 'questionid' => 4940, 'variant' => 1, 'maxmark' => 1, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'The following questions are based on the Northampton Art Gallery case study and associated web links. The questions cover artists and works that were available during the study weeks for Block 3. Some items may no longer be available on websites but will be available in the readings for Block 3.', 'rightanswer' => '', 'responsesummary' => '', 'timemodified' => 1185289572, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1185289572, 'userid' => 13, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'finished', 'fraction' => null, 'timecreated' => 1185289572, 'userid' => 13, 'data' => array('-finish' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_description_deferredfeedback_history70(): void { $quiz = (object) array( 'id' => '442', 'course' => '2591', 'name' => 'Needs Analysis (online version)', 'intro' => '

    Information

    The purpose of the Needs Analysis is to help you identify your professional development needs and those programme modules which will best address them.

    This Needs Analysis will form the basis of a discussion with your mentor from which will emerge your Professional Development Plan (PDP). Identifying professional development needs is an integral part of the process of professional development. The PDP and the subsequent professional development opportunities it identifies will be effective only if the Needs Analysis is completed fully and accurately. You will get much more out of the programme if you give plenty of time at this early stage to the Needs Analysis.

    1. Personal and Professional Information

    In addition to personal information you will be asked to outline any professional qualifications and experience to date.

    2. Professional and Musical Skills

    In this section you will be asked to record your skills, knowledge and experience against a range of professional activities which closely relate to the focus of the music CPD programme. You will be asked to provide brief examples of evidence of those areas in which you have significant experience.

    3. Issues in Music Teaching and Learning

    This section is organised under three areas: Learning Musically, Teaching Musically and Making Music.

    In this section we ask you to audit your knowledge, skills and understanding against the key issues covered by the online and face-to-face module units. You will be invited to identify your strengths and provide evidence of your experience and understanding.

    ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '1', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71719269', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '0', 'grade' => '10', 'timecreated' => '0', 'timemodified' => '1202212400', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '11230', 'uniqueid' => '11230', 'quiz' => '442', 'userid' => '193184', 'attempt' => '1', 'sumgrades' => '0', 'timestart' => '1192648793', 'timefinish' => '1192742609', 'timemodified' => '1192742300', 'layout' => '8492,0,8487,8488,8489,8490,0,8441,0,8443,0,8486,0,8444,0,8445,0,8494,0,8446,8429,0,8447,8430,0,8448,8431,0,8449,8432,0,8450,8433,0,8451,8434,0,8452,8435,0,8453,8436,0,8454,8437,0,8493,0,8455,8456,8457,0,8458,8459,8460,0,8461,8462,8463,8438,0,8464,8465,8466,0,8467,8468,8469,0,8470,8471,8472,8439,0,8473,8440,0,8474,8475,8476,0,8477,8478,8479,0,8480,8481,8482,0,8483,8484,8485,8442,0', 'preview' => '0', ); $question = (object) array( 'id' => '8492', 'category' => '131', 'parent' => '0', 'name' => 'Personal and Professional Information', 'questiontext' => '

    Personal and Professional Information


    Here we want you to enter personal information and outline your professional qualifications and experience to date.', 'questiontextformat' => '1', 'defaultmark' => '0', 'penalty' => '0', 'qtype' => 'description', 'length' => '0', 'stamp' => 'learn.open.ac.uk+070808083925+qTmPpB', 'version' => 'learn.open.ac.uk+070907143809+3ltY7I', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '0', ); $qsession = (object) array( 'id' => '206424', 'attemptid' => '11230', 'questionid' => '8492', 'newest' => '480877', 'newgraded' => '476039', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 476039 => (object) array( 'attempt' => '11230', 'question' => '8492', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1192648793', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 476039, ), 480877 => (object) array( 'attempt' => '11230', 'question' => '8492', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '', 'timestamp' => '1192648793', 'event' => '7', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 480877, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'informationitem', 'questionid' => 8492, 'variant' => 1, 'maxmark' => 0, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "PERSONAL AND PROFESSIONAL INFORMATION\n\nHere we want you to enter personal information and outline your professional qualifications and experience to date.", 'rightanswer' => '', 'responsesummary' => '', 'timemodified' => 1192648793, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1192648793, 'userid' => 193184, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'finished', 'fraction' => null, 'timecreated' => 1192648793, 'userid' => 193184, 'data' => array('-finish' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_description_deferredfeedback_history0(): void { $quiz = (object) array( 'id' => '466', 'course' => '3464', 'name' => 'Music CPD Needs Analysis', 'intro' => '

    Information

    The purpose of the Needs Analysis is to help you identify your professional development needs and those programme modules which will best address them.

    This Needs Analysis will form the basis of a discussion with your mentor from which will emerge your Professional Development Plan (PDP). Identifying professional development needs is an integral part of the process of professional development. The PDP and the subsequent professional development opportunities it identifies will be effective only if the Needs Analysis is completed fully and accurately. You will get much more out of the programme if you give plenty of time at this early stage to the Needs Analysis.

    1. Personal and Professional Information

    In addition to personal information you will be asked to outline any professional qualifications and experience to date.

    2. Professional and Musical Skills

    In this section you will be asked to record your skills, knowledge and experience against a range of professional activities which closely relate to the focus of the music CPD programme. You will be asked to provide brief examples of evidence of those areas in which you have significant experience.

    3. Issues in Music Teaching and Learning

    This section is organised under three areas: Learning Musically, Teaching Musically and Making Music.

    In this section we ask you to audit your knowledge, skills and understanding against the key issues covered by the online and face-to-face module units. You will be invited to identify your strengths and provide evidence of your experience and understanding.

    ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '1', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71719269', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '0', 'grade' => '10', 'timecreated' => '0', 'timemodified' => '1184685800', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '7401', 'uniqueid' => '7401', 'quiz' => '466', 'userid' => '24474', 'attempt' => '1', 'sumgrades' => '0', 'timestart' => '1187168654', 'timefinish' => '0', 'timemodified' => '1187168769', 'layout' => '8691,0,8690,8692,8693,8694,0,8695,0,8696,0,8697,0,8698,0,8699,0,8700,0,8701,8702,0,8703,8704,0,8705,8706,0,8707,8708,0,8709,8710,0,8711,8712,0,8713,8714,0,8715,8716,0,8717,8718,0,8719,0,8720,8721,8722,0,8723,8724,8725,0,8726,8727,8728,8729,0,8730,8731,8732,0,8733,8734,8735,0,8736,8737,8738,8739,0,8740,8741,0,8742,8743,8744,0,8745,8746,8747,0,8748,8749,8750,0,8752,8751,8753,8754,0', 'preview' => '1', ); $question = (object) array( 'id' => '8719', 'category' => '427', 'parent' => '0', 'name' => 'Music Teaching and Learning', 'questiontext' => '

    Music Teaching and Learning


    In this section we ask you to audit your knowledge, skills and understanding against the key issues covered by the non-core online and face-to-face module units. You will be invited to identify your strengths and provide evidence of your experience and understanding.

    In this set of questions, you should select from the drop-down menu ‘None’, ‘Some’, ‘Good’ or ‘Strong’.

    ', 'questiontextformat' => '1', 'defaultmark' => '0', 'penalty' => '0', 'qtype' => 'description', 'length' => '0', 'stamp' => 'learn.open.ac.uk+070430145701+r8LVld', 'version' => 'learn.open.ac.uk+070430145834+FxIAjw', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '0', ); $qsession = (object) array( 'id' => '157658', 'attemptid' => '7401', 'questionid' => '8719', 'newest' => '361166', 'newgraded' => '361166', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 361166 => (object) array( 'attempt' => '7401', 'question' => '8719', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1187168654', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 361166, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'informationitem', 'questionid' => 8719, 'variant' => 1, 'maxmark' => 0, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "MUSIC TEACHING AND LEARNING \n\nIn this section we ask you to audit your knowledge, skills and understanding against the key issues covered by the non-core online and face-to-face module units. You will be invited to identify your strengths and provide evidence of your experience and understanding.\n\nIn this set of questions, you should select from the drop-down menu ‘None’, ‘Some’, ‘Good’ or ‘Strong’.", 'rightanswer' => '', 'responsesummary' => '', 'timemodified' => 1187168654, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1187168654, 'userid' => 24474, 'data' => array(), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\dd(type/description/tests/behat/add.featurenu[@qtype @qtype_description Feature: Test creating a Description question As a teacher In order to test my students I need to be able to create a Description question Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | Scenario: Create a Description question with Correct answer as False When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Description" question filling the form with: | Question name | description-001 | | Question text | Instructions about the following questions. | | General feedback | Why actually the field 'General feedback' used in this qytype? | Then I should see "description-001" PK\u:??)type/description/tests/behat/edit.featurenu[@qtype @qtype_description Feature: Test editing a Description question As a teacher In order to be able to update my Description question I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | description | description-001 | info | Scenario: Edit a Description question When I am on the "description-001" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | | And I press "id_submitbutton" Then I should see "You must supply a value here." When I set the following fields to these values: | Question name | Edited description-001 name | And I press "id_submitbutton" Then I should see "Edited description-001 name" PK\::+type/description/tests/behat/export.featurenu[@qtype @qtype_description Feature: Test exporting Description questions As a teacher In order to be able to reuse my Description questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | description | description-001 | info | Scenario: Export a Description question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | description-001 | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\,L^^+type/description/tests/behat/import.featurenu[@qtype @qtype_description Feature: Test importing Description questions As a teacher In order to reuse Description questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import a Description question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/description/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "Instructions about the following questions." And I press "Continue" And I should see "description-001" PK\/z7type/description/tests/behat/backup_and_restore.featurenu[@qtype @qtype_description Feature: Test duplicating a quiz containing a Description question As a teacher In order re-use my courses containing Description questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | description | description-001 | info | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | description-001 | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing a Description question When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "description-001" in the question bank Then the following fields match these values: | Question name | description-001 | | Question text | Here is some information about the questions you are about to attempt. | | General feedback | And here is some more text shown only on the review page. | PK\Q^H,type/description/tests/behat/preview.featurenu[@qtype @qtype_description Feature: Preview a Description question As a teacher In order to check my Description questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | description | description-001 | info | @javascript @_switch_window Scenario: Preview a Description question and submit a correct response. When I am on the "description-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I should see "Here is some information about the questions you are about to attempt." PK\>h T!type/description/questiontype.phpnu[. /** * Question type class for the description 'question' type. * * @package qtype * @subpackage description * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); /** * The description 'question' type. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description extends question_type { public function is_real_question_type() { return false; } public function is_usable_by_random() { return false; } public function can_analyse_responses() { return false; } public function save_question($question, $form) { // Make very sure that descriptions can'e be created with a grade of // anything other than 0. $form->defaultmark = 0; return parent::save_question($question, $form); } public function actual_number_of_questions($question) { // Used for the feature number-of-questions-per-page // to determine the actual number of questions wrapped by this question. // The question type description is not even a question // in itself so it will return ZERO! return 0; } public function get_random_guess_score($questiondata) { return null; } } PK\6@type/questionbase.phpnu[. /** * This file defines the class {@link question_definition} and its subclasses. * * The type hierarchy is quite complex. Here is a summary: * - question_definition * - question_information_item * - question_with_responses implements question_manually_gradable * - question_graded_automatically implements question_automatically_gradable * - question_graded_automatically_with_countback implements question_automatically_gradable_with_countback * - question_graded_by_strategy * * Other classes: * - question_classified_response * - question_answer * - question_hint * - question_hint_with_parts * - question_first_matching_answer_grading_strategy implements question_grading_strategy * * Other interfaces: * - question_response_answer_comparer * * @package moodlecore * @subpackage questiontypes * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_question\output\question_version_info; defined('MOODLE_INTERNAL') || die(); /** * The definition of a question of a particular type. * * This class is a close match to the question table in the database. * Definitions of question of a particular type normally subclass one of the * more specific classes {@link question_with_responses}, * {@link question_graded_automatically} or {@link question_information_item}. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_definition { /** @var integer id of the question in the datase, or null if this question * is not in the database. */ public $id; /** @var integer question category id. */ public $category; /** @var integer question context id. */ public $contextid; /** @var integer parent question id. */ public $parent = 0; /** @var question_type the question type this question is. */ public $qtype; /** @var string question name. */ public $name; /** @var string question text. */ public $questiontext; /** @var integer question test format. */ public $questiontextformat; /** @var string question general feedback. */ public $generalfeedback; /** @var integer question test format. */ public $generalfeedbackformat; /** @var float what this quetsion is marked out of, by default. */ public $defaultmark = 1; /** @var integer How many question numbers this question consumes. */ public $length = 1; /** @var number penalty factor of this question. */ public $penalty = 0; /** @var string unique identifier of this question. */ public $stamp; /** @var string question idnumber. */ public $idnumber; /** @var integer timestamp when this question was created. */ public $timecreated; /** @var integer timestamp when this question was modified. */ public $timemodified; /** @var integer userid of the use who created this question. */ public $createdby; /** @var integer userid of the use who modified this question. */ public $modifiedby; /** @var array of question_hints. */ public $hints = array(); /** @var boolean question status hidden/ready/draft in the question bank. */ public $status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; /** @var int Version id of the question in a question bank */ public $versionid; /** @var int Version number of the question in a question bank */ public $version; /** @var int Bank entry id for the question */ public $questionbankentryid; /** @var ?int The latest version of the question. null if we haven't checked yet. */ protected $latestversion = null; /** * @var array of array of \core_customfield\data_controller objects indexed by fieldid for the questions custom fields. */ public $customfields = array(); /** * Constructor. Normally to get a question, you call * {@link question_bank::load_question()}, but questions can be created * directly, for example in unit test code. */ public function __construct() { } /** * When a pending definition tries to read its latest version, fill in the latest version for all pending definitions * * @param string $name * @return mixed */ public function __get($name) { if ($name === 'latestversion') { if (isset(question_version_info::$pendingdefinitions[$this->id])) { question_version_info::populate_latest_versions(); } return $this->latestversion; } } /** * @return string the name of the question type (for example multichoice) that this * question is. */ public function get_type_name() { return $this->qtype->name(); } /** * Creat the appropriate behaviour for an attempt at this quetsion, * given the desired (archetypal) behaviour. * * This default implementation will suit most normal graded questions. * * If your question is of a patricular type, then it may need to do something * different. For example, if your question can only be graded manually, then * it should probably return a manualgraded behaviour, irrespective of * what is asked for. * * If your question wants to do somthing especially complicated is some situations, * then you may wish to return a particular behaviour related to the * one asked for. For example, you migth want to return a * qbehaviour_interactive_adapted_for_myqtype. * * @param question_attempt $qa the attempt we are creating a behaviour for. * @param string $preferredbehaviour the requested type of behaviour. * @return question_behaviour the new behaviour object. */ public function make_behaviour(question_attempt $qa, $preferredbehaviour) { return question_engine::make_archetypal_behaviour($preferredbehaviour, $qa); } /** * Start a new attempt at this question, storing any information that will * be needed later in the step. * * This is where the question can do any initialisation required on a * per-attempt basis. For example, this is where the multiple choice * question type randomly shuffles the choices (if that option is set). * * Any information about how the question has been set up for this attempt * should be stored in the $step, by calling $step->set_qt_var(...). * * @param question_attempt_step The first step of the {@link question_attempt} * being started. Can be used to store state. * @param int $varant which variant of this question to start. Will be between * 1 and {@link get_num_variants()} inclusive. */ public function start_attempt(question_attempt_step $step, $variant) { } /** * When an in-progress {@link question_attempt} is re-loaded from the * database, this method is called so that the question can re-initialise * its internal state as needed by this attempt. * * For example, the multiple choice question type needs to set the order * of the choices to the order that was set up when start_attempt was called * originally. All the information required to do this should be in the * $step object, which is the first step of the question_attempt being loaded. * * @param question_attempt_step The first step of the {@link question_attempt} * being loaded. */ public function apply_attempt_state(question_attempt_step $step) { } /** * Verify if an attempt at this question can be re-graded using the other question version. * * To put it another way, will {@see update_attempt_state_date_from_old_version()} be able to work? * * It is expected that this relationship is symmetrical, so if you can regrade from V1 to V3, then * you can change back from V3 to V1. * * @param question_definition $otherversion a different version of the question to use in the regrade. * @return string|null null if the regrade can proceed, else a reason why not. */ public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string { if (get_class($otherversion) !== get_class($this)) { return get_string('cannotregradedifferentqtype', 'question'); } return null; } /** * Update the data representing the initial state of an attempt another version of this question, to allow for the changes. * * What is required is probably most easily understood using an example. Think about multiple choice questions. * The first step has a variable '_order' which is a comma-separated list of question_answer ids. * A different version of the question will have different question_answers with different ids. However, the list of * choices should be similar, and so we need to shuffle the new list of ids in the same way that the old one was. * * Note: be sure to return all the data that was originally in $oldstep, while updating the fields that * require it. Otherwise you might break features like 'Each attempt builds on last' in the quiz. * * This method should only be called if {@see validate_can_regrade_with_other_version()} did not * flag up a potential problem. So, this method will throw a {@see coding_exception} if it is not * possible to work out a return value. * * @param question_attempt_step $oldstep the first step of a {@see question_attempt} at $oldquestion. * @param question_definition $oldquestion the previous version of the question, which $oldstate comes from. * @return array the submit data which can be passed to {@see apply_attempt_state} to start * an attempt at this version of this question, corresponding to the attempt at the old question. * @throws coding_exception if this can't be done. */ public function update_attempt_state_data_for_new_version( question_attempt_step $oldstep, question_definition $oldquestion) { $message = $this->validate_can_regrade_with_other_version($oldquestion); if ($message) { throw new coding_exception($message); } return $oldstep->get_qt_data(); } /** * Generate a brief, plain-text, summary of this question. This is used by * various reports. This should show the particular variant of the question * as presented to students. For example, the calculated quetsion type would * fill in the particular numbers that were presented to the student. * This method will return null if such a summary is not possible, or * inappropriate. * @return string|null a plain text summary of this question. */ public function get_question_summary() { return $this->html_to_text($this->questiontext, $this->questiontextformat); } /** * @return int the number of vaiants that this question has. */ public function get_num_variants() { return 1; } /** * @return string that can be used to seed the pseudo-random selection of a * variant. */ public function get_variants_selection_seed() { return $this->stamp; } /** * Some questions can return a negative mark if the student gets it wrong. * * This method returns the lowest mark the question can return, on the * fraction scale. that is, where the maximum possible mark is 1.0. * * @return float minimum fraction this question will ever return. */ public function get_min_fraction() { return 0; } /** * Some questions can return a mark greater than the maximum. * * This method returns the lowest highest the question can return, on the * fraction scale. that is, where the nominal maximum mark is 1.0. * * @return float maximum fraction this question will ever return. */ public function get_max_fraction() { return 1; } /** * Given a response, rest the parts that are wrong. * @param array $response a response * @return array a cleaned up response with the wrong bits reset. */ public function clear_wrong_from_response(array $response) { return array(); } /** * Return the number of subparts of this response that are right. * @param array $response a response * @return array with two elements, the number of correct subparts, and * the total number of subparts. */ public function get_num_parts_right(array $response) { return array(null, null); } /** * @param moodle_page the page we are outputting to. * @return qtype_renderer the renderer to use for outputting this question. */ public function get_renderer(moodle_page $page) { return $page->get_renderer($this->qtype->plugin_name()); } /** * What data may be included in the form submission when a student submits * this question in its current state? * * This information is used in calls to optional_param. The parameter name * has {@link question_attempt::get_field_prefix()} automatically prepended. * * @return array|string variable name => PARAM_... constant, or, as a special case * that should only be used in unavoidable, the constant question_attempt::USE_RAW_DATA * meaning take all the raw submitted data belonging to this question. */ abstract public function get_expected_data(); /** * What data would need to be submitted to get this question correct. * If there is more than one correct answer, this method should just * return one possibility. If it is not possible to compute a correct * response, this method should return null. * * @return array|null parameter name => value. */ abstract public function get_correct_response(); /** * Takes an array of values representing a student response represented in a way that is understandable by a human and * transforms that to the response as the POST values returned from the HTML form that takes the student response during a * student attempt. Primarily this is used when reading csv values from a file of student responses in order to be able to * simulate the student interaction with a quiz. * * In most cases the array will just be returned as is. Some question types will need to transform the keys of the array, * as the meaning of the keys in the html form is deliberately obfuscated so that someone looking at the html does not get an * advantage. The values that represent the response might also be changed in order to more meaningful to a human. * * See the examples of question types that have overridden this in core and also see the csv files of simulated student * responses used in unit tests in : * - mod/quiz/tests/fixtures/stepsXX.csv * - mod/quiz/report/responses/tests/fixtures/steps00.csv * - mod/quiz/report/statistics/tests/fixtures/stepsXX.csv * * Also see {@link https://github.com/jamiepratt/moodle-quiz_simulate}, a quiz report plug in for uploading and downloading * student responses as csv files. * * @param array $simulatedresponse an array of data representing a student response * @return array a response array as would be returned from the html form (but without prefixes) */ public function prepare_simulated_post_data($simulatedresponse) { return $simulatedresponse; } /** * Does the opposite of {@link prepare_simulated_post_data}. * * This takes a student response (the POST values returned from the HTML form that takes the student response during a * student attempt) it then represents it in a way that is understandable by a human. * * Primarily this is used when creating a file of csv from real student responses in order later to be able to * simulate the same student interaction with a quiz later. * * @param string[] $realresponse the response array as was returned from the form during a student attempt (without prefixes). * @return string[] an array of data representing a student response. */ public function get_student_response_values_for_simulation($realresponse) { return $realresponse; } /** * Apply {@link format_text()} to some content with appropriate settings for * this question. * * @param string $text some content that needs to be output. * @param int $format the FORMAT_... constant. * @param question_attempt $qa the question attempt. * @param string $component used for rewriting file area URLs. * @param string $filearea used for rewriting file area URLs. * @param bool $clean Whether the HTML needs to be cleaned. Generally, * parts of the question do not need to be cleaned, and student input does. * @return string the text formatted for output by format_text. */ public function format_text($text, $format, $qa, $component, $filearea, $itemid, $clean = false) { $formatoptions = new stdClass(); $formatoptions->noclean = !$clean; $formatoptions->para = false; $text = $qa->rewrite_pluginfile_urls($text, $component, $filearea, $itemid); return format_text($text, $format, $formatoptions); } /** * Convert some part of the question text to plain text. This might be used, * for example, by get_response_summary(). * @param string $text The HTML to reduce to plain text. * @param int $format the FORMAT_... constant. * @return string the equivalent plain text. */ public function html_to_text($text, $format) { return question_utils::to_plain_text($text, $format); } /** @return the result of applying {@link format_text()} to the question text. */ public function format_questiontext($qa) { $formattext = $this->format_text( $this->questiontext, $this->questiontextformat, $qa, 'question', 'questiontext', $this->id ); return html_writer::nonempty_tag('div', $formattext, ['class' => 'clearfix']); } /** @return the result of applying {@link format_text()} to the general feedback. */ public function format_generalfeedback($qa) { $formattext = $this->format_text( $this->generalfeedback, $this->generalfeedbackformat, $qa, 'question', 'generalfeedback', $this->id ); return html_writer::nonempty_tag('div', $formattext, ['class' => 'clearfix']); } /** * Take some HTML that should probably already be a single line, like a * multiple choice choice, or the corresponding feedback, and make it so that * it is suitable to go in a place where the HTML must be inline, like inside a

    tag. * @param string $html to HTML to fix up. * @return string the fixed HTML. */ public function make_html_inline($html) { $html = preg_replace('~\s*

    \s*~u', '', $html); $html = preg_replace('~\s*

    \s*~u', '
    ', $html); $html = preg_replace('~()+$~u', '', $html); return trim($html); } /** * Checks whether the users is allow to be served a particular file. * @param question_attempt $qa the question attempt being displayed. * @param question_display_options $options the options that control display of the question. * @param string $component the name of the component we are serving files for. * @param string $filearea the name of the file area. * @param array $args the remaining bits of the file path. * @param bool $forcedownload whether the user must be forced to download the file. * @return bool true if the user can access this file. */ public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($component == 'question' && $filearea == 'questiontext') { // Question text always visible, but check it is the right question id. return $args[0] == $this->id; } else if ($component == 'question' && $filearea == 'generalfeedback') { return $options->generalfeedback && $args[0] == $this->id; } else { // Unrecognised component or filearea. return false; } } /** * Return the question settings that define this question as structured data. * * This is used by external systems such as the Moodle mobile app, which want to display the question themselves, * rather than using the renderer provided. * * This method should only return the data that the student is allowed to see or know, given the current state of * the question. For example, do not include the 'General feedback' until the student has completed the question, * and even then, only include it if the question_display_options say it should be visible. * * But, within those rules, it is recommended that you return all the settings for the question, * to give maximum flexibility to the external system providing its own rendering of the question. * * @param question_attempt $qa the current attempt for which we are exporting the settings. * @param question_display_options $options the question display options which say which aspects of the question * should be visible. * @return mixed structure representing the question settings. In web services, this will be JSON-encoded. */ public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) { debugging('This question does not implement the get_question_definition_for_external_rendering() method yet.', DEBUG_DEVELOPER); return null; } /** * Set the latest version. * * Making $this->latestversion public would break the magic __get() behaviour above, so allow it to be set externally. * * @param int $latestversion * @return void */ public function set_latest_version(int $latestversion): void { $this->latestversion = $latestversion; } } /** * This class represents a 'question' that actually does not allow the student * to respond, like the description 'question' type. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_information_item extends question_definition { public function __construct() { parent::__construct(); $this->defaultmark = 0; $this->penalty = 0; $this->length = 0; } public function make_behaviour(question_attempt $qa, $preferredbehaviour) { return question_engine::make_behaviour('informationitem', $qa, $preferredbehaviour); } public function get_expected_data() { return array(); } public function get_correct_response() { return array(); } public function get_question_summary() { return null; } } /** * Interface that a {@link question_definition} must implement to be usable by * the manual graded behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface question_manually_gradable { /** * Use by many of the behaviours to determine whether the student * has provided enough of an answer for the question to be graded automatically, * or whether it must be considered aborted. * * @param array $response responses, as returned by * {@link question_attempt_step::get_qt_data()}. * @return bool whether this response can be graded. */ public function is_gradable_response(array $response); /** * Used by many of the behaviours, to work out whether the student's * response to the question is complete. That is, whether the question attempt * should move to the COMPLETE or INCOMPLETE state. * * @param array $response responses, as returned by * {@link question_attempt_step::get_qt_data()}. * @return bool whether this response is a complete answer to this question. */ public function is_complete_response(array $response); /** * Use by many of the behaviours to determine whether the student's * response has changed. This is normally used to determine that a new set * of responses can safely be discarded. * * @param array $prevresponse the responses previously recorded for this question, * as returned by {@link question_attempt_step::get_qt_data()} * @param array $newresponse the new responses, in the same format. * @return bool whether the two sets of responses are the same - that is * whether the new set of responses can safely be discarded. */ public function is_same_response(array $prevresponse, array $newresponse); /** * Produce a plain text summary of a response. * @param array $response a response, as might be passed to {@link grade_response()}. * @return string a plain text summary of that response, that could be used in reports. */ public function summarise_response(array $response); /** * If possible, construct a response that could have lead to the given * response summary. This is basically the opposite of {@link summarise_response()} * but it is intended only to be used for testing. * * @param string $summary a string, which might have come from summarise_response * @return array a response that could have lead to that. */ public function un_summarise_response(string $summary); /** * Categorise the student's response according to the categories defined by * get_possible_responses. * @param $response a response, as might be passed to {@link grade_response()}. * @return array subpartid => {@link question_classified_response} objects. * returns an empty array if no analysis is possible. */ public function classify_response(array $response); } /** * This class is used in the return value from * {@link question_manually_gradable::classify_response()}. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_classified_response { /** * @var string the classification of this response the student gave to this * part of the question. Must match one of the responseclasses returned by * {@link question_type::get_possible_responses()}. */ public $responseclassid; /** @var string the actual response the student gave to this part. */ public $response; /** @var number the fraction this part of the response earned. */ public $fraction; /** * Constructor, just an easy way to set the fields. * @param string $responseclassid see the field descriptions above. * @param string $response see the field descriptions above. * @param number $fraction see the field descriptions above. */ public function __construct($responseclassid, $response, $fraction) { $this->responseclassid = $responseclassid; $this->response = $response; $this->fraction = $fraction; } public static function no_response() { return new question_classified_response(null, get_string('noresponse', 'question'), null); } } /** * Interface that a {@link question_definition} must implement to be usable by * the various automatic grading behaviours. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface question_automatically_gradable extends question_manually_gradable { /** * In situations where is_gradable_response() returns false, this method * should generate a description of what the problem is. * @return string the message. */ public function get_validation_error(array $response); /** * Grade a response to the question, returning a fraction between * get_min_fraction() and get_max_fraction(), and the corresponding {@link question_state} * right, partial or wrong. * @param array $response responses, as returned by * {@link question_attempt_step::get_qt_data()}. * @return array (float, integer) the fraction, and the state. */ public function grade_response(array $response); /** * Get one of the question hints. The question_attempt is passed in case * the question type wants to do something complex. For example, the * multiple choice with multiple responses question type will turn off most * of the hint options if the student has selected too many opitions. * @param int $hintnumber Which hint to display. Indexed starting from 0 * @param question_attempt $qa The question_attempt. */ public function get_hint($hintnumber, question_attempt $qa); /** * Generate a brief, plain-text, summary of the correct answer to this question. * This is used by various reports, and can also be useful when testing. * This method will return null if such a summary is not possible, or * inappropriate. * @return string|null a plain text summary of the right answer to this question. */ public function get_right_answer_summary(); } /** * Interface that a {@link question_definition} must implement to be usable by * the interactivecountback behaviour. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface question_automatically_gradable_with_countback extends question_automatically_gradable { /** * Work out a final grade for this attempt, taking into account all the * tries the student made. * @param array $responses the response for each try. Each element of this * array is a response array, as would be passed to {@link grade_response()}. * There may be between 1 and $totaltries responses. * @param int $totaltries The maximum number of tries allowed. * @return numeric the fraction that should be awarded for this * sequence of response. */ public function compute_final_grade($responses, $totaltries); } /** * This class represents a real question. That is, one that is not a * {@link question_information_item}. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_with_responses extends question_definition implements question_manually_gradable { public function classify_response(array $response) { return array(); } public function is_gradable_response(array $response) { return $this->is_complete_response($response); } public function un_summarise_response(string $summary) { throw new coding_exception('This question type (' . get_class($this) . ' does not implement the un_summarise_response testing method.'); } } /** * This class represents a question that can be graded automatically. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_graded_automatically extends question_with_responses implements question_automatically_gradable { /** @var Some question types have the option to show the number of sub-parts correct. */ public $shownumcorrect = false; public function get_right_answer_summary() { $correctresponse = $this->get_correct_response(); if (empty($correctresponse)) { return null; } return $this->summarise_response($correctresponse); } /** * Check a request for access to a file belonging to a combined feedback field. * @param question_attempt $qa the question attempt being displayed. * @param question_display_options $options the options that control display of the question. * @param string $filearea the name of the file area. * @param array $args the remaining bits of the file path. * @return bool whether access to the file should be allowed. */ protected function check_combined_feedback_file_access($qa, $options, $filearea, $args = null) { $state = $qa->get_state(); if ($args === null) { debugging('You must pass $args as the fourth argument to check_combined_feedback_file_access.', DEBUG_DEVELOPER); $args = array($this->id); // Fake it for now, so the rest of this method works. } if (!$state->is_finished()) { $response = $qa->get_last_qt_data(); if (!$this->is_gradable_response($response)) { return false; } list($notused, $state) = $this->grade_response($response); } return $options->feedback && $state->get_feedback_class() . 'feedback' == $filearea && $args[0] == $this->id; } /** * Check a request for access to a file belonging to a hint. * @param question_attempt $qa the question attempt being displayed. * @param question_display_options $options the options that control display of the question. * @param array $args the remaining bits of the file path. * @return bool whether access to the file should be allowed. */ protected function check_hint_file_access($qa, $options, $args) { if (!$options->feedback) { return false; } $hint = $qa->get_applicable_hint(); // If there is no applicable hint, that means access should not be granted. if (is_null($hint)) { return false; } $hintid = reset($args); // Itemid is hint id. return $hintid == $hint->id; } public function get_hint($hintnumber, question_attempt $qa) { if (!isset($this->hints[$hintnumber])) { return null; } return $this->hints[$hintnumber]; } public function format_hint(question_hint $hint, question_attempt $qa) { return $this->format_text($hint->hint, $hint->hintformat, $qa, 'question', 'hint', $hint->id); } } /** * This class represents a question that can be graded automatically with * countback grading in interactive mode. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_graded_automatically_with_countback extends question_graded_automatically implements question_automatically_gradable_with_countback { public function make_behaviour(question_attempt $qa, $preferredbehaviour) { if ($preferredbehaviour == 'interactive') { return question_engine::make_behaviour('interactivecountback', $qa, $preferredbehaviour); } return question_engine::make_archetypal_behaviour($preferredbehaviour, $qa); } } /** * This class represents a question that can be graded automatically by using * a {@link question_grading_strategy}. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_graded_by_strategy extends question_graded_automatically { /** @var question_grading_strategy the strategy to use for grading. */ protected $gradingstrategy; /** @param question_grading_strategy $strategy the strategy to use for grading. */ public function __construct(question_grading_strategy $strategy) { parent::__construct(); $this->gradingstrategy = $strategy; } public function get_correct_response() { $answer = $this->get_correct_answer(); if (!$answer) { return array(); } return array('answer' => $answer->answer); } /** * Get an answer that contains the feedback and fraction that should be * awarded for this resonse. * @param array $response a response. * @return question_answer the matching answer. */ public function get_matching_answer(array $response) { return $this->gradingstrategy->grade($response); } /** * @return question_answer an answer that contains the a response that would * get full marks. */ public function get_correct_answer() { return $this->gradingstrategy->get_correct_answer(); } public function grade_response(array $response) { $answer = $this->get_matching_answer($response); if ($answer) { return array($answer->fraction, question_state::graded_state_for_fraction($answer->fraction)); } else { return array(0, question_state::$gradedwrong); } } public function classify_response(array $response) { if (empty($response['answer'])) { return array($this->id => question_classified_response::no_response()); } $ans = $this->get_matching_answer($response); if (!$ans) { return array($this->id => new question_classified_response( 0, $response['answer'], 0)); } return array($this->id => new question_classified_response( $ans->id, $response['answer'], $ans->fraction)); } } /** * Class to represent a question answer, loaded from the question_answers table * in the database. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_answer { /** @var integer the answer id. */ public $id; /** @var string the answer. */ public $answer; /** @var integer one of the FORMAT_... constans. */ public $answerformat = FORMAT_PLAIN; /** @var number the fraction this answer is worth. */ public $fraction; /** @var string the feedback for this answer. */ public $feedback; /** @var integer one of the FORMAT_... constans. */ public $feedbackformat; /** * Constructor. * @param int $id the answer. * @param string $answer the answer. * @param number $fraction the fraction this answer is worth. * @param string $feedback the feedback for this answer. * @param int $feedbackformat the format of the feedback. */ public function __construct($id, $answer, $fraction, $feedback, $feedbackformat) { $this->id = $id; $this->answer = $answer; $this->fraction = $fraction; $this->feedback = $feedback; $this->feedbackformat = $feedbackformat; } } /** * Class to represent a hint associated with a question. * Used by iteractive mode, etc. A question has an array of these. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_hint { /** @var integer The hint id. */ public $id; /** @var string The feedback hint to be shown. */ public $hint; /** @var integer The corresponding text FORMAT_... type. */ public $hintformat; /** * Constructor. * @param int the hint id from the database. * @param string $hint The hint text * @param int the corresponding text FORMAT_... type. */ public function __construct($id, $hint, $hintformat) { $this->id = $id; $this->hint = $hint; $this->hintformat = $hintformat; } /** * Create a basic hint from a row loaded from the question_hints table in the database. * @param object $row with $row->hint set. * @return question_hint */ public static function load_from_record($row) { return new question_hint($row->id, $row->hint, $row->hintformat); } /** * Adjust this display options according to the hint settings. * @param question_display_options $options */ public function adjust_display_options(question_display_options $options) { // Do nothing. } } /** * An extension of {@link question_hint} for questions like match and multiple * choice with multile answers, where there are options for whether to show the * number of parts right at each stage, and to reset the wrong parts. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_hint_with_parts extends question_hint { /** @var boolean option to show the number of sub-parts of the question that were right. */ public $shownumcorrect; /** @var boolean option to clear the parts of the question that were wrong on retry. */ public $clearwrong; /** * Constructor. * @param int the hint id from the database. * @param string $hint The hint text * @param int the corresponding text FORMAT_... type. * @param bool $shownumcorrect whether the number of right parts should be shown * @param bool $clearwrong whether the wrong parts should be reset. */ public function __construct($id, $hint, $hintformat, $shownumcorrect, $clearwrong) { parent::__construct($id, $hint, $hintformat); $this->shownumcorrect = $shownumcorrect; $this->clearwrong = $clearwrong; } /** * Create a basic hint from a row loaded from the question_hints table in the database. * @param object $row with $row->hint, ->shownumcorrect and ->clearwrong set. * @return question_hint_with_parts */ public static function load_from_record($row) { return new question_hint_with_parts($row->id, $row->hint, $row->hintformat, $row->shownumcorrect, $row->clearwrong); } public function adjust_display_options(question_display_options $options) { parent::adjust_display_options($options); if ($this->clearwrong) { $options->clearwrong = true; } $options->numpartscorrect = $this->shownumcorrect; } } /** * This question_grading_strategy interface. Used to share grading code between * questions that that subclass {@link question_graded_by_strategy}. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface question_grading_strategy { /** * Return a question answer that describes the outcome (fraction and feeback) * for a particular respons. * @param array $response the response. * @return question_answer the answer describing the outcome. */ public function grade(array $response); /** * @return question_answer an answer that contains the a response that would * get full marks. */ public function get_correct_answer(); } /** * This interface defines the methods that a {@link question_definition} must * implement if it is to be graded by the * {@link question_first_matching_answer_grading_strategy}. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface question_response_answer_comparer { /** @return array of {@link question_answers}. */ public function get_answers(); /** * @param array $response the response. * @param question_answer $answer an answer. * @return bool whether the response matches the answer. */ public function compare_response_with_answer(array $response, question_answer $answer); } /** * This grading strategy is used by question types like shortanswer an numerical. * It gets a list of possible answers from the question, and returns the first one * that matches the given response. It returns the first answer with fraction 1.0 * when asked for the correct answer. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_first_matching_answer_grading_strategy implements question_grading_strategy { /** * @var question_response_answer_comparer (presumably also a * {@link question_definition}) the question we are doing the grading for. */ protected $question; /** * @param question_response_answer_comparer $question (presumably also a * {@link question_definition}) the question we are doing the grading for. */ public function __construct(question_response_answer_comparer $question) { $this->question = $question; } public function grade(array $response) { foreach ($this->question->get_answers() as $aid => $answer) { if ($this->question->compare_response_with_answer($response, $answer)) { $answer->id = $aid; return $answer; } } return null; } public function get_correct_answer() { foreach ($this->question->get_answers() as $answer) { $state = question_state::graded_state_for_fraction($answer->fraction); if ($state == question_state::$gradedright) { return $answer; } } return null; } } PK\. /** * Drag-and-drop markers question renderer class. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/rendererbase.php'); require_once($CFG->dirroot . '/question/type/ddimageortext/rendererbase.php'); /** * Generates the output for drag-and-drop markers questions. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_renderer extends qtype_ddtoimage_renderer_base { public function formulation_and_controls(question_attempt $qa, question_display_options $options) { $question = $qa->get_question(); $response = $qa->get_last_qt_data(); $componentname = $question->qtype->plugin_name(); $questiontext = $question->format_questiontext($qa); $dropareaclass = 'droparea'; $draghomesclass = 'draghomes'; if ($options->readonly) { $dropareaclass .= ' readonly'; $draghomesclass .= ' readonly'; } $output = html_writer::div($questiontext, 'qtext'); $output .= html_writer::start_div('ddarea'); $output .= html_writer::start_div($dropareaclass); $output .= html_writer::img(self::get_url_for_image($qa, 'bgimage'), get_string('dropbackground', 'qtype_ddmarker'), ['class' => 'dropbackground img-fluid w-100']); $output .= html_writer::div('', 'dropzones'); $output .= html_writer::div('', 'markertexts'); $output .= html_writer::end_div(); $output .= html_writer::start_div($draghomesclass); $orderedgroup = $question->get_ordered_choices(1); $hiddenfields = ''; $dragitems = ''; foreach ($orderedgroup as $choiceno => $drag) { $classes = ['marker', 'user-select-none', 'choice' . $choiceno]; $attr = []; if ($drag->infinite) { $classes[] = 'infinite'; } else { $classes[] = 'dragno' . $drag->noofdrags; } if (!$options->readonly) { $attr['tabindex'] = 0; } $dragoutput = html_writer::start_span(join(' ', $classes), $attr); $targeticonhtml = $this->output->image_icon('crosshairs', '', $componentname, ['class' => 'target']); $markertext = html_writer::span(question_utils::format_question_fragment($drag->text, $this->page->context), 'markertext'); $dragoutput .= $targeticonhtml . $markertext; $dragoutput .= html_writer::end_span(); $dragitems .= $dragoutput; $hiddenfields .= $this->hidden_field_choice($qa, $choiceno, $drag->infinite, $drag->noofdrags); } $output .= $dragitems; $output .= html_writer::end_div(); // Add extra hidden drag items so we can make sure the filter will be applied. $output .= html_writer::div($dragitems, 'dd-original d-none'); $output .= html_writer::end_div(); if ($qa->get_state() == question_state::$invalid) { $output .= html_writer::div($question->get_validation_error($qa->get_last_qt_data()), 'validationerror'); } $visibledropzones = []; if ($question->showmisplaced && $qa->get_state()->is_finished()) { $visibledropzones = $question->get_drop_zones_without_hit($response); if (count($visibledropzones) !== 0) { $wrongpartsstringspans = []; foreach ($visibledropzones as $visibledropzone) { $visibledropzone->markertext = question_utils::format_question_fragment( $visibledropzone->markertext, $this->page->context); $wrongpartsstringspans[] = html_writer::span($visibledropzone->markertext, 'wrongpart'); } $wrongpartsstring = join(', ', $wrongpartsstringspans); $output .= html_writer::span(get_string('followingarewrongandhighlighted', 'qtype_ddmarker', $wrongpartsstring), 'wrongparts'); } } $output .= html_writer::div($hiddenfields, 'ddform'); $this->page->requires->js_call_amd('qtype_ddmarker/question', 'init', [$qa->get_outer_question_div_unique_id(), $options->readonly, $visibledropzones]); return $output; } protected function hidden_field_choice(question_attempt $qa, $choiceno, $infinite, $noofdrags, $value = null) { $varname = 'c'.$choiceno; $classes = array('choices', 'choice'.$choiceno, 'noofdrags'.$noofdrags); if ($infinite) { $classes[] = 'infinite'; } list(, $html) = $this->hidden_field_for_qt_var($qa, $varname, null, $classes); return $html; } protected function hint(question_attempt $qa, question_hint $hint) { $output = ''; $question = $qa->get_question(); $response = $qa->get_last_qt_data(); if ($hint->statewhichincorrect) { $wrongdrags = $question->get_wrong_drags($response); $wrongparts = array(); foreach ($wrongdrags as $wrongdrag) { $wrongparts[] = html_writer::nonempty_tag('span', $wrongdrag, array('class' => 'wrongpart')); } $output .= html_writer::nonempty_tag('div', get_string('followingarewrong', 'qtype_ddmarker', join(', ', $wrongparts)), array('class' => 'wrongparts')); } $output .= parent::hint($qa, $hint); return $output; } } PK\݅qqtype/ddmarker/db/upgrade.phpnu[. /** * Ddmarker question type upgrade code. * * @package qtype_ddmarker * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the ddmarker question type. * @param int $oldversion the version we are upgrading from. * @return bool */ function xmldb_qtype_ddmarker_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\ type/ddmarker/db/install.xmlnu[
    PK\ss(type/ddmarker/lang/en/qtype_ddmarker.phpnu[. /** * Language strings for qtype_ddmarker. * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['addmoreitems'] = 'Blanks for {no} more markers'; $string['alttext'] = 'Alt text'; $string['answer'] = 'Answer'; $string['bgimage'] = 'Background image'; $string['coords'] = 'Coordinates'; $string['correctansweris'] = 'The correct answer is: {$a}'; $string['draggableimage'] = 'Draggable image'; $string['draggableitem'] = 'Draggable item'; $string['draggableitemheader'] = 'Draggable item {$a}'; $string['draggableitemtype'] = 'Type'; $string['draggableword'] = 'Draggable text'; $string['dropbackground'] = 'Background image for dragging markers onto'; $string['dropzone'] = 'Drop zone {$a}'; $string['dropzoneheader'] = 'Drop zones'; $string['dropzones'] = 'Drop zones'; $string['dropzones_help'] = 'Drop zones may be defined by coordinates, or dragged into position in the preview above. First selecting a shape (circle, rectangle or polygon) will add a new drop zone shape to the top left of the preview. It may be useful to minimise the Markers section so you can see the preview while editing the Drop zones. Editing a shape starts with a click on the shape in the preview to show the editing handles. You can move the shape using the center handle, or adjust the shape\'s dimensions with the vertex handles. For polygons only, holding the control button (command button on a Mac) while clicking on a vertex handle will add a new vertex to the polygon. Please keep a polygon shape as simple as possible, without crossing lines. For information the three shapes use coordinates in this way: * Circle: centre_x, centre_y; radius
    for example: 80,100;50 * Rectangle: top_left_x, top_left_y; width, height
    for example: 20,60;80,40 * Polygon: x1, y1; x2, y2; ...; xn, yn
    for example: 20,60;100,60;20,100 Selecting a Marker text will add that text to the shape in the preview.'; $string['formerror_dragrequired'] = 'You must add at least one marker to this question.'; $string['formerror_droprequired'] = 'You must define at least one drop zone for this question.'; $string['followingarewrong'] = 'Markers placed in the wrong area: {$a}.'; $string['followingarewrongandhighlighted'] = 'Incorrectly placed markers: {$a}. Highlighted markers are now shown with the correct placements. Click on the marker to highlight the allowed area.'; $string['formerror_nobgimage'] = 'You need to select an image to use as the background for the drag and drop area.'; $string['formerror_noitemselected'] = 'You have specified a drop zone but not chosen a marker that must be dragged to the zone.'; $string['formerror_nosemicolons'] = 'There are no semicolons in your coordinates string. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_onlysometagsallowed'] = 'Only "{$a}" tags are allowed in the label for a marker.'; $string['formerror_onlyusewholepositivenumbers'] = 'Please use only whole positive numbers to specify x,y coordinates and/or width and height of shapes. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_polygonmusthaveatleastthreepoints'] = 'For a polygon shape you need to specify at least 3 points. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_repeatedpoint'] = 'You have entered the same coordinates twice. Each point must be unique. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_shapeoutsideboundsofbgimage'] = 'The shape you have defined goes out of the bounds of the background image.'; $string['formerror_toomanysemicolons'] = 'There are too many semicolon-separated parts to the coordinates you have specified. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_unrecognisedwidthheightpart'] = 'The width and height that you have specified are unrecognisable. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['formerror_unrecognisedxypart'] = 'The x,y coordinates that you have specified are unrecognisable. Your coordinates for a {$a->shape} should be expressed as - {$a->coordsstring}.'; $string['infinite'] = 'Infinite'; $string['marker'] = 'Marker'; $string['marker_n'] = 'Marker {no}'; $string['markers'] = 'Markers'; $string['nolabel'] = 'No label text'; $string['noofdrags'] = 'Number'; $string['pleasedragatleastonemarker'] = 'Your answer is not complete; you must place at least one marker on the image.'; $string['pluginname'] = 'Drag and drop markers'; $string['pluginname_help'] = 'Drag and drop markers require the respondent to drag text labels and drop them into defined drop zones on a background image.'; $string['pluginname_link'] = 'question/type/ddmarker'; $string['pluginnameadding'] = 'Adding drag and drop markers'; $string['pluginnameediting'] = 'Editing drag and drop markers'; $string['pluginnamesummary'] = 'Markers are dragged and dropped onto a background image. Note: This question type is not accessible to users who are visually impaired.'; $string['previewareaheader'] = 'Preview'; $string['previewareamessage'] = 'Select a background image file, enter text labels for markers and define the drop zones on the background image to which they must be dragged.'; $string['privacy:metadata'] = 'Drag and drop markers question type plugin allows question authors to set default options as user preferences.'; $string['privacy:preference:defaultmark'] = 'The default mark set for a given question.'; $string['privacy:preference:penalty'] = 'The penalty for each incorrect try when questions are run using the \'Interactive with multiple tries\' or \'Adaptive mode\' behaviour.'; $string['privacy:preference:shuffleanswers'] = 'Whether the answers should be automatically shuffled.'; $string['refresh'] = 'Refresh preview'; $string['clearwrongparts'] = 'Move incorrectly placed markers back to default start position below image'; $string['shape'] = 'Shape'; $string['shape_circle'] = 'Circle'; $string['shape_circle_lowercase'] = 'circle'; $string['shape_circle_coords'] = 'x,y;r (where x,y are the coordinates of the centre of the circle and r is the radius)'; $string['shape_rectangle'] = 'Rectangle'; $string['shape_rectangle_lowercase'] = 'rectangle'; $string['shape_rectangle_coords'] = 'x,y;w,h (where x,y are the coordinates of the top left corner of the rectangle and w and h are the width and height of the rectangle)'; $string['shape_polygon'] = 'Polygon'; $string['shape_polygon_lowercase'] = 'polygon'; $string['shape_polygon_coords'] = 'x1,y1;x2,y2;x3,y3;x4,y4... (where x1, y1 are the coordinates of the first vertex, x2, y2 are the coordinates of the second etc. There is no need to repeat the coordinates for the first vertex to close the polygon.)'; $string['showmisplaced'] = 'Highlight drop zones which have not had the correct marker dropped on them'; $string['shuffleimages'] = 'Shuffle drag items each time question is attempted'; $string['stateincorrectlyplaced'] = 'State which markers are incorrectly placed'; $string['summariseplace'] = '{$a->no}. {$a->text}'; $string['summariseplaceno'] = 'Drop zone {$a}'; $string['ytop'] = 'Top'; PK\t́Dtype/ddmarker/backup/moodle2/restore_qtype_ddmarker_plugin.class.phpnu[. /** * Restore code for qtype_ddmarker. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Restore plugin class for the ddmarker question type plugin. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_ddmarker_plugin extends restore_qtype_plugin { /** * Returns the qtype name. * * @return string The type name */ protected static function qtype_name() { return 'ddmarker'; } /** * Returns the paths to be handled by the plugin at question level. * * @return array */ protected function define_question_plugin_structure() { $paths = array(); // Add own qtype stuff. $elename = 'dds'; $elepath = $this->get_pathfor('/'.self::qtype_name()); $paths[] = new restore_path_element($elename, $elepath); $elename = 'drag'; $elepath = $this->get_pathfor('/drags/drag'); $paths[] = new restore_path_element($elename, $elepath); $elename = 'drop'; $elepath = $this->get_pathfor('/drops/drop'); $paths[] = new restore_path_element($elename, $elepath); return $paths; // And we return the interesting paths. } /** * Process the qtype/{qtypename} element. * * @param array|object $data Drag and drop data to work with. */ public function process_dds($data) { global $DB; $prefix = 'qtype_'.self::qtype_name(); $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore // we need to create its qtype_ddmarker too. if ($questioncreated) { // Adjust some columns. $data->questionid = $newquestionid; // Insert record. $newitemid = $DB->insert_record($prefix, $data); // Create mapping (needed for decoding links). $this->set_mapping($prefix, $oldid, $newitemid); } } /** * Process the qtype/drags/drag element. * * @param array|object $data Drag and drop drag data to work with. */ public function process_drag($data) { global $DB; $prefix = 'qtype_'.self::qtype_name(); $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; if ($questioncreated) { $data->questionid = $newquestionid; // Insert record. $newitemid = $DB->insert_record("{$prefix}_drags", $data); // Create mapping (there are files and states based on this). $this->set_mapping("{$prefix}_drags", $oldid, $newitemid); } } /** * Process the qtype/drags/drop element. * * @param array|object $data Drag and drop drops data to work with. */ public function process_drop($data) { global $DB; $prefix = 'qtype_'.self::qtype_name(); $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; if ($questioncreated) { $data->questionid = $newquestionid; // Insert record. $newitemid = $DB->insert_record("{$prefix}_drops", $data); // Create mapping (there are files and states based on this). $this->set_mapping("{$prefix}_drops", $oldid, $newitemid); } } /** * Return the contents of this qtype to be processed by the links decoder * * @return array */ public static function define_decode_contents() { $prefix = 'qtype_'.self::qtype_name(); $contents = array(); $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); $contents[] = new restore_decode_content($prefix, $fields, $prefix); return $contents; } #[\Override] public static function convert_backup_to_questiondata(array $backupdata): \stdClass { $questiondata = parent::convert_backup_to_questiondata($backupdata); $questiondata->options->drags = array_map( fn($drag) => (object) $drag, $backupdata['plugin_qtype_ddmarker_question']['drags']['drag'] ?? [], ); $questiondata->options->drops = array_map( fn($drop) => (object) $drop, $backupdata['plugin_qtype_ddmarker_question']['drops']['drop'] ?? [], ); return $questiondata; } #[\Override] protected function define_excluded_identity_hash_fields(): array { return [ '/options/drags/id', '/options/drags/questionid', '/options/drops/id', '/options/drops/questionid', ]; } } PK\ LCtype/ddmarker/backup/moodle2/backup_qtype_ddmarker_plugin.class.phpnu[. /** * Backup code for qtype_ddmarker. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup ddmarker questions. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_ddmarker_plugin extends backup_qtype_plugin { /** * Get the name of this question type. * * @return string the question type, like 'ddmarker'. */ protected static function qtype_name() { return 'ddmarker'; } /** * Returns the qtype information to attach to question element */ protected function define_question_plugin_structure() { $qtype = self::qtype_name(); $plugin = $this->get_plugin_element(null, '../../qtype', $qtype); $pluginwrapper = new backup_nested_element($this->get_recommended_name()); $plugin->add_child($pluginwrapper); $dds = new backup_nested_element($qtype, array('id'), array( 'shuffleanswers', 'correctfeedback', 'correctfeedbackformat', 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', 'incorrectfeedback', 'incorrectfeedbackformat', 'shownumcorrect', 'showmisplaced') ); $pluginwrapper->add_child($dds); $drags = new backup_nested_element('drags'); $drag = new backup_nested_element('drag', array('id'), array('no', 'infinite', 'label', 'noofdrags')); $drops = new backup_nested_element('drops'); $drop = new backup_nested_element('drop', array('id'), array('no', 'shape', 'coords', 'choice')); $dds->set_source_table("qtype_{$qtype}", array('questionid' => backup::VAR_PARENTID)); $pluginwrapper->add_child($drags); $drags->add_child($drag); $pluginwrapper->add_child($drops); $drops->add_child($drop); $drag->set_source_table("qtype_{$qtype}_drags", array('questionid' => backup::VAR_PARENTID)); $drop->set_source_table("qtype_{$qtype}_drops", array('questionid' => backup::VAR_PARENTID)); return $plugin; } /** * Returns one array with filearea => mappingname elements for the qtype * * Used by {@link get_components_and_fileareas} to know about all the qtype * files to be processed both in backup and restore. */ public static function get_qtype_fileareas() { $qtype = self::qtype_name(); return array( 'correctfeedback' => 'question_created', 'partiallycorrectfeedback' => 'question_created', 'incorrectfeedback' => 'question_created', 'bgimage' => 'question_created'); } } PK\It\++type/ddmarker/pix/grid.svgnu[ PK\Vb/ type/ddmarker/pix/crosshairs.svgnu[ PK\?isstype/ddmarker/pix/icon.svgnu[PK\$$type/ddmarker/lib.phpnu[. /** * Serve question type files. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for ddmarker questions. * * @param object $course The course we are in * @param object $cm Course module * @param object $context The context object * @param string $filearea the name of the file area. * @param array $args the remaining bits of the file path. * @param bool $forcedownload whether the user must be forced to download the file. * @param array $options additional options affecting the file serving */ function qtype_ddmarker_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_ddmarker', $filearea, $args, $forcedownload, $options); } /** * Get icon mapping for font-awesome. */ function qtype_ddmarker_get_fontawesome_icon_map() { return [ 'qtype_ddmarker:crosshairs' => 'fa-crosshairs', 'qtype_ddmarker:grid' => 'fa-table-cells', ]; } PK\<type/ddmarker/version.phpnu[. /** * Version information for the drag-and-drop markers question type. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->component = 'qtype_ddmarker'; $plugin->maturity = MATURITY_STABLE; $plugin->dependencies = [ 'qtype_gapselect' => 2025040800, 'qtype_ddimageortext' => 2025040800, ]; PK\GDDtype/ddmarker/question.phpnu[. /** * Drag-and-drop markers question definition class. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/ddimageortext/questionbase.php'); require_once($CFG->dirroot . '/question/type/ddmarker/shapes.php'); /** * Represents a drag-and-drop markers question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_question extends qtype_ddtoimage_question_base { public $showmisplaced; public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($filearea == 'bgimage') { $validfilearea = true; } else { $validfilearea = false; } if ($component == 'qtype_ddmarker' && $validfilearea) { $question = $qa->get_question(false); $itemid = reset($args); return $itemid == $question->id; } else { return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } } /** * Get a choice identifier * * @param int $choice stem number * @return string the question-type variable name. */ public function choice($choice) { return 'c' . $choice; } public function get_expected_data() { $vars = array(); foreach ($this->choices[1] as $choice => $notused) { $vars[$this->choice($choice)] = PARAM_NOTAGS; } return $vars; } public function is_complete_response(array $response) { foreach ($this->choices[1] as $choiceno => $notused) { if (isset($response[$this->choice($choiceno)]) && '' != trim($response[$this->choice($choiceno)])) { return true; } } return false; } public function is_gradable_response(array $response) { return $this->is_complete_response($response); } public function is_same_response(array $prevresponse, array $newresponse) { foreach ($this->choices[1] as $choice => $notused) { $fieldname = $this->choice($choice); if (!$this->arrays_same_at_key_integer( $prevresponse, $newresponse, $fieldname)) { return false; } } return true; } /** * Tests to see whether two arrays have the same set of coords at a particular key. Coords * can be in any order. * @param array $array1 the first array. * @param array $array2 the second array. * @param string $key an array key. * @return bool whether the two arrays have the same set of coords (or lack of them) * for a given key. */ public function arrays_same_at_key_integer( array $array1, array $array2, $key) { if (array_key_exists($key, $array1)) { $value1 = $array1[$key]; } else { $value1 = ''; } if (array_key_exists($key, $array2)) { $value2 = $array2[$key]; } else { $value2 = ''; } $coords1 = explode(';', $value1); $coords2 = explode(';', $value2); if (count($coords1) !== count($coords2)) { return false; } else if (count($coords1) === 0) { return true; } else { $valuesinbotharrays = $this->array_intersect_fixed($coords1, $coords2); return (count($valuesinbotharrays) == count($coords1)); } } /** * * This function is a variation of array_intersect that checks for the existence of duplicate * array values too. * @author dml at nm dot ru (taken from comments on php manual) * @param array $array1 * @param array $array2 * @return bool whether array1 and array2 contain the same values including duplicate values */ protected function array_intersect_fixed($array1, $array2) { $result = array(); foreach ($array1 as $val) { if (($key = array_search($val, $array2, true)) !== false) { $result[] = $val; unset($array2[$key]); } } return $result; } public function get_validation_error(array $response) { if ($this->is_complete_response($response)) { return ''; } return get_string('pleasedragatleastonemarker', 'qtype_ddmarker'); } public function get_num_parts_right(array $response) { $chosenhits = $this->choose_hits($response); $divisor = max(count($this->rightchoices), $this->total_number_of_items_dragged($response)); return array(count($chosenhits), $divisor); } /** * Choose hits to maximize grade where drop targets may have more than one hit and drop targets * can overlap. * @param array $response * @return array chosen hits */ protected function choose_hits(array $response) { $allhits = $this->get_all_hits($response); $chosenhits = array(); foreach ($allhits as $placeno => $hits) { foreach ($hits as $itemno => $hit) { $choice = $this->get_right_choice_for($placeno); $choiceitem = "$choice $itemno"; if (!in_array($choiceitem, $chosenhits)) { $chosenhits[$placeno] = $choiceitem; break; } } } return $chosenhits; } public function total_number_of_items_dragged(array $response) { $total = 0; foreach ($this->choiceorder[1] as $choice) { $choicekey = $this->choice($choice); if (array_key_exists($choicekey, $response) && trim($response[$choicekey] !== '')) { $total += count(explode(';', $response[$choicekey])); } } return $total; } /** * Get's an array of all hits on drop targets. Needs further processing to find which hits * to select in the general case that drop targets may have more than one hit and drop targets * can overlap. * @param array $response * @return array all hits */ protected function get_all_hits(array $response) { $hits = array(); foreach ($this->places as $placeno => $place) { $rightchoice = $this->get_right_choice_for($placeno); $rightchoicekey = $this->choice($rightchoice); if (!array_key_exists($rightchoicekey, $response)) { continue; } $choicecoords = $response[$rightchoicekey]; $coords = explode(';', $choicecoords); foreach ($coords as $itemno => $coord) { if (trim($coord) === '') { continue; } $pointxy = explode(',', $coord); $pointxy[0] = round($pointxy[0]); $pointxy[1] = round($pointxy[1]); if ($place->drop_hit($pointxy)) { if (!isset($hits[$placeno])) { $hits[$placeno] = array(); } $hits[$placeno][$itemno] = $coord; } } } // Reverse sort in order of number of hits per place (if two or more // hits per place then we want to make sure hits do not hit elsewhere). $sortcomparison = function ($a1, $a2){ return (count($a1) - count($a2)); }; uasort($hits, $sortcomparison); return $hits; } public function get_right_choice_for($place) { $group = $this->places[$place]->group; foreach ($this->choiceorder[$group] as $choicekey => $choiceid) { if ($this->rightchoices[$place] == $choiceid) { return $choicekey; } } return null; } public function grade_response(array $response) { list($right, $total) = $this->get_num_parts_right($response); $fraction = $right / $total; return array($fraction, question_state::graded_state_for_fraction($fraction)); } public function compute_final_grade($responses, $totaltries) { $maxitemsdragged = 0; $wrongtries = array(); foreach ($responses as $i => $response) { $maxitemsdragged = max($maxitemsdragged, $this->total_number_of_items_dragged($response)); $hits = $this->choose_hits($response); foreach ($hits as $place => $choiceitem) { if (!isset($wrongtries[$place])) { $wrongtries[$place] = $i; } } foreach ($wrongtries as $place => $notused) { if (!isset($hits[$place])) { unset($wrongtries[$place]); } } } $numtries = count($responses); $numright = count($wrongtries); $penalty = array_sum($wrongtries) * $this->penalty; $grade = ($numright - $penalty) / (max($maxitemsdragged, count($this->places))); return $grade; } public function clear_wrong_from_response(array $response) { $hits = $this->choose_hits($response); $cleanedresponse = array(); foreach ($response as $choicekey => $coords) { $choice = (int)substr($choicekey, 1); $choiceresponse = array(); $coordparts = explode(';', $coords); foreach ($coordparts as $itemno => $coord) { if (in_array("$choice $itemno", $hits)) { $choiceresponse[] = $coord; } } $cleanedresponse[$choicekey] = join(';', $choiceresponse); } return $cleanedresponse; } public function get_wrong_drags(array $response) { $hits = $this->choose_hits($response); $wrong = array(); foreach ($response as $choicekey => $coords) { $choice = (int)substr($choicekey, 1); if ($coords != '') { $coordparts = explode(';', $coords); foreach ($coordparts as $itemno => $coord) { if (!in_array("$choice $itemno", $hits)) { $wrong[] = $this->get_selected_choice(1, $choice)->text; } } } } return $wrong; } public function get_drop_zones_without_hit(array $response) { $hits = $this->choose_hits($response); $nohits = array(); foreach ($this->places as $placeno => $place) { $choice = $this->get_right_choice_for($placeno); if (!isset($hits[$placeno])) { $nohit = new stdClass(); $nohit->coords = $place->coords; $nohit->shape = $place->shape->name(); $nohit->markertext = $this->choices[1][$this->choiceorder[1][$choice]]->text; $nohits[] = $nohit; } } return $nohits; } public function classify_response(array $response) { $parts = array(); $hits = $this->choose_hits($response); foreach ($this->places as $placeno => $place) { if (isset($hits[$placeno])) { $shuffledchoiceno = $this->get_right_choice_for($placeno); $choice = $this->get_selected_choice(1, $shuffledchoiceno); $parts[$placeno] = new question_classified_response( $choice->no, $choice->summarise(), 1 / count($this->places)); } else { $parts[$placeno] = question_classified_response::no_response(); } } return $parts; } public function get_correct_response() { $responsecoords = array(); foreach ($this->places as $placeno => $place) { $rightchoice = $this->get_right_choice_for($placeno); if ($rightchoice !== null) { $rightchoicekey = $this->choice($rightchoice); $correctcoords = $place->correct_coords(); if ($correctcoords !== null) { if (!isset($responsecoords[$rightchoicekey])) { $responsecoords[$rightchoicekey] = array(); } $responsecoords[$rightchoicekey][] = join(',', $correctcoords); } } } $response = array(); foreach ($responsecoords as $choicekey => $coords) { $response[$choicekey] = join(';', $coords); } return $response; } public function get_right_answer_summary() { $placesummaries = array(); foreach ($this->places as $placeno => $place) { $shuffledchoiceno = $this->get_right_choice_for($placeno); $choice = $this->get_selected_choice(1, $shuffledchoiceno); $placesummaries[] = '{'.$place->summarise().' -> '.$choice->summarise().'}'; } return join(', ', $placesummaries); } public function summarise_response(array $response) { $hits = $this->choose_hits($response); $goodhits = array(); foreach ($this->places as $placeno => $place) { if (isset($hits[$placeno])) { $shuffledchoiceno = $this->get_right_choice_for($placeno); $choice = $this->get_selected_choice(1, $shuffledchoiceno); $goodhits[] = "{".$place->summarise()." -> ". $choice->summarise(). "}"; } } if (count($goodhits) == 0) { return null; } return implode(', ', $goodhits); } public function get_random_guess_score() { return null; } } /** * Represents one of the choices (draggable markers). * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_drag_item { /** @var string Label for the drag item */ public $text; /** @var int Number of the item */ public $no; /** @var int Group of the item */ public $infinite; /** @var int Number of drags */ public $noofdrags; /** * Drag item object setup. * * @param string $label The label text of the drag item * @param int $no Which number drag item this is * @param bool $infinite True if the item can be used an unlimited number of times * @param int $noofdrags */ public function __construct($label, $no, $infinite, $noofdrags) { $this->text = $label; $this->infinite = $infinite; $this->no = $no; $this->noofdrags = $noofdrags; } /** * Returns the group of this item. * * @return int */ public function choice_group() { return 1; } /** * Creates summary text of for the drag item. * * @return string */ public function summarise() { return $this->text; } } /** * Represents one of the places (drop zones). * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_drop_zone { /** @var int Group of the item */ public $group = 1; /** @var int Number of the item */ public $no; /** @var object Shape of the item */ public $shape; /** @var array Location of the item */ public $coords; /** * Setup a drop zone object. * * @param int $no Which number drop zone this is * @param int $shape Shape of the drop zone * @param array $coords Coordinates of the zone */ public function __construct($no, $shape, $coords) { $this->no = $no; $this->shape = qtype_ddmarker_shape::create($shape, $coords); $this->coords = $coords; } /** * Creates summary text of for the drop zone * * @return string */ public function summarise() { return get_string('summariseplaceno', 'qtype_ddmarker', $this->no); } /** * Indicates if the it coordinates are in this drop zone. * * @param array $xy Array of X and Y location * @return bool */ public function drop_hit($xy) { return $this->shape->is_point_in_shape($xy); } /** * Gets the center point of this zone * * @return array X and Y location */ public function correct_coords() { return $this->shape->center_point(); } } PK\& *type/ddmarker/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_ddmarker. * * @package qtype_ddmarker * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_ddmarker\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\transform; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_ddmarker implementing user_preference_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This component has data. // We need to return default options that have been set a user preferences. \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qtype_ddmarker_defaultmark', 'privacy:preference:defaultmark'); $collection->add_user_preference('qtype_ddmarker_penalty', 'privacy:preference:penalty'); $collection->add_user_preference('qtype_ddmarker_shuffleanswers', 'privacy:preference:shuffleanswers'); return $collection; } /** * Export all user preferences for the plugin. * * @param int $userid The userid of the user whose data is to be exported. */ public static function export_user_preferences(int $userid) { $preference = get_user_preferences('qtype_ddmarker_defaultmark', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:defaultmark', 'qtype_ddmarker'); writer::export_user_preference('qtype_ddmarker', 'defaultmark', $preference, $desc); } $preference = get_user_preferences('qtype_ddmarker_penalty', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:penalty', 'qtype_ddmarker'); writer::export_user_preference('qtype_ddmarker', 'penalty', transform::percentage($preference), $desc); } $preference = get_user_preferences('qtype_ddmarker_shuffleanswers', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:shuffleanswers', 'qtype_ddmarker'); writer::export_user_preference('qtype_ddmarker', 'shuffleanswers', transform::yesno($preference), $desc); } } } PK\Qggtype/ddmarker/amd/src/shapes.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /* eslint max-depth: ["error", 8] */ /** * Library of classes for handling simple shapes. * * These classes can represent shapes, let you alter them, can go to and from a string * representation, and can give you an SVG representation. * * @module qtype_ddmarker/shapes * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(function() { "use strict"; /** * A point, with x and y coordinates. * * @param {int} x centre X. * @param {int} y centre Y. * @constructor */ function Point(x, y) { this.x = x; this.y = y; } /** * Standard toString method. * @returns {string} "x;y"; */ Point.prototype.toString = function() { return this.x + ',' + this.y; }; /** * Move a point * @param {int} dx x offset * @param {int} dy y offset */ Point.prototype.move = function(dx, dy) { this.x += dx; this.y += dy; }; /** * Return a new point that is a certain position relative to this one. * * @param {(int|Point)} offsetX if a point, offset by this points coordinates, else and int x offset. * @param {int} [offsetY] used if offsetX is an int, the corresponding y offset. * @return {Point} the new point. */ Point.prototype.offset = function(offsetX, offsetY) { if (offsetX instanceof Point) { offsetY = offsetX.y; offsetX = offsetX.x; } return new Point(this.x + offsetX, this.y + offsetY); }; /** * Make a point from the string representation. * * @param {String} coordinates "x,y". * @return {Point} the point. Throws an exception if input is not valid. */ Point.parse = function(coordinates) { var bits = coordinates.split(','); if (bits.length !== 2) { throw new Error(coordinates + ' is not a valid point'); } return new Point(Math.round(bits[0]), Math.round(bits[1])); }; /** * Shape constructor. Abstract class to represent the different types of drop zone shapes. * * @param {String} [label] name of this area. * @param {int} [x] centre X. * @param {int} [y] centre Y. * @constructor */ function Shape(label, x, y) { this.label = label; this.centre = new Point(x || 0, y || 0); } /** * Get the type of shape. * * @return {String} 'circle', 'rectangle' or 'polygon'; */ Shape.prototype.getType = function() { throw new Error('Not implemented.'); }; /** * Get the string representation of this shape. * * @return {String} coordinates as they need to be typed into the form. */ Shape.prototype.getCoordinates = function() { throw new Error('Not implemented.'); }; /** * Update the shape from the string representation. * * @param {String} coordinates in the form returned by getCoordinates. * @param {number} ratio Ratio to scale. * @return {boolean} true if the string could be parsed and the shape updated, else false. */ Shape.prototype.parse = function(coordinates, ratio) { void (coordinates, ratio); throw new Error('Not implemented.'); }; /** * Move the entire shape by this offset. * * @param {int} dx x offset. * @param {int} dy y offset. * @param {int} maxX ensure that after editing, the shape lies between 0 and maxX on the x-axis. * @param {int} maxY ensure that after editing, the shape lies between 0 and maxX on the y-axis. */ Shape.prototype.move = function(dx, dy, maxX, maxY) { void (maxY); }; /** * Move one of the edit handles by this offset. * * @param {int} handleIndex which handle was moved. * @param {int} dx x offset. * @param {int} dy y offset. * @param {int} maxX ensure that after editing, the shape lies between 0 and maxX on the x-axis. * @param {int} maxY ensure that after editing, the shape lies between 0 and maxX on the y-axis. */ Shape.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) { void (maxY); }; /** * Update the properties of this shape after a sequence of edits. * * For example make sure the circle radius is positive, of the polygon centre is centred. */ Shape.prototype.normalizeShape = function() { void (1); // To make CiBoT happy. }; /** * Get the string representation of this shape. * * @param {SVGElement} svg the SVG graphic to add this shape to. * @return {SVGElement} SVG representation of this shape. */ Shape.prototype.makeSvg = function(svg) { void (svg); throw new Error('Not implemented.'); }; /** * Update the SVG representation of this shape. * * @param {SVGElement} svgEl the SVG representation of this shape. */ Shape.prototype.updateSvg = function(svgEl) { void (svgEl); }; /** * Make a circle similar to this shape. * * @return {Circle} a circle that is about the same size and position as this shape. */ Shape.prototype.makeSimilarCircle = function() { throw new Error('Not implemented.'); }; /** * Make a rectangle similar to this shape. * * @return {Rectangle} a rectangle that is about the same size and position as this shape. */ Shape.prototype.makeSimilarRectangle = function() { throw new Error('Not implemented.'); }; /** * Make a polygon similar to this shape. * * @return {Polygon} a polygon that is about the same size and position as this shape. */ Shape.prototype.makeSimilarPolygon = function() { throw new Error('Not implemented.'); }; /** * Get the handles that should be offered to edit this shape, or null if not appropriate. * * @return {Object[]} with properties moveHandle {Point} and editHandles {Point[]} */ Shape.prototype.getHandlePositions = function() { return null; }; /** * A shape that is a circle. * * @param {String} label name of this area. * @param {int} [x] centre X. * @param {int} [y] centre Y. * @param {int} [radius] radius. * @constructor */ function Circle(label, x, y, radius) { x = x || 15; y = y || 15; Shape.call(this, label, x, y); this.radius = radius || 15; } Circle.prototype = new Shape(); Circle.prototype.getType = function() { return 'circle'; }; Circle.prototype.getCoordinates = function() { return this.centre + ';' + Math.abs(this.radius); }; Circle.prototype.makeSvg = function(svg) { var svgEl = createSvgShapeGroup(svg, 'circle'); this.updateSvg(svgEl); return svgEl; }; Circle.prototype.updateSvg = function(svgEl) { svgEl.childNodes[0].setAttribute('cx', this.centre.x); svgEl.childNodes[0].setAttribute('cy', this.centre.y); svgEl.childNodes[0].setAttribute('r', Math.abs(this.radius)); svgEl.childNodes[1].setAttribute('x', this.centre.x); svgEl.childNodes[1].setAttribute('y', this.centre.y + 15); svgEl.childNodes[1].textContent = this.label; }; Circle.prototype.parse = function(coordinates, ratio) { if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/)) { return false; } var bits = coordinates.split(';'); this.centre = Point.parse(bits[0]); this.centre.x = this.centre.x * parseFloat(ratio); this.centre.y = this.centre.y * parseFloat(ratio); this.radius = Math.round(bits[1]) * parseFloat(ratio); return true; }; Circle.prototype.move = function(dx, dy, maxX, maxY) { this.centre.move(dx, dy); if (this.centre.x < this.radius) { this.centre.x = this.radius; } if (this.centre.x > maxX - this.radius) { this.centre.x = maxX - this.radius; } if (this.centre.y < this.radius) { this.centre.y = this.radius; } if (this.centre.y > maxY - this.radius) { this.centre.y = maxY - this.radius; } }; Circle.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) { this.radius += dx; var limit = Math.min(this.centre.x, this.centre.y, maxX - this.centre.x, maxY - this.centre.y); if (this.radius > limit) { this.radius = limit; } if (this.radius < -limit) { this.radius = -limit; } }; /** * Update the properties of this shape after a sequence of edits. * * For example make sure the circle radius is positive, of the polygon centre is centred. */ Circle.prototype.normalizeShape = function() { this.radius = Math.abs(this.radius); }; Circle.prototype.makeSimilarRectangle = function() { return new Rectangle(this.label, this.centre.x - this.radius, this.centre.y - this.radius, this.radius * 2, this.radius * 2); }; Circle.prototype.makeSimilarPolygon = function() { // We make a similar square, so if you go to and from Rectangle afterwards, it is loss-less. return new Polygon(this.label, [ this.centre.offset(-this.radius, -this.radius), this.centre.offset(-this.radius, this.radius), this.centre.offset(this.radius, this.radius), this.centre.offset(this.radius, -this.radius)]); }; Circle.prototype.getHandlePositions = function() { return { moveHandle: this.centre, editHandles: [this.centre.offset(this.radius, 0)] }; }; /** * A shape that is a rectangle. * * @param {String} label name of this area. * @param {int} [x] top left X. * @param {int} [y] top left Y. * @param {int} [width] width. * @param {int} [height] height. * @constructor */ function Rectangle(label, x, y, width, height) { Shape.call(this, label, x, y); this.width = width || 30; this.height = height || 30; } Rectangle.prototype = new Shape(); Rectangle.prototype.getType = function() { return 'rectangle'; }; Rectangle.prototype.getCoordinates = function() { return this.centre + ';' + this.width + ',' + this.height; }; Rectangle.prototype.makeSvg = function(svg) { var svgEl = createSvgShapeGroup(svg, 'rect'); this.updateSvg(svgEl); return svgEl; }; Rectangle.prototype.updateSvg = function(svgEl) { if (this.width >= 0) { svgEl.childNodes[0].setAttribute('x', this.centre.x); svgEl.childNodes[0].setAttribute('width', this.width); } else { svgEl.childNodes[0].setAttribute('x', this.centre.x + this.width); svgEl.childNodes[0].setAttribute('width', -this.width); } if (this.height >= 0) { svgEl.childNodes[0].setAttribute('y', this.centre.y); svgEl.childNodes[0].setAttribute('height', this.height); } else { svgEl.childNodes[0].setAttribute('y', this.centre.y + this.height); svgEl.childNodes[0].setAttribute('height', -this.height); } svgEl.childNodes[1].setAttribute('x', this.centre.x + this.width / 2); svgEl.childNodes[1].setAttribute('y', this.centre.y + this.height / 2 + 15); svgEl.childNodes[1].textContent = this.label; }; Rectangle.prototype.parse = function(coordinates, ratio) { if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/)) { return false; } var bits = coordinates.split(';'); this.centre = Point.parse(bits[0]); this.centre.x = this.centre.x * parseFloat(ratio); this.centre.y = this.centre.y * parseFloat(ratio); var size = Point.parse(bits[1]); this.width = size.x * parseFloat(ratio); this.height = size.y * parseFloat(ratio); return true; }; Rectangle.prototype.move = function(dx, dy, maxX, maxY) { this.centre.move(dx, dy); if (this.centre.x < 0) { this.centre.x = 0; } if (this.centre.x > maxX - this.width) { this.centre.x = maxX - this.width; } if (this.centre.y < 0) { this.centre.y = 0; } if (this.centre.y > maxY - this.height) { this.centre.y = maxY - this.height; } }; Rectangle.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) { this.width += dx; this.height += dy; if (this.width < -this.centre.x) { this.width = -this.centre.x; } if (this.width > maxX - this.centre.x) { this.width = maxX - this.centre.x; } if (this.height < -this.centre.y) { this.height = -this.centre.y; } if (this.height > maxY - this.centre.y) { this.height = maxY - this.centre.y; } }; /** * Update the properties of this shape after a sequence of edits. * * For example make sure the circle radius is positive, of the polygon centre is centred. */ Rectangle.prototype.normalizeShape = function() { if (this.width < 0) { this.centre.x += this.width; this.width = -this.width; } if (this.height < 0) { this.centre.y += this.height; this.height = -this.height; } }; Rectangle.prototype.makeSimilarCircle = function() { return new Circle(this.label, Math.round(this.centre.x + this.width / 2), Math.round(this.centre.y + this.height / 2), Math.round((this.width + this.height) / 4)); }; Rectangle.prototype.makeSimilarPolygon = function() { return new Polygon(this.label, [ this.centre, this.centre.offset(0, this.height), this.centre.offset(this.width, this.height), this.centre.offset(this.width, 0)]); }; Rectangle.prototype.getHandlePositions = function() { return { moveHandle: this.centre.offset(this.width / 2, this.height / 2), editHandles: [this.centre.offset(this.width, this.height)] }; }; /** * A shape that is a polygon. * * @param {String} label name of this area. * @param {Point[]} [points] position of the vertices relative to (centreX, centreY). * each object in the array should have two * @constructor */ function Polygon(label, points) { Shape.call(this, label, 0, 0); this.points = points ? points.slice() : [new Point(10, 10), new Point(40, 10), new Point(10, 40)]; this.normalizeShape(); this.ratio = 1; } Polygon.prototype = new Shape(); Polygon.prototype.getType = function() { return 'polygon'; }; Polygon.prototype.getCoordinates = function() { var coordinates = ''; for (var i = 0; i < this.points.length; i++) { coordinates += this.centre.offset(this.points[i]) + ';'; } return coordinates.slice(0, coordinates.length - 1); // Strip off the last ';'. }; Polygon.prototype.makeSvg = function(svg) { var svgEl = createSvgShapeGroup(svg, 'polygon'); this.updateSvg(svgEl); return svgEl; }; Polygon.prototype.updateSvg = function(svgEl) { svgEl.childNodes[0].setAttribute('points', this.getCoordinates().replace(/[,;]/g, ' ')); svgEl.childNodes[0].setAttribute('transform', 'scale(' + parseFloat(this.ratio) + ')'); svgEl.childNodes[1].setAttribute('x', this.centre.x); svgEl.childNodes[1].setAttribute('y', this.centre.y + 15); svgEl.childNodes[1].textContent = this.label; }; Polygon.prototype.parse = function(coordinates, ratio) { if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?(?:;\d+(\.\d+)?,\d+(\.\d+)?)*$/)) { return false; } var bits = coordinates.split(';'); var points = []; for (var i = 0; i < bits.length; i++) { points.push(Point.parse(bits[i])); } this.points = points; this.centre.x = 0; this.centre.y = 0; this.ratio = ratio; this.normalizeShape(); return true; }; Polygon.prototype.move = function(dx, dy, maxX, maxY) { this.centre.move(dx, dy); var bbXMin = maxX, bbXMax = 0, bbYMin = maxY, bbYMax = 0; // Computer centre. for (var i = 0; i < this.points.length; i++) { bbXMin = Math.min(bbXMin, this.points[i].x); bbXMax = Math.max(bbXMax, this.points[i].x); bbYMin = Math.min(bbYMin, this.points[i].y); bbYMax = Math.max(bbYMax, this.points[i].y); } if (this.centre.x < -bbXMin) { this.centre.x = -bbXMin; } if (this.centre.x > maxX - bbXMax) { this.centre.x = maxX - bbXMax; } if (this.centre.y < -bbYMin) { this.centre.y = -bbYMin; } if (this.centre.y > maxY - bbYMax) { this.centre.y = maxY - bbYMax; } }; Polygon.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) { this.points[handleIndex].move(dx, dy); if (this.points[handleIndex].x < -this.centre.x) { this.points[handleIndex].x = -this.centre.x; } if (this.points[handleIndex].x > maxX - this.centre.x) { this.points[handleIndex].x = maxX - this.centre.x; } if (this.points[handleIndex].y < -this.centre.y) { this.points[handleIndex].y = -this.centre.y; } if (this.points[handleIndex].y > maxY - this.centre.y) { this.points[handleIndex].y = maxY - this.centre.y; } }; /** * Add a new point after the given point, with the same co-ordinates. * * This does not automatically normalise. * * @param {int} pointIndex the index of the vertex after which to insert this new one. */ Polygon.prototype.addNewPointAfter = function(pointIndex) { this.points.splice(pointIndex, 0, new Point(this.points[pointIndex].x, this.points[pointIndex].y)); }; Polygon.prototype.normalizeShape = function() { var i, x = 0, y = 0; if (this.points.length === 0) { return; } // Computer centre. for (i = 0; i < this.points.length; i++) { x += this.points[i].x; y += this.points[i].y; } x = Math.round(x / this.points.length); y = Math.round(y / this.points.length); if (x === 0 && y === 0) { return; } for (i = 0; i < this.points.length; i++) { this.points[i].move(-x, -y); } this.centre.move(x, y); }; Polygon.prototype.makeSimilarCircle = function() { return this.makeSimilarRectangle().makeSimilarCircle(); }; Polygon.prototype.makeSimilarRectangle = function() { var p, minX = 0, maxX = 0, minY = 0, maxY = 0; for (var i = 0; i < this.points.length; i++) { p = this.points[i]; minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); } return new Rectangle(this.label, this.centre.x + minX, this.centre.y + minY, Math.max(maxX - minX, 10), Math.max(maxY - minY, 10)); }; Polygon.prototype.getHandlePositions = function() { var editHandles = []; for (var i = 0; i < this.points.length; i++) { editHandles.push(this.points[i].offset(this.centre.x, this.centre.y)); } this.centre.x = this.centre.x * parseFloat(this.ratio); this.centre.y = this.centre.y * parseFloat(this.ratio); return { moveHandle: this.centre, editHandles: editHandles }; }; /** * Not a shape (null object pattern). * * @param {String} label name of this area. * @constructor */ function NullShape(label) { Shape.call(this, label); } NullShape.prototype = new Shape(); NullShape.prototype.getType = function() { return 'null'; }; NullShape.prototype.getCoordinates = function() { return ''; }; NullShape.prototype.makeSvg = function(svg) { void (svg); return null; }; NullShape.prototype.updateSvg = function(svgEl) { void (svgEl); }; NullShape.prototype.parse = function(coordinates) { void (coordinates); return false; }; NullShape.prototype.makeSimilarCircle = function() { return new Circle(this.label); }; NullShape.prototype.makeSimilarRectangle = function() { return new Rectangle(this.label); }; NullShape.prototype.makeSimilarPolygon = function() { return new Polygon(this.label); }; /** * Make a new SVG DOM element as a child of svg. * * @param {SVGElement} svg the parent node. * @param {String} tagName the tag name. * @return {SVGElement} the newly created node. */ function createSvgElement(svg, tagName) { var svgEl = svg.ownerDocument.createElementNS('http://www.w3.org/2000/svg', tagName); svg.appendChild(svgEl); return svgEl; } /** * Make a group SVG DOM elements containing a shape of the given type as first child, * and a text label as the second child. * * @param {SVGElement} svg the parent node. * @param {String} tagName the tag name. * @return {SVGElement} the newly created g element. */ function createSvgShapeGroup(svg, tagName) { var svgEl = createSvgElement(svg, 'g'); createSvgElement(svgEl, tagName).setAttribute('class', 'shape'); createSvgElement(svgEl, 'text').setAttribute('class', 'shapeLabel'); return svgEl; } /** * @alias module:qtype_ddmarker/shapes */ return { /** * A point, with x and y coordinates. * * @param {int} x centre X. * @param {int} y centre Y. * @constructor */ Point: Point, /** * A point, with x and y coordinates. * * @param {int} x centre X. * @param {int} y centre Y. * @constructor */ Shape: Shape, /** * A shape that is a circle. * * @param {String} label name of this area. * @param {int} [x] centre X. * @param {int} [y] centre Y. * @param {int} [radius] radius. * @constructor */ Circle: Circle, /** * A shape that is a rectangle. * * @param {String} label name of this area. * @param {int} [x] top left X. * @param {int} [y] top left Y. * @param {int} [width] width. * @param {int} [height] height. * @constructor */ Rectangle: Rectangle, /** * A shape that is a polygon. * * @param {String} label name of this area. * @param {Point[]} [points] position of the vertices relative to (centreX, centreY). * each object in the array should have two * @constructor */ Polygon: Polygon, /** * Not a shape (null object pattern). * * @param {String} label name of this area. * @constructor */ NullShape: NullShape, /** * Make a new SVG DOM element as a child of svg. * * @param {SVGElement} svg the parent node. * @param {String} tagName the tag name. * @return {SVGElement} the newly created node. */ createSvgElement: createSvgElement, /** * Make a shape of the given type. * * @param {String} shapeType * @param {String} label * @return {Shape} the requested shape. */ make: function(shapeType, label) { switch (shapeType) { case 'circle': return new Circle(label); case 'rectangle': return new Rectangle(label); case 'polygon': return new Polygon(label); default: return new NullShape(label); } }, /** * Make a shape of the given type that is similar to the shape of the original type. * * @param {String} shapeType the new type of shape to make * @param {Shape} shape the shape to copy * @return {Shape} the similar shape of a different type. */ getSimilar: function(shapeType, shape) { if (shapeType === shape.getType()) { return shape; } switch (shapeType) { case 'circle': return shape.makeSimilarCircle(); case 'rectangle': return shape.makeSimilarRectangle(); case 'polygon': return shape.makeSimilarPolygon(); default: return new NullShape(shape.label); } } }; }); PK\jH!type/ddmarker/amd/src/question.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Question class for drag and drop marker question type, used to support the question and preview pages. * * @module qtype_ddmarker/question * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([ 'jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes', 'core_form/changechecker', 'core_filters/events', ], function( $, dragDrop, Shapes, keys, FormChangeChecker, filterEvent ) { "use strict"; /** * Object to handle one drag-drop markers question. * * @param {String} containerId id of the outer div for this question. * @param {boolean} readOnly whether the question is being displayed read-only. * @param {Object[]} visibleDropZones the geometry of any drop-zones to show. * Objects have fields shape, coords and markertext. * @constructor */ function DragDropMarkersQuestion(containerId, readOnly, visibleDropZones) { var thisQ = this; this.containerId = containerId; this.visibleDropZones = visibleDropZones; this.shapes = []; this.shapeSVGs = []; this.isPrinting = false; this.questionAnswer = {}; if (readOnly) { this.getRoot().addClass('qtype_ddmarker-readonly'); } thisQ.allImagesLoaded = false; thisQ.getNotYetLoadedImages().one('load', function() { thisQ.waitForAllImagesToBeLoaded(); }); thisQ.waitForAllImagesToBeLoaded(); } /** * Draws the svg shapes of any drop zones that should be visible for feedback purposes. */ DragDropMarkersQuestion.prototype.drawDropzones = function() { if (this.visibleDropZones.length > 0) { var bgImage = this.bgImage(); this.getRoot().find('div.dropzones').html(''); var svg = this.getRoot().find('svg.dropzones'); var nextColourIndex = 0; for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) { var colourClass = 'color' + nextColourIndex; nextColourIndex = (nextColourIndex + 1) % 8; this.addDropzone(svg, dropZoneNo, colourClass); } } }; /** * Adds a dropzone shape with colour, coords and link provided to the array of shapes. * * @param {jQuery} svg the SVG image to which to add this drop zone. * @param {int} dropZoneNo which drop-zone to add. * @param {string} colourClass class name */ DragDropMarkersQuestion.prototype.addDropzone = function(svg, dropZoneNo, colourClass) { var dropZone = this.visibleDropZones[dropZoneNo], shape = Shapes.make(dropZone.shape, ''), existingmarkertext, bgRatio = this.bgRatio(); if (!shape.parse(dropZone.coords, bgRatio)) { return; } existingmarkertext = this.getRoot().find('div.markertexts span.markertext' + dropZoneNo); if (existingmarkertext.length) { if (dropZone.markertext !== '') { existingmarkertext.html(dropZone.markertext); filterEvent.notifyFilterContentUpdated(existingmarkertext); } else { existingmarkertext.remove(); } } else if (dropZone.markertext !== '') { var classnames = 'markertext markertext' + dropZoneNo; this.getRoot().find('div.markertexts').append('' + dropZone.markertext + ''); var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo); if (markerspan.length) { var handles = shape.getHandlePositions(); var positionLeft = handles.moveHandle.x - (markerspan.outerWidth() / 2) - 4; var positionTop = handles.moveHandle.y - (markerspan.outerHeight() / 2); markerspan .css('left', positionLeft) .css('top', positionTop); markerspan .data('originX', markerspan.position().left / bgRatio) .data('originY', markerspan.position().top / bgRatio); this.handleElementScale(markerspan, 'center'); } filterEvent.notifyFilterContentUpdated(markerspan); } var shapeSVG = shape.makeSvg(svg[0]); shapeSVG.setAttribute('class', 'dropzone ' + colourClass); this.shapes[this.shapes.length] = shape; this.shapeSVGs[this.shapeSVGs.length] = shapeSVG; }; /** * Draws the drag items on the page (and drop zones if required). * The idea is to re-draw all the drags and drops whenever there is a change * like a widow resize or an item dropped in place. */ DragDropMarkersQuestion.prototype.repositionDrags = function() { var root = this.getRoot(), thisQ = this; root.find('div.draghomes .marker').not('.dragplaceholder').each(function(key, item) { $(item).addClass('unneeded'); }); root.find('input.choices').each(function(key, input) { var choiceNo = thisQ.getChoiceNoFromElement(input), imageCoords = thisQ.getImageCoords(input); if (imageCoords.length) { var drag = thisQ.getRoot().find('.draghomes' + ' span.marker' + '.choice' + choiceNo).not('.dragplaceholder'); drag.remove(); for (var i = 0; i < imageCoords.length; i++) { var dragInDrop = drag.clone(); // Convert image coords to screen coords. const screenCoords = thisQ.convertToWindowXY(imageCoords[i]); dragInDrop.data('pagex', screenCoords.x).data('pagey', screenCoords.y); // Save image coords to the drag item so we can use it later. dragInDrop.data('imageCoords', imageCoords[i]); // We always save the coordinates in the 1:1 ratio. // So we need to set the scale ratio to 1 for the initial load. dragInDrop.data('scaleRatio', 1); thisQ.sendDragToDrop(dragInDrop, false, true); } thisQ.getDragClone(drag).addClass('active'); thisQ.cloneDragIfNeeded(drag); } }); // Save the question answer. thisQ.questionAnswer = thisQ.getQuestionAnsweredValues(); }; /** * Get the question answered values. * * @return {Object} Contain key-value with key is the input id and value is the input value. */ DragDropMarkersQuestion.prototype.getQuestionAnsweredValues = function() { let result = {}; this.getRoot().find('input.choices').each((i, inputNode) => { result[inputNode.id] = inputNode.value; }); return result; }; /** * Check if the question is being interacted or not. * * @return {boolean} Return true if the user has changed the question-answer. */ DragDropMarkersQuestion.prototype.isQuestionInteracted = function() { const oldAnswer = this.questionAnswer; const newAnswer = this.getQuestionAnsweredValues(); let isInteracted = false; // First, check both answers have the same structure or not. if (JSON.stringify(newAnswer) !== JSON.stringify(oldAnswer)) { isInteracted = true; return isInteracted; } // Check the values. Object.keys(newAnswer).forEach(key => { if (newAnswer[key] !== oldAnswer[key]) { isInteracted = true; } }); return isInteracted; }; /** * Determine what drag items need to be shown and * return coords of all drag items except any that are currently being dragged * based on contents of hidden inputs and whether drags are 'infinite' or how many * drags should be shown. * * @param {jQuery} inputNode * @returns {Point[]} image coordinates of however many copies of the drag item should be shown. */ DragDropMarkersQuestion.prototype.getImageCoords = function(inputNode) { var imageCoords = [], val = $(inputNode).val(); if (val !== '') { var coordsStrings = val.split(';'); for (var i = 0; i < coordsStrings.length; i++) { imageCoords[i] = Shapes.Point.parse(coordsStrings[i]); } } return imageCoords; }; /** * Converts the relative x and y position coordinates into * absolute x and y position coordinates. * * @param {Point} point relative to the background image. * @returns {Point} point relative to the page. */ DragDropMarkersQuestion.prototype.convertToWindowXY = function(point) { var bgImage = this.bgImage(); // The +1 seems rather odd, but seems to give the best results in // the three main browsers at a range of zoom levels. // (Its due to the 1px border around the image, that shifts the // image pixels by 1 down and to the left.) return point.offset(bgImage.offset().left + 1, bgImage.offset().top + 1); }; /** * Utility function converting window coordinates to relative to the * background image coordinates. * * @param {Point} point relative to the page. * @returns {Point} point relative to the background image. */ DragDropMarkersQuestion.prototype.convertToBgImgXY = function(point) { var bgImage = this.bgImage(); return point.offset(-bgImage.offset().left - 1, -bgImage.offset().top - 1); }; /** * Is the point within the background image? * * @param {Point} point relative to the BG image. * @return {boolean} true it they are. */ DragDropMarkersQuestion.prototype.coordsInBgImg = function(point) { var bgImage = this.bgImage(); var bgPosition = bgImage.offset(); return point.x >= bgPosition.left && point.x < bgPosition.left + bgImage.width() && point.y >= bgPosition.top && point.y < bgPosition.top + bgImage.height(); }; /** * Get the outer div for this question. * @returns {jQuery} containing that div. */ DragDropMarkersQuestion.prototype.getRoot = function() { return $(document.getElementById(this.containerId)); }; /** * Get the img that is the background image. * @returns {jQuery} containing that img. */ DragDropMarkersQuestion.prototype.bgImage = function() { return this.getRoot().find('img.dropbackground'); }; DragDropMarkersQuestion.prototype.handleDragStart = function(e) { var thisQ = this, dragged = $(e.target).closest('.marker'); var info = dragDrop.prepare(e); if (!info.start) { return; } dragged.addClass('beingdragged').css('transform', ''); var placed = !dragged.hasClass('unneeded'); if (!placed) { var hiddenDrag = thisQ.getDragClone(dragged); if (hiddenDrag.length) { hiddenDrag.addClass('active'); dragged.offset(hiddenDrag.offset()); } } dragDrop.start(e, dragged, function() { void (1); }, function(x, y, dragged) { thisQ.dragEnd(dragged); }); }; /** * Functionality at the end of a drag drop. * @param {jQuery} dragged the marker that was dragged. */ DragDropMarkersQuestion.prototype.dragEnd = function(dragged) { var placed = false, choiceNo = this.getChoiceNoFromElement(dragged), bgRatio = this.bgRatio(), dragXY; dragged.data('pagex', dragged.offset().left).data('pagey', dragged.offset().top); dragXY = new Shapes.Point(dragged.data('pagex'), dragged.data('pagey')); if (this.coordsInBgImg(dragXY)) { this.sendDragToDrop(dragged, true); placed = true; // Since we already move the drag item to new position. // Remove the image coords if this drag item have it. // We will get the new image coords for this drag item in saveCoordsForChoice. if (dragged.data('imageCoords')) { dragged.data('imageCoords', null); } // It seems that the dragdrop sometimes leaves the drag // one pixel out of position. Put it in exactly the right place. var bgImgXY = this.convertToBgImgXY(dragXY); bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio); dragged.data('originX', bgImgXY.x).data('originY', bgImgXY.y); } if (!placed) { this.sendDragHome(dragged); this.removeDragIfNeeded(dragged); } else { this.cloneDragIfNeeded(dragged); } this.saveCoordsForChoice(choiceNo); }; /** * Save the coordinates for a dropped item in the form field. * @param {Number} choiceNo which copy of the choice this was. */ DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo) { let imageCoords = []; var items = this.getRoot().find('div.droparea span.marker.choice' + choiceNo), thiQ = this, bgRatio = this.bgRatio(); if (items.length) { items.each(function() { var drag = $(this); if (!drag.hasClass('beingdragged') && !drag.data('imageCoords')) { if (drag.data('scaleRatio') !== bgRatio) { // The scale ratio for the draggable item was changed. We need to update that. drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top); } var dragXY = new Shapes.Point(drag.data('pagex'), drag.data('pagey')); if (thiQ.coordsInBgImg(dragXY)) { var bgImgXY = thiQ.convertToBgImgXY(dragXY); bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio); imageCoords[imageCoords.length] = bgImgXY; } } else if (drag.data('imageCoords')) { imageCoords[imageCoords.length] = drag.data('imageCoords'); } }); } this.getRoot().find('input.choice' + choiceNo).val(imageCoords.join(';')); if (this.isQuestionInteracted()) { // The user has interacted with the draggable items. We need to mark the form as dirty. questionManager.handleFormDirty(); // Save the new answered value. this.questionAnswer = this.getQuestionAnsweredValues(); } }; /** * Handle key down / press events on markers. * @param {KeyboardEvent} e */ DragDropMarkersQuestion.prototype.handleKeyPress = function(e) { var drag = $(e.target).closest('.marker'), point = new Shapes.Point(drag.offset().left, drag.offset().top), choiceNo = this.getChoiceNoFromElement(drag); switch (e.keyCode) { case keys.arrowLeft: case 65: // A. point.x -= 1; break; case keys.arrowRight: case 68: // D. point.x += 1; break; case keys.arrowDown: case 83: // S. point.y += 1; break; case keys.arrowUp: case 87: // W. point.y -= 1; break; case keys.space: case keys.escape: point = null; break; default: return; // Ingore other keys. } e.preventDefault(); if (point !== null) { point = this.constrainToBgImg(point); drag.offset({'left': point.x, 'top': point.y}); drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top); var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey'))); drag.data('originX', dragXY.x / this.bgRatio()).data('originY', dragXY.y / this.bgRatio()); if (this.coordsInBgImg(new Shapes.Point(drag.offset().left, drag.offset().top))) { if (drag.hasClass('unneeded')) { this.sendDragToDrop(drag, true); var hiddenDrag = this.getDragClone(drag); if (hiddenDrag.length) { hiddenDrag.addClass('active'); } this.cloneDragIfNeeded(drag); } } } else { drag.css('left', '').css('top', ''); drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top); this.sendDragHome(drag); this.removeDragIfNeeded(drag); } drag.focus(); this.saveCoordsForChoice(choiceNo); }; /** * Makes sure the dragged item always exists within the background image area. * * @param {Point} windowxy * @returns {Point} coordinates */ DragDropMarkersQuestion.prototype.constrainToBgImg = function(windowxy) { var bgImg = this.bgImage(), bgImgXY = this.convertToBgImgXY(windowxy); bgImgXY.x = Math.max(0, bgImgXY.x); bgImgXY.y = Math.max(0, bgImgXY.y); bgImgXY.x = Math.min(bgImg.width(), bgImgXY.x); bgImgXY.y = Math.min(bgImg.height(), bgImgXY.y); return this.convertToWindowXY(bgImgXY); }; /** * Returns the choice number for a node. * * @param {Element|jQuery} node * @returns {Number} */ DragDropMarkersQuestion.prototype.getChoiceNoFromElement = function(node) { return Number(this.getClassnameNumericSuffix(node, 'choice')); }; /** * Returns the numeric part of a class with the given prefix. * * @param {Element|jQuery} node * @param {String} prefix * @returns {Number|null} */ DragDropMarkersQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) { var classes = $(node).attr('class'); if (classes !== undefined && classes !== '') { var classesarr = classes.split(' '); for (var index = 0; index < classesarr.length; index++) { var patt1 = new RegExp('^' + prefix + '([0-9])+$'); if (patt1.test(classesarr[index])) { var patt2 = new RegExp('([0-9])+$'); var match = patt2.exec(classesarr[index]); return Number(match[0]); } } } return null; }; /** * Handle when the window is resized. */ DragDropMarkersQuestion.prototype.handleResize = function() { var thisQ = this, bgRatio = this.bgRatio(); if (this.isPrinting) { bgRatio = 1; } this.getRoot().find('div.droparea .marker').not('.beingdragged').each(function(key, drag) { $(drag) .css('left', parseFloat($(drag).data('originX')) * parseFloat(bgRatio)) .css('top', parseFloat($(drag).data('originY')) * parseFloat(bgRatio)); thisQ.handleElementScale(drag, 'left top'); }); this.getRoot().find('div.droparea svg.dropzones') .width(this.bgImage().width()) .height(this.bgImage().height()); for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) { var dropZone = thisQ.visibleDropZones[dropZoneNo]; var originCoords = dropZone.coords; var shape = thisQ.shapes[dropZoneNo]; var shapeSVG = thisQ.shapeSVGs[dropZoneNo]; shape.parse(originCoords, bgRatio); shape.updateSvg(shapeSVG); var handles = shape.getHandlePositions(); var markerSpan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo); markerSpan .css('left', handles.moveHandle.x - (markerSpan.outerWidth() / 2) - 4) .css('top', handles.moveHandle.y - (markerSpan.outerHeight() / 2)); thisQ.handleElementScale(markerSpan, 'center'); } }; /** * Clone the drag. */ DragDropMarkersQuestion.prototype.cloneDrags = function() { var thisQ = this; this.getRoot().find('div.draghomes span.marker').each(function(index, draghome) { var drag = $(draghome); var placeHolder = drag.clone(); placeHolder.removeClass(); placeHolder.addClass('marker'); placeHolder.addClass('choice' + thisQ.getChoiceNoFromElement(drag)); placeHolder.addClass(thisQ.getDragNoClass(drag, false)); placeHolder.addClass('dragplaceholder'); drag.before(placeHolder); }); }; /** * Get the drag number of a drag. * * @param {jQuery} drag the drag. * @returns {Number} the drag number. */ DragDropMarkersQuestion.prototype.getDragNo = function(drag) { return this.getClassnameNumericSuffix(drag, 'dragno'); }; /** * Get the drag number prefix of a drag. * * @param {jQuery} drag the drag. * @param {Boolean} includeSelector include the CSS selector prefix or not. * @return {String} Class name */ DragDropMarkersQuestion.prototype.getDragNoClass = function(drag, includeSelector) { var className = 'dragno' + this.getDragNo(drag); if (this.isInfiniteDrag(drag)) { className = 'infinite'; } if (includeSelector) { return '.' + className; } return className; }; /** * Get drag clone for a given drag. * * @param {jQuery} drag the drag. * @returns {jQuery} the drag's clone. */ DragDropMarkersQuestion.prototype.getDragClone = function(drag) { return this.getRoot().find('.draghomes' + ' span.marker' + '.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true) + '.dragplaceholder'); }; /** * Get the drop area element. * @returns {jQuery} droparea element. */ DragDropMarkersQuestion.prototype.dropArea = function() { return this.getRoot().find('div.droparea'); }; /** * Animate a drag back to its home. * * @param {jQuery} drag the item being moved. */ DragDropMarkersQuestion.prototype.sendDragHome = function(drag) { drag.removeClass('beingdragged') .addClass('unneeded') .css('top', '') .css('left', '') .css('transform', ''); var placeHolder = this.getDragClone(drag); placeHolder.after(drag); placeHolder.removeClass('active'); }; /** * Animate a drag item into a given place. * * @param {jQuery} drag the item to place. * @param {boolean} isScaling Scaling or not. * @param {boolean} initialLoad Whether it is the initial load or not. */ DragDropMarkersQuestion.prototype.sendDragToDrop = function(drag, isScaling, initialLoad = false) { var dropArea = this.dropArea(), bgRatio = this.bgRatio(); drag.removeClass('beingdragged').removeClass('unneeded'); var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey'))); if (isScaling) { drag.data('originX', dragXY.x / bgRatio).data('originY', dragXY.y / bgRatio); drag.css('left', dragXY.x).css('top', dragXY.y); } else { drag.data('originX', dragXY.x).data('originY', dragXY.y); drag.css('left', dragXY.x * bgRatio).css('top', dragXY.y * bgRatio); } // We need to save the original scale ratio for each draggable item. if (!initialLoad) { // Only set the scale ratio for a current being-dragged item, not for the initial loading. drag.data('scaleRatio', bgRatio); } dropArea.append(drag); this.handleElementScale(drag, 'left top'); }; /** * Clone the drag at the draghome area if needed. * * @param {jQuery} drag the item to place. */ DragDropMarkersQuestion.prototype.cloneDragIfNeeded = function(drag) { var inputNode = this.getInput(drag), noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')), displayedDragsInDropArea = this.getRoot().find('div.droparea .marker.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).length, displayedDragsInDragHomes = this.getRoot().find('div.draghomes .marker.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder').length; if ((this.isInfiniteDrag(drag) || !this.isInfiniteDrag(drag) && displayedDragsInDropArea < noOfDrags) && displayedDragsInDragHomes === 0) { var dragClone = drag.clone(); dragClone.addClass('unneeded') .css('top', '') .css('left', '') .css('transform', ''); this.getDragClone(drag) .removeClass('active') .after(dragClone); questionManager.addEventHandlersToMarker(dragClone); } }; /** * Remove the clone drag at the draghome area if needed. * * @param {jQuery} drag the item to place. */ DragDropMarkersQuestion.prototype.removeDragIfNeeded = function(drag) { var dragsInHome = this.getRoot().find('div.draghomes .marker.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder'); var displayedDrags = dragsInHome.length; while (displayedDrags > 1) { dragsInHome.first().remove(); displayedDrags--; } }; /** * Get the input belong to drag. * * @param {jQuery} drag the item to place. * @returns {jQuery} input element. */ DragDropMarkersQuestion.prototype.getInput = function(drag) { var choiceNo = this.getChoiceNoFromElement(drag); return this.getRoot().find('input.choices.choice' + choiceNo); }; /** * Return the background ratio. * * @returns {number} Background ratio. */ DragDropMarkersQuestion.prototype.bgRatio = function() { var bgImg = this.bgImage(); var bgImgNaturalWidth = bgImg.get(0).naturalWidth; var bgImgClientWidth = bgImg.width(); return bgImgClientWidth / bgImgNaturalWidth; }; /** * Scale the drag if needed. * * @param {jQuery} element the item to place. * @param {String} type scaling type */ DragDropMarkersQuestion.prototype.handleElementScale = function(element, type) { var bgRatio = parseFloat(this.bgRatio()); if (this.isPrinting) { bgRatio = 1; } $(element).css({ '-webkit-transform': 'scale(' + bgRatio + ')', '-moz-transform': 'scale(' + bgRatio + ')', '-ms-transform': 'scale(' + bgRatio + ')', '-o-transform': 'scale(' + bgRatio + ')', 'transform': 'scale(' + bgRatio + ')', 'transform-origin': type }); }; /** * Check if the given drag is in infinite mode or not. * * @param {jQuery} drag The drag item need to check. */ DragDropMarkersQuestion.prototype.isInfiniteDrag = function(drag) { return drag.hasClass('infinite'); }; /** * Waits until all images are loaded before calling setupQuestion(). * * This function is called from the onLoad of each image, and also polls with * a time-out, because image on-loads are allegedly unreliable. */ DragDropMarkersQuestion.prototype.waitForAllImagesToBeLoaded = function() { var thisQ = this; // This method may get called multiple times (via image on-loads or timeouts. // If we are already done, don't do it again. if (this.allImagesLoaded) { return; } // Clear any current timeout, if set. if (this.imageLoadingTimeoutId !== null) { clearTimeout(this.imageLoadingTimeoutId); } // If we have not yet loaded all images, set a timeout to // call ourselves again, since apparently images on-load // events are flakey. if (this.getNotYetLoadedImages().length > 0) { this.imageLoadingTimeoutId = setTimeout(function() { this.waitForAllImagesToBeLoaded(); }, 100); return; } // We now have all images. Carry on, but only after giving the layout a chance to settle down. this.allImagesLoaded = true; this.cloneDrags(); this.repositionDrags(); this.drawDropzones(); // Wait for all dynamic content loaded by filter to be completed. document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete, (elements) => { elements.detail.nodes.forEach((element) => { thisQ.changeAllMakerToFilteredContent(element); }); }); }; /** * Change all the maker related to the item that has been changed by filter to correct size and content. * * @param {object} filteredElement the element has been modified by filter. */ DragDropMarkersQuestion.prototype.changeAllMakerToFilteredContent = function(filteredElement) { let currentFilteredItem = $(filteredElement); const parentIsMarker = currentFilteredItem.parent().closest('span.marker'); const isMarker = currentFilteredItem.hasClass('marker'); const root = this.getRoot(); // The filtered element or parent element should a drag or drop item. if (!parentIsMarker && !isMarker) { return; } if (parentIsMarker) { currentFilteredItem = currentFilteredItem.parent().closest('span.marker'); } if (root.find(currentFilteredItem).length <= 0) { // If the maker doesn't belong to this question // In case we have multiple questions in the same page. return; } const dragNo = this.getDragNo(currentFilteredItem); const choiceNo = this.getChoiceNoFromElement(currentFilteredItem); const listOfContainerToBeModifed = [ 'div.draghomes .marker:not(.dragplaceholder).dragno' + dragNo + '.choice' + choiceNo, 'div.droparea .marker:not(.dragplaceholder).dragno' + dragNo + '.choice' + choiceNo, 'div.draghomes .marker:not(.dragplaceholder).infinite.choice' + choiceNo, 'div.droparea .marker:not(.dragplaceholder).infinite.choice' + choiceNo ]; let listOfModifiedDragDrop = []; // We want to keep all the handler and event for filtered item, so using clone is the only choice. const filteredDragDropClone = currentFilteredItem.clone(); listOfContainerToBeModifed.forEach(function(selector) { root.find(selector).each(function(i, node) { const originalClass = $(node).attr('class'); const originalStyle = $(node).attr('style'); // Replace the class and style of the maker we want to replace for the clone. filteredDragDropClone.attr('class', originalClass); filteredDragDropClone.attr('style', originalStyle); // Add event for the clone. questionManager.addEventHandlersToMarker(filteredDragDropClone); // Insert into DOM. $(node).before(filteredDragDropClone); listOfModifiedDragDrop.push(node); }); }); listOfModifiedDragDrop.forEach(function(node) { $(node).remove(); }); }; /** * Get any of the images in the drag-drop area that are not yet fully loaded. * * @returns {jQuery} those images. */ DragDropMarkersQuestion.prototype.getNotYetLoadedImages = function() { return this.getRoot().find('.ddmarker img.dropbackground').not(function(i, imgNode) { return this.imageIsLoaded(imgNode); }); }; /** * Check if an image has loaded without errors. * * @param {HTMLImageElement} imgElement an image. * @returns {boolean} true if this image has loaded without errors. */ DragDropMarkersQuestion.prototype.imageIsLoaded = function(imgElement) { return imgElement.complete && imgElement.naturalHeight !== 0; }; /** * Singleton that tracks all the DragDropToTextQuestions on this page, and deals * with event dispatching. * * @type {Object} */ var questionManager = { /** * {boolean} ensures that the event handlers are only initialised once per page. */ eventHandlersInitialised: false, /** * {Object} ensures that the marker event handlers are only initialised once per question, * indexed by containerId (id on the .que div). */ markerEventHandlersInitialised: {}, /** * {boolean} is printing or not. */ isPrinting: false, /** * {boolean} is keyboard navigation. */ isKeyboardNavigation: false, /** * {Object} all the questions on this page, indexed by containerId (id on the .que div). */ questions: {}, // An object containing all the information about each question on the page. /** * Initialise one question. * * @param {String} containerId the id of the div.que that contains this question. * @param {boolean} readOnly whether the question is read-only. * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback. */ init: function(containerId, readOnly, visibleDropZones) { questionManager.questions[containerId] = new DragDropMarkersQuestion(containerId, readOnly, visibleDropZones); if (!questionManager.eventHandlersInitialised) { questionManager.setupEventHandlers(); questionManager.eventHandlersInitialised = true; } if (!questionManager.markerEventHandlersInitialised.hasOwnProperty(containerId)) { questionManager.markerEventHandlersInitialised[containerId] = true; // We do not use the body event here to prevent the other event on Mobile device, such as scroll event. var questionContainer = document.getElementById(containerId); if (questionContainer.classList.contains('ddmarker') && !questionContainer.classList.contains('qtype_ddmarker-readonly')) { // TODO: Convert all the jQuery selectors and events to native Javascript. questionManager.addEventHandlersToMarker($(questionContainer).find('div.draghomes .marker')); questionManager.addEventHandlersToMarker($(questionContainer).find('div.droparea .marker')); } } }, /** * Set up the event handlers that make this question type work. (Done once per page.) */ setupEventHandlers: function() { $(window).on('resize', function() { questionManager.handleWindowResize(false); }); window.addEventListener('beforeprint', function() { questionManager.isPrinting = true; questionManager.handleWindowResize(questionManager.isPrinting); }); window.addEventListener('afterprint', function() { questionManager.isPrinting = false; questionManager.handleWindowResize(questionManager.isPrinting); }); setTimeout(function() { questionManager.fixLayoutIfThingsMoved(); }, 100); }, /** * Binding the event again for newly created element. * * @param {jQuery} element Element to bind the event */ addEventHandlersToMarker: function(element) { element .on('mousedown touchstart', questionManager.handleDragStart) .on('keydown keypress', questionManager.handleKeyPress) .focusin(function(e) { questionManager.handleKeyboardFocus(e, true); }) .focusout(function(e) { questionManager.handleKeyboardFocus(e, false); }); }, /** * Handle mouse down / touch start events on markers. * @param {Event} e the DOM event. */ handleDragStart: function(e) { e.preventDefault(); var question = questionManager.getQuestionForEvent(e); if (question) { question.handleDragStart(e); } }, /** * Handle key down / press events on markers. * @param {Event} e */ handleKeyPress: function(e) { var question = questionManager.getQuestionForEvent(e); if (question) { question.handleKeyPress(e); } }, /** * Handle when the window is resized. * @param {boolean} isPrinting */ handleWindowResize: function(isPrinting) { for (var containerId in questionManager.questions) { if (questionManager.questions.hasOwnProperty(containerId)) { questionManager.questions[containerId].isPrinting = isPrinting; questionManager.questions[containerId].handleResize(); } } }, /** * Handle focus lost events on markers. * @param {Event} e * @param {boolean} isNavigating */ handleKeyboardFocus: function(e, isNavigating) { questionManager.isKeyboardNavigation = isNavigating; }, /** * Sometimes, despite our best efforts, things change in a way that cannot * be specifically caught (e.g. dock expanding or collapsing in Boost). * Therefore, we need to periodically check everything is in the right position. */ fixLayoutIfThingsMoved: function() { if (!questionManager.isKeyboardNavigation) { this.handleWindowResize(questionManager.isPrinting); } // We use setTimeout after finishing work, rather than setInterval, // in case positioning things is slow. We want 100 ms gap // between executions, not what setInterval does. setTimeout(function() { questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting); }, 100); }, /** * Given an event, work out which question it effects. * @param {Event} e the event. * @returns {DragDropMarkersQuestion|undefined} The question, or undefined. */ getQuestionForEvent: function(e) { var containerId = $(e.currentTarget).closest('.que.ddmarker').attr('id'); return questionManager.questions[containerId]; }, /** * Handle when the form is dirty. */ handleFormDirty: function() { const responseForm = document.getElementById('responseform'); FormChangeChecker.markFormAsDirty(responseForm); } }; /** * @alias module:qtype_ddmarker/question */ return { /** * Initialise one drag-drop markers question. * * @param {String} containerId id of the outer div for this question. * @param {String} bgImgUrl the URL of the background image. * @param {boolean} readOnly whether the question is being displayed read-only. * @param {String[]} visibleDropZones the geometry of any drop-zones to show. */ init: questionManager.init }; }); PK\yfjjtype/ddmarker/amd/src/form.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * This class provides the enhancements to the drag-drop marker editing form. * * @module qtype_ddmarker/form * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes'], function($, dragDrop, Shapes) { "use strict"; /** * Create the manager object that deals with keeping everything synchronised for one drop zone. * * @param {int} dropzoneNo the index of this drop zone in the form. 0, 1, .... * @constructor */ function DropZoneManager(dropzoneNo) { this.dropzoneNo = dropzoneNo; this.svgEl = null; this.shape = Shapes.make(this.getShapeType(), this.getLabel()); this.updateCoordinatesFromForm(); } /** * Update the coordinates from a particular string. * * @param {SVGElement} [svg] the SVG element that is the preview. */ DropZoneManager.prototype.updateCoordinatesFromForm = function(svg) { var coordinates = this.getCoordinates(), currentNumPoints = this.shape.getType() === 'polygon' && this.shape.points.length; if (this.shape.getCoordinates() === coordinates) { return; } // We don't need to scale the shape for editing form. if (!this.shape.parse(coordinates, 1)) { // Invalid coordinates. Don't update the preview. return; } if (this.shape.getType() === 'polygon' && currentNumPoints !== this.shape.points.length) { // Polygon, and size has changed. var currentyActive = this.isActive(); this.removeFromSvg(); if (svg) { this.addToSvg(svg); if (currentyActive) { this.setActive(); } } } else { // Simple update. this.updateSvgEl(); } // Update the rounded coordinates if needed. this.setCoordinatesInForm(); }; /** * Update the label. */ DropZoneManager.prototype.updateLabel = function() { var label = this.getLabel(); if (this.shape.label !== label) { this.shape.label = label; this.updateSvgEl(); } }; /** * Handle if the type of shape has changed. * * @param {SVGElement} [svg] an SVG element to add this new shape to. */ DropZoneManager.prototype.changeShape = function(svg) { var newShapeType = this.getShapeType(), currentyActive = this.isActive(); if (newShapeType === this.shape.getType()) { return; } // It has really changed. this.removeFromSvg(); this.shape = Shapes.getSimilar(newShapeType, this.shape); if (svg) { this.addToSvg(svg); if (currentyActive) { this.setActive(); } } this.setCoordinatesInForm(); }; /** * Add this drop zone to an SVG graphic. * * @param {SVGElement} svg the SVG image to which to add this drop zone. */ DropZoneManager.prototype.addToSvg = function(svg) { if (this.svgEl !== null) { throw new Error('this.svgEl already set'); } this.svgEl = this.shape.makeSvg(svg); if (!this.svgEl) { return; } this.svgEl.setAttribute('class', 'dropzone'); this.svgEl.setAttribute('data-dropzone-no', this.dropzoneNo); // Add handles. var handles = this.shape.getHandlePositions(); if (handles === null) { return; } var moveHandle = Shapes.createSvgElement(this.svgEl, 'circle'); moveHandle.setAttribute('cx', handles.moveHandle.x); moveHandle.setAttribute('cy', handles.moveHandle.y); moveHandle.setAttribute('r', 7); moveHandle.setAttribute('class', 'handle move'); for (var i = 0; i < handles.editHandles.length; ++i) { this.makeEditHandle(i, handles.editHandles[i]); } }; /** * Add a new edit handle. * * @param {int} index the handle index. * @param {Point} point the point at which to add the handle. */ DropZoneManager.prototype.makeEditHandle = function(index, point) { var editHandle = Shapes.createSvgElement(this.svgEl, 'rect'); editHandle.setAttribute('x', point.x - 6); editHandle.setAttribute('y', point.y - 6); editHandle.setAttribute('width', 11); editHandle.setAttribute('height', 11); editHandle.setAttribute('class', 'handle edit'); editHandle.setAttribute('data-edit-handle-no', index); }; /** * Remove this drop zone from an SVG image. */ DropZoneManager.prototype.removeFromSvg = function() { if (this.svgEl !== null) { this.svgEl.parentNode.removeChild(this.svgEl); this.svgEl = null; } }; /** * Update the shape of this drop zone (but not type) in an SVG image. */ DropZoneManager.prototype.updateSvgEl = function() { if (this.svgEl === null) { return; } this.shape.updateSvg(this.svgEl); // Adjust handles. var handles = this.shape.getHandlePositions(); if (handles === null) { return; } // Move handle. // The shape + its label are the first two children of svgEl. // Then come the move handle followed by the edit handles. this.svgEl.childNodes[2].setAttribute('cx', handles.moveHandle.x); this.svgEl.childNodes[2].setAttribute('cy', handles.moveHandle.y); // Edit handles. for (var i = 0; i < handles.editHandles.length; ++i) { this.svgEl.childNodes[3 + i].setAttribute('x', handles.editHandles[i].x - 6); this.svgEl.childNodes[3 + i].setAttribute('y', handles.editHandles[i].y - 6); } }; /** * Find out of this drop zone is currently being edited. * * @return {boolean} true if it is. */ DropZoneManager.prototype.isActive = function() { return this.svgEl !== null && this.svgEl.getAttribute('class').match(/\bactive\b/); }; /** * Set this drop zone as being edited. */ DropZoneManager.prototype.setActive = function() { // Move this one to last, so that it is always on top. // (Otherwise the handles may not be able to receive events.) var parent = this.svgEl.parentNode; parent.removeChild(this.svgEl); parent.appendChild(this.svgEl); this.svgEl.setAttribute('class', this.svgEl.getAttribute('class') + ' active'); }; /** * Set the coordinates in the form to match the current shape. */ DropZoneManager.prototype.setCoordinatesInForm = function() { dragDropForm.form.setFormValue('drops', [this.dropzoneNo, 'coords'], this.shape.getCoordinates()); }; /** * Returns the coordinates for a drop zone from the text input in the form. * @returns {string} the coordinates. */ DropZoneManager.prototype.getCoordinates = function() { return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'coords']).replace(/\s*/g, ''); }; /** * Returns the selected marker number from the dropdown in the form. * @returns {int} choice number. */ DropZoneManager.prototype.getChoiceNo = function() { return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'choice']); }; /** * Returns the selected marker number from the dropdown in the form. * @returns {String} marker label text. */ DropZoneManager.prototype.getLabel = function() { return dragDropForm.form.getMarkerText(this.getChoiceNo()); }; /** * Returns the selected type of shape in the form. * @returns {String} 'circle', 'rectangle' or 'polygon'. */ DropZoneManager.prototype.getShapeType = function() { return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'shape']); }; /** * Start responding to dragging the move handle. * @param {Event} e Event object */ DropZoneManager.prototype.handleMove = function(e) { var info = dragDrop.prepare(e); if (!info.start) { return; } var movingDropZone = this, lastX = info.x, lastY = info.y, dragProxy = this.makeDragProxy(info.x, info.y), bgImg = $('fieldset#id_previewareaheader .dropbackground'), maxX = bgImg.width(), maxY = bgImg.height(); dragDrop.start(e, $(dragProxy), function(pageX, pageY) { movingDropZone.shape.move(pageX - lastX, pageY - lastY, maxX, maxY); lastX = pageX; lastY = pageY; movingDropZone.updateSvgEl(); movingDropZone.setCoordinatesInForm(); }, function() { document.body.removeChild(dragProxy); }); }; /** * Start responding to dragging the move handle. * @param {Event} e Event object * @param {int} handleIndex * @param {SVGElement} [svg] an SVG element to add this new shape to. */ DropZoneManager.prototype.handleEdit = function(e, handleIndex, svg) { var info = dragDrop.prepare(e); if (!info.start) { return; } // For polygons, CTRL + drag adds a new point. if (this.shape.getType() === 'polygon' && (e.ctrlKey || e.metaKey)) { this.shape.addNewPointAfter(handleIndex); this.removeFromSvg(); this.addToSvg(svg); this.setActive(); } var changingDropZone = this, lastX = info.x, lastY = info.y, dragProxy = this.makeDragProxy(info.x, info.y), bgImg = $('fieldset#id_previewareaheader .dropbackground'), maxX = bgImg.width(), maxY = bgImg.height(); dragDrop.start(e, $(dragProxy), function(pageX, pageY) { changingDropZone.shape.edit(handleIndex, pageX - lastX, pageY - lastY, maxX, maxY); lastX = pageX; lastY = pageY; changingDropZone.updateSvgEl(); changingDropZone.setCoordinatesInForm(); }, function() { document.body.removeChild(dragProxy); changingDropZone.shape.normalizeShape(); changingDropZone.updateSvgEl(); changingDropZone.setCoordinatesInForm(); }); }; /** * Make an invisible drag proxy. * * @param {int} x x position . * @param {int} y y position. * @returns {HTMLElement} the drag proxy. */ DropZoneManager.prototype.makeDragProxy = function(x, y) { var dragProxy = document.createElement('div'); dragProxy.style.position = 'absolute'; dragProxy.style.top = y + 'px'; dragProxy.style.left = x + 'px'; dragProxy.style.width = '1px'; dragProxy.style.height = '1px'; document.body.appendChild(dragProxy); return dragProxy; }; /** * Singleton object for managing all the parts of the form. */ var dragDropForm = { /** * @var {object} for interacting with the file pickers. */ fp: null, // Object containing functions associated with the file picker. /** * @var {int} the number of drop-zones on the form. */ noDropZones: null, /** * @var {DropZoneManager[]} the drop zones in the preview, indexed by drop zone number. */ dropZones: [], /** * Initialise the form. */ init: function() { dragDropForm.fp = dragDropForm.filePickers(); dragDropForm.noDropZones = dragDropForm.form.getFormValue('nodropzone', []); dragDropForm.setOptionsForDragItemSelectors(); dragDropForm.createShapes(); dragDropForm.setupEventHandlers(); dragDropForm.waitForFilePickerToInitialise(); }, /** * Add html for the preview area. */ setupPreviewArea: function() { $('fieldset#id_previewareaheader div.fcontainer').append( '
    ' + '
    ' + ' ' + '
    ' + '
    ' + '
    ' + '
    '); }, /** * When a new marker is added this function updates the Marker dropdown controls in Drop zones. */ setOptionsForDragItemSelectors: function() { var dragItemsOptions = {'0': ''}; var noItems = dragDropForm.form.getFormValue('noitems', []); var selectedValues = []; var selector; var i, label; for (i = 1; i <= noItems; i++) { label = dragDropForm.form.getMarkerText(i); if (label !== "") { // HTML escape the label. dragItemsOptions[i] = $('
    ').text(label).html(); } } // Get all the currently selected drags for each drop. for (i = 0; i < dragDropForm.noDropZones; i++) { selector = $('#id_drops_' + i + '_choice'); selectedValues[i] = Number(selector.val()); } for (i = 0; i < dragDropForm.noDropZones; i++) { selector = $('#id_drops_' + i + '_choice'); // Remove all options for drag choice. selector.find('option').remove(); // And recreate the options. for (var value in dragItemsOptions) { value = Number(value); var option = ''; selector.append(option); var optionnode = selector.find('option[value="' + value + '"]'); if (value === 0) { continue; // The 'no item' option is always selectable. } // Is this the currently selected value? if (value === selectedValues[i]) { optionnode.attr('selected', true); continue; // If it s selected, we must leave it enabled. } // Count how many times it is used, and if necessary, disable. var noofdrags = dragDropForm.form.getFormValue('drags', [value - 1, 'noofdrags']); if (Number(noofdrags) === 0) { // 'noofdrags === 0' means infinite. continue; // Nothing to check. } // Go through all selected values in drop downs. for (var k in selectedValues) { if (Number(selectedValues[k]) !== value) { continue; } // Count down 'noofdrags' and if reach zero then set disabled option for this drag item. if (Number(noofdrags) === 1) { optionnode.attr('disabled', true); break; } else { noofdrags--; } } } if (dragDropForm.dropZones.length > 0) { dragDropForm.dropZones[i].updateLabel(); } } }, /** * Create the shape representation of each dropZone. */ createShapes: function() { for (var dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) { dragDropForm.dropZones[dropzoneNo] = new DropZoneManager(dropzoneNo); } }, /** * Events linked to form actions. */ setupEventHandlers: function() { // Changes to labels in the Markers section. $('fieldset#id_draggableitemheader').on('change input', 'input, select', function() { dragDropForm.setOptionsForDragItemSelectors(); }); // Changes to Drop zones section: shape, coordinates and marker. $('fieldset#id_dropzoneheader').on('change input', 'input, select', function(e) { var ids = e.currentTarget.name.match(/^drops\[(\d+)]\[([a-z]*)]$/); if (!ids) { return; } var dropzoneNo = ids[1], inputType = ids[2], dropZone = dragDropForm.dropZones[dropzoneNo]; switch (inputType) { case 'shape': dropZone.changeShape(dragDropForm.form.getSvg()); break; case 'coords': dropZone.updateCoordinatesFromForm(dragDropForm.form.getSvg()); break; case 'choice': dropZone.updateLabel(); break; } }); // Click to toggle graphical editing. var previewArea = $('fieldset#id_previewareaheader'); previewArea.on('click', 'g.dropzone', function(e) { var dropzoneNo = $(e.currentTarget).data('dropzone-no'), currentlyActive = dragDropForm.dropZones[dropzoneNo].isActive(); $(dragDropForm.form.getSvg()).find('.dropzone.active').removeClass('active'); if (!currentlyActive) { dragDropForm.dropZones[dropzoneNo].setActive(); } }); // Drag start on a move handle. previewArea.on('mousedown touchstart', '.dropzone .handle.move', function(e) { var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo'); dragDropForm.dropZones[dropzoneNo].handleMove(e); }); // Drag start on a move handle. previewArea.on('mousedown touchstart', '.dropzone .handle.edit', function(e) { var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo'), handleIndex = e.currentTarget.getAttribute('data-edit-handle-no'); dragDropForm.dropZones[dropzoneNo].handleEdit(e, handleIndex, dragDropForm.form.getSvg()); }); }, /** * Prevents adding drop zones until the preview background image is ready to load. */ waitForFilePickerToInitialise: function() { if (dragDropForm.fp.file('bgimage').href === null) { // It would be better to use an onload or onchange event rather than this timeout. // Unfortunately attempts to do this early are overwritten by filepicker during its loading. setTimeout(dragDropForm.waitForFilePickerToInitialise, 1000); return; } // From now on, when a new file gets loaded into the filepicker, update the preview. // This is not in the setupEventHandlers section as it needs to be delayed until // after filepicker's javascript has finished. $('form.mform[data-qtype="ddmarker"]').on('change', '#id_bgimage', dragDropForm.loadPreviewImage); if ($('#ddm-droparea').length) { dragDropForm.loadPreviewImage(); } else { // Setup preview area when the background image is uploaded the first time. dragDropForm.setupPreviewArea(); dragDropForm.loadPreviewImage(); } }, /** * Loads the preview background image. */ loadPreviewImage: function() { $('fieldset#id_previewareaheader .dropbackground') .one('load', dragDropForm.afterPreviewImageLoaded) .attr('src', dragDropForm.fp.file('bgimage').href); }, /** * Functions to run after background image loaded. */ afterPreviewImageLoaded: function() { var bgImg = $('fieldset#id_previewareaheader .dropbackground'); // Place the dropzone area over the background image (adding one to account for the border). $('#ddm-dropzone').css('position', 'relative').css('top', (bgImg.height() + 1) * -1); $('#ddm-droparea').css('height', bgImg.height() + 20); dragDropForm.updateSvgDisplay(); }, /** * Draws or re-draws all dropzones in the preview area based on form data. * Call this function when there is a change in the form data. */ updateSvgDisplay: function() { var bgImg = $('fieldset#id_previewareaheader .dropbackground'), dropzoneNo; if (dragDropForm.form.getSvg()) { // Already exists, just need to be updated. for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) { dragDropForm.dropZones[dropzoneNo].updateSvgEl(); } } else { // Create. $('#ddm-dropzone').html(''); for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) { dragDropForm.dropZones[dropzoneNo].addToSvg(dragDropForm.form.getSvg()); } } }, /** * Helper to make it easy to work with form elements with names like "drops[0][shape]". */ form: { /** * Returns the label text for a marker. * @param {int} markerNo * @returns {string} Marker text */ getMarkerText: function(markerNo) { if (Number(markerNo) !== 0) { var label = dragDropForm.form.getFormValue('drags', [markerNo - 1, 'label']); return label.replace(new RegExp("^\\s*(.*)\\s*$"), "$1"); } else { return ''; } }, /** * Get the SVG element, if there is one, otherwise return null. * * @returns {SVGElement|null} the SVG element or null. */ getSvg: function() { var svg = $('fieldset#id_previewareaheader svg'); if (svg.length === 0) { return null; } else { return svg[0]; } }, toNameWithIndex: function(name, indexes) { var indexString = name; for (var i = 0; i < indexes.length; i++) { indexString = indexString + '[' + indexes[i] + ']'; } return indexString; }, getEl: function(name, indexes) { var form = $('form.mform[data-qtype="ddmarker"]')[0]; return form.elements[this.toNameWithIndex(name, indexes)]; }, /** * Helper to get the value of a form elements with name like "drops[0][shape]". * * @param {String} name the base name, e.g. 'drops'. * @param {String[]} indexes the indexes, e.g. ['0', 'shape']. * @return {String} the value of that field. */ getFormValue: function(name, indexes) { var el = this.getEl(name, indexes); if (el.type === 'checkbox') { return el.checked; } else { return el.value; } }, /** * Helper to get the value of a form elements with name like "drops[0][shape]". * * @param {String} name the base name, e.g. 'drops'. * @param {String[]} indexes the indexes, e.g. ['0', 'shape']. * @param {String} value the value to set. */ setFormValue: function(name, indexes, value) { var el = this.getEl(name, indexes); if (el.type === 'checkbox') { el.checked = value; } else { el.value = value; } } }, /** * Utility to get the file name and url from the filepicker. * @returns {Object} object containing functions {file, name} */ filePickers: function() { var draftItemIdsToName; var nameToParentNode; if (draftItemIdsToName === undefined) { draftItemIdsToName = {}; nameToParentNode = {}; $('form.mform input.filepickerhidden').each(function(key, filepicker) { draftItemIdsToName[filepicker.value] = filepicker.name; nameToParentNode[filepicker.name] = filepicker.parentNode; }); } return { file: function(name) { var fileAnchor = $(nameToParentNode[name]).find('div.filepicker-filelist a'); if (fileAnchor.length) { return {href: fileAnchor.get(0).href, name: fileAnchor.get(0).innerHTML}; } else { return {href: null, name: null}; } }, name: function(draftitemid) { return draftItemIdsToName[draftitemid]; } }; } }; /** * @alias module:qtype_ddmarker/form */ return { /** * Initialise the form javascript features. * @param {Object} maxBgimageSize object with two properties: width and height. */ init: dragDropForm.init }; }); PK\*`//%type/ddmarker/amd/build/shapes.min.jsnu[/** * Library of classes for handling simple shapes. * * These classes can represent shapes, let you alter them, can go to and from a string * representation, and can give you an SVG representation. * * @module qtype_ddmarker/shapes * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("qtype_ddmarker/shapes",(function(){function Point(x,y){this.x=x,this.y=y}function Shape(label,x,y){this.label=label,this.centre=new Point(x||0,y||0)}function Circle(label,x,y,radius){x=x||15,y=y||15,Shape.call(this,label,x,y),this.radius=radius||15}function Rectangle(label,x,y,width,height){Shape.call(this,label,x,y),this.width=width||30,this.height=height||30}function Polygon(label,points){Shape.call(this,label,0,0),this.points=points?points.slice():[new Point(10,10),new Point(40,10),new Point(10,40)],this.normalizeShape(),this.ratio=1}function NullShape(label){Shape.call(this,label)}function createSvgElement(svg,tagName){var svgEl=svg.ownerDocument.createElementNS("http://www.w3.org/2000/svg",tagName);return svg.appendChild(svgEl),svgEl}function createSvgShapeGroup(svg,tagName){var svgEl=createSvgElement(svg,"g");return createSvgElement(svgEl,tagName).setAttribute("class","shape"),createSvgElement(svgEl,"text").setAttribute("class","shapeLabel"),svgEl}return Point.prototype.toString=function(){return this.x+","+this.y},Point.prototype.move=function(dx,dy){this.x+=dx,this.y+=dy},Point.prototype.offset=function(offsetX,offsetY){return offsetX instanceof Point&&(offsetY=offsetX.y,offsetX=offsetX.x),new Point(this.x+offsetX,this.y+offsetY)},Point.parse=function(coordinates){var bits=coordinates.split(",");if(2!==bits.length)throw new Error(coordinates+" is not a valid point");return new Point(Math.round(bits[0]),Math.round(bits[1]))},Shape.prototype.getType=function(){throw new Error("Not implemented.")},Shape.prototype.getCoordinates=function(){throw new Error("Not implemented.")},Shape.prototype.parse=function(coordinates,ratio){throw new Error("Not implemented.")},Shape.prototype.move=function(dx,dy,maxX,maxY){},Shape.prototype.edit=function(handleIndex,dx,dy,maxX,maxY){},Shape.prototype.normalizeShape=function(){},Shape.prototype.makeSvg=function(svg){throw new Error("Not implemented.")},Shape.prototype.updateSvg=function(svgEl){},Shape.prototype.makeSimilarCircle=function(){throw new Error("Not implemented.")},Shape.prototype.makeSimilarRectangle=function(){throw new Error("Not implemented.")},Shape.prototype.makeSimilarPolygon=function(){throw new Error("Not implemented.")},Shape.prototype.getHandlePositions=function(){return null},Circle.prototype=new Shape,Circle.prototype.getType=function(){return"circle"},Circle.prototype.getCoordinates=function(){return this.centre+";"+Math.abs(this.radius)},Circle.prototype.makeSvg=function(svg){var svgEl=createSvgShapeGroup(svg,"circle");return this.updateSvg(svgEl),svgEl},Circle.prototype.updateSvg=function(svgEl){svgEl.childNodes[0].setAttribute("cx",this.centre.x),svgEl.childNodes[0].setAttribute("cy",this.centre.y),svgEl.childNodes[0].setAttribute("r",Math.abs(this.radius)),svgEl.childNodes[1].setAttribute("x",this.centre.x),svgEl.childNodes[1].setAttribute("y",this.centre.y+15),svgEl.childNodes[1].textContent=this.label},Circle.prototype.parse=function(coordinates,ratio){if(!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/))return!1;var bits=coordinates.split(";");return this.centre=Point.parse(bits[0]),this.centre.x=this.centre.x*parseFloat(ratio),this.centre.y=this.centre.y*parseFloat(ratio),this.radius=Math.round(bits[1])*parseFloat(ratio),!0},Circle.prototype.move=function(dx,dy,maxX,maxY){this.centre.move(dx,dy),this.centre.xmaxX-this.radius&&(this.centre.x=maxX-this.radius),this.centre.ymaxY-this.radius&&(this.centre.y=maxY-this.radius)},Circle.prototype.edit=function(handleIndex,dx,dy,maxX,maxY){this.radius+=dx;var limit=Math.min(this.centre.x,this.centre.y,maxX-this.centre.x,maxY-this.centre.y);this.radius>limit&&(this.radius=limit),this.radius<-limit&&(this.radius=-limit)},Circle.prototype.normalizeShape=function(){this.radius=Math.abs(this.radius)},Circle.prototype.makeSimilarRectangle=function(){return new Rectangle(this.label,this.centre.x-this.radius,this.centre.y-this.radius,2*this.radius,2*this.radius)},Circle.prototype.makeSimilarPolygon=function(){return new Polygon(this.label,[this.centre.offset(-this.radius,-this.radius),this.centre.offset(-this.radius,this.radius),this.centre.offset(this.radius,this.radius),this.centre.offset(this.radius,-this.radius)])},Circle.prototype.getHandlePositions=function(){return{moveHandle:this.centre,editHandles:[this.centre.offset(this.radius,0)]}},Rectangle.prototype=new Shape,Rectangle.prototype.getType=function(){return"rectangle"},Rectangle.prototype.getCoordinates=function(){return this.centre+";"+this.width+","+this.height},Rectangle.prototype.makeSvg=function(svg){var svgEl=createSvgShapeGroup(svg,"rect");return this.updateSvg(svgEl),svgEl},Rectangle.prototype.updateSvg=function(svgEl){this.width>=0?(svgEl.childNodes[0].setAttribute("x",this.centre.x),svgEl.childNodes[0].setAttribute("width",this.width)):(svgEl.childNodes[0].setAttribute("x",this.centre.x+this.width),svgEl.childNodes[0].setAttribute("width",-this.width)),this.height>=0?(svgEl.childNodes[0].setAttribute("y",this.centre.y),svgEl.childNodes[0].setAttribute("height",this.height)):(svgEl.childNodes[0].setAttribute("y",this.centre.y+this.height),svgEl.childNodes[0].setAttribute("height",-this.height)),svgEl.childNodes[1].setAttribute("x",this.centre.x+this.width/2),svgEl.childNodes[1].setAttribute("y",this.centre.y+this.height/2+15),svgEl.childNodes[1].textContent=this.label},Rectangle.prototype.parse=function(coordinates,ratio){if(!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/))return!1;var bits=coordinates.split(";");this.centre=Point.parse(bits[0]),this.centre.x=this.centre.x*parseFloat(ratio),this.centre.y=this.centre.y*parseFloat(ratio);var size=Point.parse(bits[1]);return this.width=size.x*parseFloat(ratio),this.height=size.y*parseFloat(ratio),!0},Rectangle.prototype.move=function(dx,dy,maxX,maxY){this.centre.move(dx,dy),this.centre.x<0&&(this.centre.x=0),this.centre.x>maxX-this.width&&(this.centre.x=maxX-this.width),this.centre.y<0&&(this.centre.y=0),this.centre.y>maxY-this.height&&(this.centre.y=maxY-this.height)},Rectangle.prototype.edit=function(handleIndex,dx,dy,maxX,maxY){this.width+=dx,this.height+=dy,this.width<-this.centre.x&&(this.width=-this.centre.x),this.width>maxX-this.centre.x&&(this.width=maxX-this.centre.x),this.height<-this.centre.y&&(this.height=-this.centre.y),this.height>maxY-this.centre.y&&(this.height=maxY-this.centre.y)},Rectangle.prototype.normalizeShape=function(){this.width<0&&(this.centre.x+=this.width,this.width=-this.width),this.height<0&&(this.centre.y+=this.height,this.height=-this.height)},Rectangle.prototype.makeSimilarCircle=function(){return new Circle(this.label,Math.round(this.centre.x+this.width/2),Math.round(this.centre.y+this.height/2),Math.round((this.width+this.height)/4))},Rectangle.prototype.makeSimilarPolygon=function(){return new Polygon(this.label,[this.centre,this.centre.offset(0,this.height),this.centre.offset(this.width,this.height),this.centre.offset(this.width,0)])},Rectangle.prototype.getHandlePositions=function(){return{moveHandle:this.centre.offset(this.width/2,this.height/2),editHandles:[this.centre.offset(this.width,this.height)]}},Polygon.prototype=new Shape,Polygon.prototype.getType=function(){return"polygon"},Polygon.prototype.getCoordinates=function(){for(var coordinates="",i=0;imaxX-bbXMax&&(this.centre.x=maxX-bbXMax),this.centre.y<-bbYMin&&(this.centre.y=-bbYMin),this.centre.y>maxY-bbYMax&&(this.centre.y=maxY-bbYMax)},Polygon.prototype.edit=function(handleIndex,dx,dy,maxX,maxY){this.points[handleIndex].move(dx,dy),this.points[handleIndex].x<-this.centre.x&&(this.points[handleIndex].x=-this.centre.x),this.points[handleIndex].x>maxX-this.centre.x&&(this.points[handleIndex].x=maxX-this.centre.x),this.points[handleIndex].y<-this.centre.y&&(this.points[handleIndex].y=-this.centre.y),this.points[handleIndex].y>maxY-this.centre.y&&(this.points[handleIndex].y=maxY-this.centre.y)},Polygon.prototype.addNewPointAfter=function(pointIndex){this.points.splice(pointIndex,0,new Point(this.points[pointIndex].x,this.points[pointIndex].y))},Polygon.prototype.normalizeShape=function(){var i,x=0,y=0;if(0!==this.points.length){for(i=0;i0){var bgImage=this.bgImage();this.getRoot().find("div.dropzones").html('');for(var svg=this.getRoot().find("svg.dropzones"),nextColourIndex=0,dropZoneNo=0;dropZoneNo'+dropZone.markertext+"");var markerspan=this.getRoot().find("div.ddarea div.markertexts span.markertext"+dropZoneNo);if(markerspan.length){var handles=shape.getHandlePositions(),positionLeft=handles.moveHandle.x-markerspan.outerWidth()/2-4,positionTop=handles.moveHandle.y-markerspan.outerHeight()/2;markerspan.css("left",positionLeft).css("top",positionTop),markerspan.data("originX",markerspan.position().left/bgRatio).data("originY",markerspan.position().top/bgRatio),this.handleElementScale(markerspan,"center")}filterEvent.notifyFilterContentUpdated(markerspan)}var shapeSVG=shape.makeSvg(svg[0]);shapeSVG.setAttribute("class","dropzone "+colourClass),this.shapes[this.shapes.length]=shape,this.shapeSVGs[this.shapeSVGs.length]=shapeSVG}},DragDropMarkersQuestion.prototype.repositionDrags=function(){var root=this.getRoot(),thisQ=this;root.find("div.draghomes .marker").not(".dragplaceholder").each((function(key,item){$(item).addClass("unneeded")})),root.find("input.choices").each((function(key,input){var choiceNo=thisQ.getChoiceNoFromElement(input),imageCoords=thisQ.getImageCoords(input);if(imageCoords.length){var drag=thisQ.getRoot().find(".draghomes span.marker.choice"+choiceNo).not(".dragplaceholder");drag.remove();for(var i=0;i{result[inputNode.id]=inputNode.value})),result},DragDropMarkersQuestion.prototype.isQuestionInteracted=function(){const oldAnswer=this.questionAnswer,newAnswer=this.getQuestionAnsweredValues();let isInteracted=!1;return JSON.stringify(newAnswer)!==JSON.stringify(oldAnswer)?(isInteracted=!0,isInteracted):(Object.keys(newAnswer).forEach((key=>{newAnswer[key]!==oldAnswer[key]&&(isInteracted=!0)})),isInteracted)},DragDropMarkersQuestion.prototype.getImageCoords=function(inputNode){var imageCoords=[],val=$(inputNode).val();if(""!==val)for(var coordsStrings=val.split(";"),i=0;i=bgPosition.left&&point.x=bgPosition.top&&point.y2&&void 0!==arguments[2]&&arguments[2];var dropArea=this.dropArea(),bgRatio=this.bgRatio();drag.removeClass("beingdragged").removeClass("unneeded");var dragXY=this.convertToBgImgXY(new Shapes.Point(drag.data("pagex"),drag.data("pagey")));isScaling?(drag.data("originX",dragXY.x/bgRatio).data("originY",dragXY.y/bgRatio),drag.css("left",dragXY.x).css("top",dragXY.y)):(drag.data("originX",dragXY.x).data("originY",dragXY.y),drag.css("left",dragXY.x*bgRatio).css("top",dragXY.y*bgRatio)),initialLoad||drag.data("scaleRatio",bgRatio),dropArea.append(drag),this.handleElementScale(drag,"left top")},DragDropMarkersQuestion.prototype.cloneDragIfNeeded=function(drag){var inputNode=this.getInput(drag),noOfDrags=Number(this.getClassnameNumericSuffix(inputNode,"noofdrags")),displayedDragsInDropArea=this.getRoot().find("div.droparea .marker.choice"+this.getChoiceNoFromElement(drag)+this.getDragNoClass(drag,!0)).length,displayedDragsInDragHomes=this.getRoot().find("div.draghomes .marker.choice"+this.getChoiceNoFromElement(drag)+this.getDragNoClass(drag,!0)).not(".dragplaceholder").length;if((this.isInfiniteDrag(drag)||!this.isInfiniteDrag(drag)&&displayedDragsInDropArea1;)dragsInHome.first().remove(),displayedDrags--},DragDropMarkersQuestion.prototype.getInput=function(drag){var choiceNo=this.getChoiceNoFromElement(drag);return this.getRoot().find("input.choices.choice"+choiceNo)},DragDropMarkersQuestion.prototype.bgRatio=function(){var bgImg=this.bgImage(),bgImgNaturalWidth=bgImg.get(0).naturalWidth;return bgImg.width()/bgImgNaturalWidth},DragDropMarkersQuestion.prototype.handleElementScale=function(element,type){var bgRatio=parseFloat(this.bgRatio());this.isPrinting&&(bgRatio=1),$(element).css({"-webkit-transform":"scale("+bgRatio+")","-moz-transform":"scale("+bgRatio+")","-ms-transform":"scale("+bgRatio+")","-o-transform":"scale("+bgRatio+")",transform:"scale("+bgRatio+")","transform-origin":type})},DragDropMarkersQuestion.prototype.isInfiniteDrag=function(drag){return drag.hasClass("infinite")},DragDropMarkersQuestion.prototype.waitForAllImagesToBeLoaded=function(){var thisQ=this;this.allImagesLoaded||(null!==this.imageLoadingTimeoutId&&clearTimeout(this.imageLoadingTimeoutId),this.getNotYetLoadedImages().length>0?this.imageLoadingTimeoutId=setTimeout((function(){this.waitForAllImagesToBeLoaded()}),100):(this.allImagesLoaded=!0,this.cloneDrags(),this.repositionDrags(),this.drawDropzones(),document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete,(elements=>{elements.detail.nodes.forEach((element=>{thisQ.changeAllMakerToFilteredContent(element)}))}))))},DragDropMarkersQuestion.prototype.changeAllMakerToFilteredContent=function(filteredElement){let currentFilteredItem=$(filteredElement);const parentIsMarker=currentFilteredItem.parent().closest("span.marker"),isMarker=currentFilteredItem.hasClass("marker"),root=this.getRoot();if(!parentIsMarker&&!isMarker)return;if(parentIsMarker&&(currentFilteredItem=currentFilteredItem.parent().closest("span.marker")),root.find(currentFilteredItem).length<=0)return;const dragNo=this.getDragNo(currentFilteredItem),choiceNo=this.getChoiceNoFromElement(currentFilteredItem),listOfContainerToBeModifed=["div.draghomes .marker:not(.dragplaceholder).dragno"+dragNo+".choice"+choiceNo,"div.droparea .marker:not(.dragplaceholder).dragno"+dragNo+".choice"+choiceNo,"div.draghomes .marker:not(.dragplaceholder).infinite.choice"+choiceNo,"div.droparea .marker:not(.dragplaceholder).infinite.choice"+choiceNo];let listOfModifiedDragDrop=[];const filteredDragDropClone=currentFilteredItem.clone();listOfContainerToBeModifed.forEach((function(selector){root.find(selector).each((function(i,node){const originalClass=$(node).attr("class"),originalStyle=$(node).attr("style");filteredDragDropClone.attr("class",originalClass),filteredDragDropClone.attr("style",originalStyle),questionManager.addEventHandlersToMarker(filteredDragDropClone),$(node).before(filteredDragDropClone),listOfModifiedDragDrop.push(node)}))})),listOfModifiedDragDrop.forEach((function(node){$(node).remove()}))},DragDropMarkersQuestion.prototype.getNotYetLoadedImages=function(){return this.getRoot().find(".ddmarker img.dropbackground").not((function(i,imgNode){return this.imageIsLoaded(imgNode)}))},DragDropMarkersQuestion.prototype.imageIsLoaded=function(imgElement){return imgElement.complete&&0!==imgElement.naturalHeight};var questionManager={eventHandlersInitialised:!1,markerEventHandlersInitialised:{},isPrinting:!1,isKeyboardNavigation:!1,questions:{},init:function(containerId,readOnly,visibleDropZones){if(questionManager.questions[containerId]=new DragDropMarkersQuestion(containerId,readOnly,visibleDropZones),questionManager.eventHandlersInitialised||(questionManager.setupEventHandlers(),questionManager.eventHandlersInitialised=!0),!questionManager.markerEventHandlersInitialised.hasOwnProperty(containerId)){questionManager.markerEventHandlersInitialised[containerId]=!0;var questionContainer=document.getElementById(containerId);questionContainer.classList.contains("ddmarker")&&!questionContainer.classList.contains("qtype_ddmarker-readonly")&&(questionManager.addEventHandlersToMarker($(questionContainer).find("div.draghomes .marker")),questionManager.addEventHandlersToMarker($(questionContainer).find("div.droparea .marker")))}},setupEventHandlers:function(){$(window).on("resize",(function(){questionManager.handleWindowResize(!1)})),window.addEventListener("beforeprint",(function(){questionManager.isPrinting=!0,questionManager.handleWindowResize(questionManager.isPrinting)})),window.addEventListener("afterprint",(function(){questionManager.isPrinting=!1,questionManager.handleWindowResize(questionManager.isPrinting)})),setTimeout((function(){questionManager.fixLayoutIfThingsMoved()}),100)},addEventHandlersToMarker:function(element){element.on("mousedown touchstart",questionManager.handleDragStart).on("keydown keypress",questionManager.handleKeyPress).focusin((function(e){questionManager.handleKeyboardFocus(e,!0)})).focusout((function(e){questionManager.handleKeyboardFocus(e,!1)}))},handleDragStart:function(e){e.preventDefault();var question=questionManager.getQuestionForEvent(e);question&&question.handleDragStart(e)},handleKeyPress:function(e){var question=questionManager.getQuestionForEvent(e);question&&question.handleKeyPress(e)},handleWindowResize:function(isPrinting){for(var containerId in questionManager.questions)questionManager.questions.hasOwnProperty(containerId)&&(questionManager.questions[containerId].isPrinting=isPrinting,questionManager.questions[containerId].handleResize())},handleKeyboardFocus:function(e,isNavigating){questionManager.isKeyboardNavigation=isNavigating},fixLayoutIfThingsMoved:function(){questionManager.isKeyboardNavigation||this.handleWindowResize(questionManager.isPrinting),setTimeout((function(){questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting)}),100)},getQuestionForEvent:function(e){var containerId=$(e.currentTarget).closest(".que.ddmarker").attr("id");return questionManager.questions[containerId]},handleFormDirty:function(){const responseForm=document.getElementById("responseform");FormChangeChecker.markFormAsDirty(responseForm)}};return{init:questionManager.init}})); //# sourceMappingURL=question.min.js.mapPK\80AA'type/ddmarker/amd/build/form.min.js.mapnu[{"version":3,"file":"form.min.js","sources":["../src/form.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This class provides the enhancements to the drag-drop marker editing form.\n *\n * @module qtype_ddmarker/form\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes'], function($, dragDrop, Shapes) {\n\n \"use strict\";\n\n /**\n * Create the manager object that deals with keeping everything synchronised for one drop zone.\n *\n * @param {int} dropzoneNo the index of this drop zone in the form. 0, 1, ....\n * @constructor\n */\n function DropZoneManager(dropzoneNo) {\n this.dropzoneNo = dropzoneNo;\n this.svgEl = null;\n\n this.shape = Shapes.make(this.getShapeType(), this.getLabel());\n this.updateCoordinatesFromForm();\n }\n\n /**\n * Update the coordinates from a particular string.\n *\n * @param {SVGElement} [svg] the SVG element that is the preview.\n */\n DropZoneManager.prototype.updateCoordinatesFromForm = function(svg) {\n var coordinates = this.getCoordinates(),\n currentNumPoints = this.shape.getType() === 'polygon' && this.shape.points.length;\n if (this.shape.getCoordinates() === coordinates) {\n return;\n }\n // We don't need to scale the shape for editing form.\n if (!this.shape.parse(coordinates, 1)) {\n // Invalid coordinates. Don't update the preview.\n return;\n }\n\n if (this.shape.getType() === 'polygon' && currentNumPoints !== this.shape.points.length) {\n // Polygon, and size has changed.\n var currentyActive = this.isActive();\n this.removeFromSvg();\n if (svg) {\n this.addToSvg(svg);\n if (currentyActive) {\n this.setActive();\n }\n }\n } else {\n // Simple update.\n this.updateSvgEl();\n }\n // Update the rounded coordinates if needed.\n this.setCoordinatesInForm();\n };\n\n /**\n * Update the label.\n */\n DropZoneManager.prototype.updateLabel = function() {\n var label = this.getLabel();\n if (this.shape.label !== label) {\n this.shape.label = label;\n this.updateSvgEl();\n }\n };\n\n /**\n * Handle if the type of shape has changed.\n *\n * @param {SVGElement} [svg] an SVG element to add this new shape to.\n */\n DropZoneManager.prototype.changeShape = function(svg) {\n var newShapeType = this.getShapeType(),\n currentyActive = this.isActive();\n\n if (newShapeType === this.shape.getType()) {\n return;\n }\n\n // It has really changed.\n this.removeFromSvg();\n this.shape = Shapes.getSimilar(newShapeType, this.shape);\n if (svg) {\n this.addToSvg(svg);\n if (currentyActive) {\n this.setActive();\n }\n }\n this.setCoordinatesInForm();\n };\n\n /**\n * Add this drop zone to an SVG graphic.\n *\n * @param {SVGElement} svg the SVG image to which to add this drop zone.\n */\n DropZoneManager.prototype.addToSvg = function(svg) {\n if (this.svgEl !== null) {\n throw new Error('this.svgEl already set');\n }\n this.svgEl = this.shape.makeSvg(svg);\n if (!this.svgEl) {\n return;\n }\n this.svgEl.setAttribute('class', 'dropzone');\n this.svgEl.setAttribute('data-dropzone-no', this.dropzoneNo);\n\n // Add handles.\n var handles = this.shape.getHandlePositions();\n if (handles === null) {\n return;\n }\n\n var moveHandle = Shapes.createSvgElement(this.svgEl, 'circle');\n moveHandle.setAttribute('cx', handles.moveHandle.x);\n moveHandle.setAttribute('cy', handles.moveHandle.y);\n moveHandle.setAttribute('r', 7);\n moveHandle.setAttribute('class', 'handle move');\n\n for (var i = 0; i < handles.editHandles.length; ++i) {\n this.makeEditHandle(i, handles.editHandles[i]);\n }\n };\n\n /**\n * Add a new edit handle.\n *\n * @param {int} index the handle index.\n * @param {Point} point the point at which to add the handle.\n */\n DropZoneManager.prototype.makeEditHandle = function(index, point) {\n var editHandle = Shapes.createSvgElement(this.svgEl, 'rect');\n editHandle.setAttribute('x', point.x - 6);\n editHandle.setAttribute('y', point.y - 6);\n editHandle.setAttribute('width', 11);\n editHandle.setAttribute('height', 11);\n editHandle.setAttribute('class', 'handle edit');\n editHandle.setAttribute('data-edit-handle-no', index);\n };\n\n /**\n * Remove this drop zone from an SVG image.\n */\n DropZoneManager.prototype.removeFromSvg = function() {\n if (this.svgEl !== null) {\n this.svgEl.parentNode.removeChild(this.svgEl);\n this.svgEl = null;\n }\n };\n\n /**\n * Update the shape of this drop zone (but not type) in an SVG image.\n */\n DropZoneManager.prototype.updateSvgEl = function() {\n if (this.svgEl === null) {\n return;\n }\n\n this.shape.updateSvg(this.svgEl);\n\n // Adjust handles.\n var handles = this.shape.getHandlePositions();\n if (handles === null) {\n return;\n }\n\n // Move handle.\n // The shape + its label are the first two children of svgEl.\n // Then come the move handle followed by the edit handles.\n this.svgEl.childNodes[2].setAttribute('cx', handles.moveHandle.x);\n this.svgEl.childNodes[2].setAttribute('cy', handles.moveHandle.y);\n\n // Edit handles.\n for (var i = 0; i < handles.editHandles.length; ++i) {\n this.svgEl.childNodes[3 + i].setAttribute('x', handles.editHandles[i].x - 6);\n this.svgEl.childNodes[3 + i].setAttribute('y', handles.editHandles[i].y - 6);\n }\n };\n\n /**\n * Find out of this drop zone is currently being edited.\n *\n * @return {boolean} true if it is.\n */\n DropZoneManager.prototype.isActive = function() {\n return this.svgEl !== null && this.svgEl.getAttribute('class').match(/\\bactive\\b/);\n };\n\n /**\n * Set this drop zone as being edited.\n */\n DropZoneManager.prototype.setActive = function() {\n // Move this one to last, so that it is always on top.\n // (Otherwise the handles may not be able to receive events.)\n var parent = this.svgEl.parentNode;\n parent.removeChild(this.svgEl);\n parent.appendChild(this.svgEl);\n this.svgEl.setAttribute('class', this.svgEl.getAttribute('class') + ' active');\n };\n\n /**\n * Set the coordinates in the form to match the current shape.\n */\n DropZoneManager.prototype.setCoordinatesInForm = function() {\n dragDropForm.form.setFormValue('drops', [this.dropzoneNo, 'coords'], this.shape.getCoordinates());\n };\n\n /**\n * Returns the coordinates for a drop zone from the text input in the form.\n * @returns {string} the coordinates.\n */\n DropZoneManager.prototype.getCoordinates = function() {\n return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'coords']).replace(/\\s*/g, '');\n };\n\n /**\n * Returns the selected marker number from the dropdown in the form.\n * @returns {int} choice number.\n */\n DropZoneManager.prototype.getChoiceNo = function() {\n return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'choice']);\n };\n\n /**\n * Returns the selected marker number from the dropdown in the form.\n * @returns {String} marker label text.\n */\n DropZoneManager.prototype.getLabel = function() {\n return dragDropForm.form.getMarkerText(this.getChoiceNo());\n };\n\n\n /**\n * Returns the selected type of shape in the form.\n * @returns {String} 'circle', 'rectangle' or 'polygon'.\n */\n DropZoneManager.prototype.getShapeType = function() {\n return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'shape']);\n };\n\n /**\n * Start responding to dragging the move handle.\n * @param {Event} e Event object\n */\n DropZoneManager.prototype.handleMove = function(e) {\n var info = dragDrop.prepare(e);\n if (!info.start) {\n return;\n }\n\n var movingDropZone = this,\n lastX = info.x,\n lastY = info.y,\n dragProxy = this.makeDragProxy(info.x, info.y),\n bgImg = $('fieldset#id_previewareaheader .dropbackground'),\n maxX = bgImg.width(),\n maxY = bgImg.height();\n\n dragDrop.start(e, $(dragProxy), function(pageX, pageY) {\n movingDropZone.shape.move(pageX - lastX, pageY - lastY, maxX, maxY);\n lastX = pageX;\n lastY = pageY;\n movingDropZone.updateSvgEl();\n movingDropZone.setCoordinatesInForm();\n }, function() {\n document.body.removeChild(dragProxy);\n });\n };\n\n /**\n * Start responding to dragging the move handle.\n * @param {Event} e Event object\n * @param {int} handleIndex\n * @param {SVGElement} [svg] an SVG element to add this new shape to.\n */\n DropZoneManager.prototype.handleEdit = function(e, handleIndex, svg) {\n var info = dragDrop.prepare(e);\n if (!info.start) {\n return;\n }\n\n // For polygons, CTRL + drag adds a new point.\n if (this.shape.getType() === 'polygon' && (e.ctrlKey || e.metaKey)) {\n this.shape.addNewPointAfter(handleIndex);\n this.removeFromSvg();\n this.addToSvg(svg);\n this.setActive();\n }\n\n var changingDropZone = this,\n lastX = info.x,\n lastY = info.y,\n dragProxy = this.makeDragProxy(info.x, info.y),\n bgImg = $('fieldset#id_previewareaheader .dropbackground'),\n maxX = bgImg.width(),\n maxY = bgImg.height();\n\n dragDrop.start(e, $(dragProxy), function(pageX, pageY) {\n changingDropZone.shape.edit(handleIndex, pageX - lastX, pageY - lastY, maxX, maxY);\n lastX = pageX;\n lastY = pageY;\n changingDropZone.updateSvgEl();\n changingDropZone.setCoordinatesInForm();\n }, function() {\n document.body.removeChild(dragProxy);\n changingDropZone.shape.normalizeShape();\n changingDropZone.updateSvgEl();\n changingDropZone.setCoordinatesInForm();\n });\n };\n\n /**\n * Make an invisible drag proxy.\n *\n * @param {int} x x position .\n * @param {int} y y position.\n * @returns {HTMLElement} the drag proxy.\n */\n DropZoneManager.prototype.makeDragProxy = function(x, y) {\n var dragProxy = document.createElement('div');\n dragProxy.style.position = 'absolute';\n dragProxy.style.top = y + 'px';\n dragProxy.style.left = x + 'px';\n dragProxy.style.width = '1px';\n dragProxy.style.height = '1px';\n document.body.appendChild(dragProxy);\n return dragProxy;\n };\n\n /**\n * Singleton object for managing all the parts of the form.\n */\n var dragDropForm = {\n\n /**\n * @var {object} for interacting with the file pickers.\n */\n fp: null, // Object containing functions associated with the file picker.\n\n /**\n * @var {int} the number of drop-zones on the form.\n */\n noDropZones: null,\n\n /**\n * @var {DropZoneManager[]} the drop zones in the preview, indexed by drop zone number.\n */\n dropZones: [],\n\n /**\n * Initialise the form.\n */\n init: function() {\n dragDropForm.fp = dragDropForm.filePickers();\n dragDropForm.noDropZones = dragDropForm.form.getFormValue('nodropzone', []);\n dragDropForm.setOptionsForDragItemSelectors();\n dragDropForm.createShapes();\n dragDropForm.setupEventHandlers();\n dragDropForm.waitForFilePickerToInitialise();\n },\n\n /**\n * Add html for the preview area.\n */\n setupPreviewArea: function() {\n $('fieldset#id_previewareaheader div.fcontainer').append(\n '
    ' +\n '
    ' +\n ' ' +\n '
    ' +\n '
    ' +\n '
    ' +\n '
    ');\n },\n\n /**\n * When a new marker is added this function updates the Marker dropdown controls in Drop zones.\n */\n setOptionsForDragItemSelectors: function() {\n var dragItemsOptions = {'0': ''};\n var noItems = dragDropForm.form.getFormValue('noitems', []);\n var selectedValues = [];\n var selector;\n var i, label;\n for (i = 1; i <= noItems; i++) {\n label = dragDropForm.form.getMarkerText(i);\n if (label !== \"\") {\n // HTML escape the label.\n dragItemsOptions[i] = $('
    ').text(label).html();\n }\n }\n // Get all the currently selected drags for each drop.\n for (i = 0; i < dragDropForm.noDropZones; i++) {\n selector = $('#id_drops_' + i + '_choice');\n selectedValues[i] = Number(selector.val());\n }\n for (i = 0; i < dragDropForm.noDropZones; i++) {\n selector = $('#id_drops_' + i + '_choice');\n // Remove all options for drag choice.\n selector.find('option').remove();\n // And recreate the options.\n for (var value in dragItemsOptions) {\n value = Number(value);\n var option = '';\n selector.append(option);\n var optionnode = selector.find('option[value=\"' + value + '\"]');\n\n\n if (value === 0) {\n continue; // The 'no item' option is always selectable.\n }\n\n // Is this the currently selected value?\n if (value === selectedValues[i]) {\n optionnode.attr('selected', true);\n continue; // If it s selected, we must leave it enabled.\n }\n\n // Count how many times it is used, and if necessary, disable.\n var noofdrags = dragDropForm.form.getFormValue('drags', [value - 1, 'noofdrags']);\n if (Number(noofdrags) === 0) { // 'noofdrags === 0' means infinite.\n continue; // Nothing to check.\n }\n\n // Go through all selected values in drop downs.\n for (var k in selectedValues) {\n if (Number(selectedValues[k]) !== value) {\n continue;\n }\n\n // Count down 'noofdrags' and if reach zero then set disabled option for this drag item.\n if (Number(noofdrags) === 1) {\n optionnode.attr('disabled', true);\n break;\n } else {\n noofdrags--;\n }\n }\n }\n\n if (dragDropForm.dropZones.length > 0) {\n dragDropForm.dropZones[i].updateLabel();\n }\n }\n },\n\n /**\n * Create the shape representation of each dropZone.\n */\n createShapes: function() {\n for (var dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {\n dragDropForm.dropZones[dropzoneNo] = new DropZoneManager(dropzoneNo);\n }\n },\n\n /**\n * Events linked to form actions.\n */\n setupEventHandlers: function() {\n // Changes to labels in the Markers section.\n $('fieldset#id_draggableitemheader').on('change input', 'input, select', function() {\n dragDropForm.setOptionsForDragItemSelectors();\n });\n\n // Changes to Drop zones section: shape, coordinates and marker.\n $('fieldset#id_dropzoneheader').on('change input', 'input, select', function(e) {\n var ids = e.currentTarget.name.match(/^drops\\[(\\d+)]\\[([a-z]*)]$/);\n if (!ids) {\n return;\n }\n\n var dropzoneNo = ids[1],\n inputType = ids[2],\n dropZone = dragDropForm.dropZones[dropzoneNo];\n\n switch (inputType) {\n case 'shape':\n dropZone.changeShape(dragDropForm.form.getSvg());\n break;\n\n case 'coords':\n dropZone.updateCoordinatesFromForm(dragDropForm.form.getSvg());\n break;\n\n case 'choice':\n dropZone.updateLabel();\n break;\n }\n });\n\n // Click to toggle graphical editing.\n var previewArea = $('fieldset#id_previewareaheader');\n previewArea.on('click', 'g.dropzone', function(e) {\n var dropzoneNo = $(e.currentTarget).data('dropzone-no'),\n currentlyActive = dragDropForm.dropZones[dropzoneNo].isActive();\n\n $(dragDropForm.form.getSvg()).find('.dropzone.active').removeClass('active');\n\n if (!currentlyActive) {\n dragDropForm.dropZones[dropzoneNo].setActive();\n }\n });\n\n // Drag start on a move handle.\n previewArea.on('mousedown touchstart', '.dropzone .handle.move', function(e) {\n var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo');\n\n dragDropForm.dropZones[dropzoneNo].handleMove(e);\n });\n\n // Drag start on a move handle.\n previewArea.on('mousedown touchstart', '.dropzone .handle.edit', function(e) {\n var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo'),\n handleIndex = e.currentTarget.getAttribute('data-edit-handle-no');\n\n dragDropForm.dropZones[dropzoneNo].handleEdit(e, handleIndex, dragDropForm.form.getSvg());\n });\n },\n\n /**\n * Prevents adding drop zones until the preview background image is ready to load.\n */\n waitForFilePickerToInitialise: function() {\n if (dragDropForm.fp.file('bgimage').href === null) {\n // It would be better to use an onload or onchange event rather than this timeout.\n // Unfortunately attempts to do this early are overwritten by filepicker during its loading.\n setTimeout(dragDropForm.waitForFilePickerToInitialise, 1000);\n return;\n }\n\n // From now on, when a new file gets loaded into the filepicker, update the preview.\n // This is not in the setupEventHandlers section as it needs to be delayed until\n // after filepicker's javascript has finished.\n $('form.mform[data-qtype=\"ddmarker\"]').on('change', '#id_bgimage', dragDropForm.loadPreviewImage);\n\n if ($('#ddm-droparea').length) {\n dragDropForm.loadPreviewImage();\n } else {\n // Setup preview area when the background image is uploaded the first time.\n dragDropForm.setupPreviewArea();\n dragDropForm.loadPreviewImage();\n }\n },\n\n /**\n * Loads the preview background image.\n */\n loadPreviewImage: function() {\n $('fieldset#id_previewareaheader .dropbackground')\n .one('load', dragDropForm.afterPreviewImageLoaded)\n .attr('src', dragDropForm.fp.file('bgimage').href);\n },\n\n /**\n * Functions to run after background image loaded.\n */\n afterPreviewImageLoaded: function() {\n var bgImg = $('fieldset#id_previewareaheader .dropbackground');\n // Place the dropzone area over the background image (adding one to account for the border).\n $('#ddm-dropzone').css('position', 'relative').css('top', (bgImg.height() + 1) * -1);\n $('#ddm-droparea').css('height', bgImg.height() + 20);\n dragDropForm.updateSvgDisplay();\n },\n\n /**\n * Draws or re-draws all dropzones in the preview area based on form data.\n * Call this function when there is a change in the form data.\n */\n updateSvgDisplay: function() {\n var bgImg = $('fieldset#id_previewareaheader .dropbackground'),\n dropzoneNo;\n\n if (dragDropForm.form.getSvg()) {\n // Already exists, just need to be updated.\n for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {\n dragDropForm.dropZones[dropzoneNo].updateSvgEl();\n }\n\n } else {\n // Create.\n $('#ddm-dropzone').html('');\n for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {\n dragDropForm.dropZones[dropzoneNo].addToSvg(dragDropForm.form.getSvg());\n }\n }\n },\n\n /**\n * Helper to make it easy to work with form elements with names like \"drops[0][shape]\".\n */\n form: {\n /**\n * Returns the label text for a marker.\n * @param {int} markerNo\n * @returns {string} Marker text\n */\n getMarkerText: function(markerNo) {\n if (Number(markerNo) !== 0) {\n var label = dragDropForm.form.getFormValue('drags', [markerNo - 1, 'label']);\n return label.replace(new RegExp(\"^\\\\s*(.*)\\\\s*$\"), \"$1\");\n } else {\n return '';\n }\n },\n\n /**\n * Get the SVG element, if there is one, otherwise return null.\n *\n * @returns {SVGElement|null} the SVG element or null.\n */\n getSvg: function() {\n var svg = $('fieldset#id_previewareaheader svg');\n if (svg.length === 0) {\n return null;\n } else {\n return svg[0];\n }\n },\n\n toNameWithIndex: function(name, indexes) {\n var indexString = name;\n for (var i = 0; i < indexes.length; i++) {\n indexString = indexString + '[' + indexes[i] + ']';\n }\n return indexString;\n },\n\n getEl: function(name, indexes) {\n var form = $('form.mform[data-qtype=\"ddmarker\"]')[0];\n return form.elements[this.toNameWithIndex(name, indexes)];\n },\n\n /**\n * Helper to get the value of a form elements with name like \"drops[0][shape]\".\n *\n * @param {String} name the base name, e.g. 'drops'.\n * @param {String[]} indexes the indexes, e.g. ['0', 'shape'].\n * @return {String} the value of that field.\n */\n getFormValue: function(name, indexes) {\n var el = this.getEl(name, indexes);\n if (el.type === 'checkbox') {\n return el.checked;\n } else {\n return el.value;\n }\n },\n\n /**\n * Helper to get the value of a form elements with name like \"drops[0][shape]\".\n *\n * @param {String} name the base name, e.g. 'drops'.\n * @param {String[]} indexes the indexes, e.g. ['0', 'shape'].\n * @param {String} value the value to set.\n */\n setFormValue: function(name, indexes, value) {\n var el = this.getEl(name, indexes);\n if (el.type === 'checkbox') {\n el.checked = value;\n } else {\n el.value = value;\n }\n }\n },\n\n /**\n * Utility to get the file name and url from the filepicker.\n * @returns {Object} object containing functions {file, name}\n */\n filePickers: function() {\n var draftItemIdsToName;\n var nameToParentNode;\n if (draftItemIdsToName === undefined) {\n draftItemIdsToName = {};\n nameToParentNode = {};\n $('form.mform input.filepickerhidden').each(function(key, filepicker) {\n draftItemIdsToName[filepicker.value] = filepicker.name;\n nameToParentNode[filepicker.name] = filepicker.parentNode;\n });\n }\n return {\n file: function(name) {\n var fileAnchor = $(nameToParentNode[name]).find('div.filepicker-filelist a');\n if (fileAnchor.length) {\n return {href: fileAnchor.get(0).href, name: fileAnchor.get(0).innerHTML};\n } else {\n return {href: null, name: null};\n }\n },\n name: function(draftitemid) {\n return draftItemIdsToName[draftitemid];\n }\n };\n }\n };\n\n /**\n * @alias module:qtype_ddmarker/form\n */\n return {\n /**\n * Initialise the form javascript features.\n * @param {Object} maxBgimageSize object with two properties: width and height.\n */\n init: dragDropForm.init\n };\n});\n"],"names":["define","$","dragDrop","Shapes","DropZoneManager","dropzoneNo","svgEl","shape","make","this","getShapeType","getLabel","updateCoordinatesFromForm","prototype","svg","coordinates","getCoordinates","currentNumPoints","getType","points","length","parse","currentyActive","isActive","removeFromSvg","addToSvg","setActive","updateSvgEl","setCoordinatesInForm","updateLabel","label","changeShape","newShapeType","getSimilar","Error","makeSvg","setAttribute","handles","getHandlePositions","moveHandle","createSvgElement","x","y","i","editHandles","makeEditHandle","index","point","editHandle","parentNode","removeChild","updateSvg","childNodes","getAttribute","match","parent","appendChild","dragDropForm","form","setFormValue","getFormValue","replace","getChoiceNo","getMarkerText","handleMove","e","info","prepare","start","movingDropZone","lastX","lastY","dragProxy","makeDragProxy","bgImg","maxX","width","maxY","height","pageX","pageY","move","document","body","handleEdit","handleIndex","ctrlKey","metaKey","addNewPointAfter","changingDropZone","edit","normalizeShape","createElement","style","position","top","left","fp","noDropZones","dropZones","init","filePickers","setOptionsForDragItemSelectors","createShapes","setupEventHandlers","waitForFilePickerToInitialise","setupPreviewArea","append","selector","dragItemsOptions","noItems","selectedValues","text","html","Number","val","value","find","remove","option","optionnode","noofdrags","k","attr","on","ids","currentTarget","name","inputType","dropZone","getSvg","previewArea","data","currentlyActive","removeClass","closest","file","href","loadPreviewImage","setTimeout","one","afterPreviewImageLoaded","css","updateSvgDisplay","outerWidth","outerHeight","markerNo","RegExp","toNameWithIndex","indexes","indexString","getEl","elements","el","type","checked","draftItemIdsToName","nameToParentNode","undefined","each","key","filepicker","fileAnchor","get","innerHTML","draftitemid"],"mappings":";;;;;;;AAuBAA,6BAAO,CAAC,SAAU,gBAAiB,0BAA0B,SAASC,EAAGC,SAAUC,iBAUtEC,gBAAgBC,iBAChBA,WAAaA,gBACbC,MAAQ,UAERC,MAAQJ,OAAOK,KAAKC,KAAKC,eAAgBD,KAAKE,iBAC9CC,4BAQTR,gBAAgBS,UAAUD,0BAA4B,SAASE,SACvDC,YAAcN,KAAKO,iBACnBC,iBAA4C,YAAzBR,KAAKF,MAAMW,WAA2BT,KAAKF,MAAMY,OAAOC,UAC3EX,KAAKF,MAAMS,mBAAqBD,aAI/BN,KAAKF,MAAMc,MAAMN,YAAa,OAKN,YAAzBN,KAAKF,MAAMW,WAA2BD,mBAAqBR,KAAKF,MAAMY,OAAOC,OAAQ,KAEjFE,eAAiBb,KAAKc,gBACrBC,gBACDV,WACKW,SAASX,KACVQ,qBACKI,uBAKRC,mBAGJC,yBAMTxB,gBAAgBS,UAAUgB,YAAc,eAChCC,MAAQrB,KAAKE,WACbF,KAAKF,MAAMuB,QAAUA,aAChBvB,MAAMuB,MAAQA,WACdH,gBASbvB,gBAAgBS,UAAUkB,YAAc,SAASjB,SACzCkB,aAAevB,KAAKC,eACpBY,eAAiBb,KAAKc,WAEtBS,eAAiBvB,KAAKF,MAAMW,iBAK3BM,qBACAjB,MAAQJ,OAAO8B,WAAWD,aAAcvB,KAAKF,OAC9CO,WACKW,SAASX,KACVQ,qBACKI,kBAGRE,yBAQTxB,gBAAgBS,UAAUY,SAAW,SAASX,QACvB,OAAfL,KAAKH,YACC,IAAI4B,MAAM,kCAEf5B,MAAQG,KAAKF,MAAM4B,QAAQrB,KAC3BL,KAAKH,YAGLA,MAAM8B,aAAa,QAAS,iBAC5B9B,MAAM8B,aAAa,mBAAoB3B,KAAKJ,gBAG7CgC,QAAU5B,KAAKF,MAAM+B,wBACT,OAAZD,aAIAE,WAAapC,OAAOqC,iBAAiB/B,KAAKH,MAAO,UACrDiC,WAAWH,aAAa,KAAMC,QAAQE,WAAWE,GACjDF,WAAWH,aAAa,KAAMC,QAAQE,WAAWG,GACjDH,WAAWH,aAAa,IAAK,GAC7BG,WAAWH,aAAa,QAAS,mBAE5B,IAAIO,EAAI,EAAGA,EAAIN,QAAQO,YAAYxB,SAAUuB,OACzCE,eAAeF,EAAGN,QAAQO,YAAYD,OAUnDvC,gBAAgBS,UAAUgC,eAAiB,SAASC,MAAOC,WACnDC,WAAa7C,OAAOqC,iBAAiB/B,KAAKH,MAAO,QACrD0C,WAAWZ,aAAa,IAAKW,MAAMN,EAAI,GACvCO,WAAWZ,aAAa,IAAKW,MAAML,EAAI,GACvCM,WAAWZ,aAAa,QAAS,IACjCY,WAAWZ,aAAa,SAAU,IAClCY,WAAWZ,aAAa,QAAS,eACjCY,WAAWZ,aAAa,sBAAuBU,QAMnD1C,gBAAgBS,UAAUW,cAAgB,WACnB,OAAff,KAAKH,aACAA,MAAM2C,WAAWC,YAAYzC,KAAKH,YAClCA,MAAQ,OAOrBF,gBAAgBS,UAAUc,YAAc,cACjB,OAAflB,KAAKH,YAIJC,MAAM4C,UAAU1C,KAAKH,WAGtB+B,QAAU5B,KAAKF,MAAM+B,wBACT,OAAZD,cAOC/B,MAAM8C,WAAW,GAAGhB,aAAa,KAAMC,QAAQE,WAAWE,QAC1DnC,MAAM8C,WAAW,GAAGhB,aAAa,KAAMC,QAAQE,WAAWG,OAG1D,IAAIC,EAAI,EAAGA,EAAIN,QAAQO,YAAYxB,SAAUuB,OACzCrC,MAAM8C,WAAW,EAAIT,GAAGP,aAAa,IAAKC,QAAQO,YAAYD,GAAGF,EAAI,QACrEnC,MAAM8C,WAAW,EAAIT,GAAGP,aAAa,IAAKC,QAAQO,YAAYD,GAAGD,EAAI,MASlFtC,gBAAgBS,UAAUU,SAAW,kBACX,OAAfd,KAAKH,OAAkBG,KAAKH,MAAM+C,aAAa,SAASC,MAAM,eAMzElD,gBAAgBS,UAAUa,UAAY,eAG9B6B,OAAS9C,KAAKH,MAAM2C,WACxBM,OAAOL,YAAYzC,KAAKH,OACxBiD,OAAOC,YAAY/C,KAAKH,YACnBA,MAAM8B,aAAa,QAAS3B,KAAKH,MAAM+C,aAAa,SAAW,YAMxEjD,gBAAgBS,UAAUe,qBAAuB,WAC7C6B,aAAaC,KAAKC,aAAa,QAAS,CAAClD,KAAKJ,WAAY,UAAWI,KAAKF,MAAMS,mBAOpFZ,gBAAgBS,UAAUG,eAAiB,kBAChCyC,aAAaC,KAAKE,aAAa,QAAS,CAACnD,KAAKJ,WAAY,WAAWwD,QAAQ,OAAQ,KAOhGzD,gBAAgBS,UAAUiD,YAAc,kBAC7BL,aAAaC,KAAKE,aAAa,QAAS,CAACnD,KAAKJ,WAAY,YAOrED,gBAAgBS,UAAUF,SAAW,kBAC1B8C,aAAaC,KAAKK,cAActD,KAAKqD,gBAQhD1D,gBAAgBS,UAAUH,aAAe,kBAC9B+C,aAAaC,KAAKE,aAAa,QAAS,CAACnD,KAAKJ,WAAY,WAOrED,gBAAgBS,UAAUmD,WAAa,SAASC,OACxCC,KAAOhE,SAASiE,QAAQF,MACvBC,KAAKE,WAINC,eAAiB5D,KACb6D,MAAQJ,KAAKzB,EACb8B,MAAQL,KAAKxB,EACb8B,UAAY/D,KAAKgE,cAAcP,KAAKzB,EAAGyB,KAAKxB,GAC5CgC,MAAQzE,EAAE,iDACV0E,KAAOD,MAAME,QACbC,KAAOH,MAAMI,SAErB5E,SAASkE,MAAMH,EAAGhE,EAAEuE,YAAY,SAASO,MAAOC,OAC5CX,eAAe9D,MAAM0E,KAAKF,MAAQT,MAAOU,MAAQT,MAAOI,KAAME,MAC9DP,MAAQS,MACRR,MAAQS,MACRX,eAAe1C,cACf0C,eAAezC,0BAChB,WACCsD,SAASC,KAAKjC,YAAYsB,gBAUlCpE,gBAAgBS,UAAUuE,WAAa,SAASnB,EAAGoB,YAAavE,SACxDoD,KAAOhE,SAASiE,QAAQF,MACvBC,KAAKE,OAKmB,YAAzB3D,KAAKF,MAAMW,YAA4B+C,EAAEqB,SAAWrB,EAAEsB,gBACjDhF,MAAMiF,iBAAiBH,kBACvB7D,qBACAC,SAASX,UACTY,iBAGL+D,iBAAmBhF,KACnB6D,MAAQJ,KAAKzB,EACb8B,MAAQL,KAAKxB,EACb8B,UAAY/D,KAAKgE,cAAcP,KAAKzB,EAAGyB,KAAKxB,GAC5CgC,MAAQzE,EAAE,iDACV0E,KAAOD,MAAME,QACbC,KAAOH,MAAMI,SAEjB5E,SAASkE,MAAMH,EAAGhE,EAAEuE,YAAY,SAASO,MAAOC,OAC5CS,iBAAiBlF,MAAMmF,KAAKL,YAAaN,MAAQT,MAAOU,MAAQT,MAAOI,KAAME,MAC7EP,MAAQS,MACRR,MAAQS,MACRS,iBAAiB9D,cACjB8D,iBAAiB7D,0BAClB,WACCsD,SAASC,KAAKjC,YAAYsB,WAC1BiB,iBAAiBlF,MAAMoF,iBACvBF,iBAAiB9D,cACjB8D,iBAAiB7D,4BAWzBxB,gBAAgBS,UAAU4D,cAAgB,SAAShC,EAAGC,OAC9C8B,UAAYU,SAASU,cAAc,cACvCpB,UAAUqB,MAAMC,SAAW,WAC3BtB,UAAUqB,MAAME,IAAMrD,EAAI,KAC1B8B,UAAUqB,MAAMG,KAAOvD,EAAI,KAC3B+B,UAAUqB,MAAMjB,MAAQ,MACxBJ,UAAUqB,MAAMf,OAAS,MACzBI,SAASC,KAAK3B,YAAYgB,WACnBA,eAMPf,aAAe,CAKfwC,GAAI,KAKJC,YAAa,KAKbC,UAAW,GAKXC,KAAM,WACF3C,aAAawC,GAAKxC,aAAa4C,cAC/B5C,aAAayC,YAAczC,aAAaC,KAAKE,aAAa,aAAc,IACxEH,aAAa6C,iCACb7C,aAAa8C,eACb9C,aAAa+C,qBACb/C,aAAagD,iCAMjBC,iBAAkB,WACdzG,EAAE,gDAAgD0G,OAC9C,kMAYRL,+BAAgC,eAIxBM,SACAjE,EAAGb,MAJH+E,iBAAmB,GAAM,IACzBC,QAAUrD,aAAaC,KAAKE,aAAa,UAAW,IACpDmD,eAAiB,OAGhBpE,EAAI,EAAGA,GAAKmE,QAASnE,IAER,MADdb,MAAQ2B,aAAaC,KAAKK,cAAcpB,MAGpCkE,iBAAiBlE,GAAK1C,EAAE,UAAU+G,KAAKlF,OAAOmF,YAIjDtE,EAAI,EAAGA,EAAIc,aAAayC,YAAavD,IACtCiE,SAAW3G,EAAE,aAAe0C,EAAI,WAChCoE,eAAepE,GAAKuE,OAAON,SAASO,WAEnCxE,EAAI,EAAGA,EAAIc,aAAayC,YAAavD,IAAK,KAKtC,IAAIyE,SAJTR,SAAW3G,EAAE,aAAe0C,EAAI,YAEvB0E,KAAK,UAAUC,SAENT,iBAAkB,KAE5BU,OAAS,mBADbH,MAAQF,OAAOE,QAC0B,KAAOP,iBAAiBO,OAAS,YAC1ER,SAASD,OAAOY,YACZC,WAAaZ,SAASS,KAAK,iBAAmBD,MAAQ,SAG5C,IAAVA,SAKAA,QAAUL,eAAepE,QAMzB8E,UAAYhE,aAAaC,KAAKE,aAAa,QAAS,CAACwD,MAAQ,EAAG,iBAC1C,IAAtBF,OAAOO,eAKN,IAAIC,KAAKX,kBACNG,OAAOH,eAAeW,MAAQN,UAKR,IAAtBF,OAAOO,WAAkB,CACzBD,WAAWG,KAAK,YAAY,SAG5BF,kBArBJD,WAAWG,KAAK,YAAY,GA0BhClE,aAAa0C,UAAU/E,OAAS,GAChCqC,aAAa0C,UAAUxD,GAAGd,gBAQtC0E,aAAc,eACL,IAAIlG,WAAa,EAAGA,WAAaoD,aAAayC,YAAa7F,aAC5DoD,aAAa0C,UAAU9F,YAAc,IAAID,gBAAgBC,aAOjEmG,mBAAoB,WAEhBvG,EAAE,mCAAmC2H,GAAG,eAAgB,iBAAiB,WACrEnE,aAAa6C,oCAIjBrG,EAAE,8BAA8B2H,GAAG,eAAgB,iBAAiB,SAAS3D,OACrE4D,IAAM5D,EAAE6D,cAAcC,KAAKzE,MAAM,iCAChCuE,SAIDxH,WAAawH,IAAI,GACjBG,UAAYH,IAAI,GAChBI,SAAWxE,aAAa0C,UAAU9F,mBAE9B2H,eACC,QACDC,SAASlG,YAAY0B,aAAaC,KAAKwE,oBAGtC,SACDD,SAASrH,0BAA0B6C,aAAaC,KAAKwE,oBAGpD,SACDD,SAASpG,uBAMjBsG,YAAclI,EAAE,iCACpBkI,YAAYP,GAAG,QAAS,cAAc,SAAS3D,OACvC5D,WAAaJ,EAAEgE,EAAE6D,eAAeM,KAAK,eACrCC,gBAAkB5E,aAAa0C,UAAU9F,YAAYkB,WAEzDtB,EAAEwD,aAAaC,KAAKwE,UAAUb,KAAK,oBAAoBiB,YAAY,UAE9DD,iBACD5E,aAAa0C,UAAU9F,YAAYqB,eAK3CyG,YAAYP,GAAG,uBAAwB,0BAA0B,SAAS3D,OAClE5D,WAAaJ,EAAEgE,EAAE6D,eAAeS,QAAQ,KAAKH,KAAK,cAEtD3E,aAAa0C,UAAU9F,YAAY2D,WAAWC,MAIlDkE,YAAYP,GAAG,uBAAwB,0BAA0B,SAAS3D,OAClE5D,WAAaJ,EAAEgE,EAAE6D,eAAeS,QAAQ,KAAKH,KAAK,cAClD/C,YAAcpB,EAAE6D,cAAczE,aAAa,uBAE/CI,aAAa0C,UAAU9F,YAAY+E,WAAWnB,EAAGoB,YAAa5B,aAAaC,KAAKwE,cAOxFzB,8BAA+B,WACkB,OAAzChD,aAAawC,GAAGuC,KAAK,WAAWC,MAUpCxI,EAAE,qCAAqC2H,GAAG,SAAU,cAAenE,aAAaiF,kBAE5EzI,EAAE,iBAAiBmB,QAInBqC,aAAaiD,mBAHbjD,aAAaiF,oBAVbC,WAAWlF,aAAagD,8BAA+B,MAqB/DiC,iBAAkB,WACdzI,EAAE,iDACO2I,IAAI,OAAQnF,aAAaoF,yBACzBlB,KAAK,MAAOlE,aAAawC,GAAGuC,KAAK,WAAWC,OAMzDI,wBAAyB,eACjBnE,MAAQzE,EAAE,iDAEdA,EAAE,iBAAiB6I,IAAI,WAAY,YAAYA,IAAI,OAA+B,GAAvBpE,MAAMI,SAAW,IAC5E7E,EAAE,iBAAiB6I,IAAI,SAAUpE,MAAMI,SAAW,IAClDrB,aAAasF,oBAOjBA,iBAAkB,eAEV1I,WADAqE,MAAQzE,EAAE,oDAGVwD,aAAaC,KAAKwE,aAEb7H,WAAa,EAAGA,WAAaoD,aAAayC,YAAa7F,aACxDoD,aAAa0C,UAAU9F,YAAYsB,uBAKvC1B,EAAE,iBAAiBgH,KAAK,oEACRvC,MAAMsE,aADE,aAEPtE,MAAMuE,cAAgB,YAClC5I,WAAa,EAAGA,WAAaoD,aAAayC,YAAa7F,aACxDoD,aAAa0C,UAAU9F,YAAYoB,SAASgC,aAAaC,KAAKwE,WAQ1ExE,KAAM,CAMFK,cAAe,SAASmF,iBACK,IAArBhC,OAAOgC,UACKzF,aAAaC,KAAKE,aAAa,QAAS,CAACsF,SAAW,EAAG,UACtDrF,QAAQ,IAAIsF,OAAO,kBAAmB,MAE5C,IASfjB,OAAQ,eACApH,IAAMb,EAAE,4CACO,IAAfa,IAAIM,OACG,KAEAN,IAAI,IAInBsI,gBAAiB,SAASrB,KAAMsB,iBACxBC,YAAcvB,KACTpF,EAAI,EAAGA,EAAI0G,QAAQjI,OAAQuB,IAChC2G,YAAcA,YAAc,IAAMD,QAAQ1G,GAAK,WAE5C2G,aAGXC,MAAO,SAASxB,KAAMsB,gBACPpJ,EAAE,qCAAqC,GACtCuJ,SAAS/I,KAAK2I,gBAAgBrB,KAAMsB,WAUpDzF,aAAc,SAASmE,KAAMsB,aACrBI,GAAKhJ,KAAK8I,MAAMxB,KAAMsB,eACV,aAAZI,GAAGC,KACID,GAAGE,QAEHF,GAAGrC,OAWlBzD,aAAc,SAASoE,KAAMsB,QAASjC,WAC9BqC,GAAKhJ,KAAK8I,MAAMxB,KAAMsB,SACV,aAAZI,GAAGC,KACHD,GAAGE,QAAUvC,MAEbqC,GAAGrC,MAAQA,QASvBf,YAAa,eACLuD,mBACAC,6BACuBC,IAAvBF,qBACAA,mBAAqB,GACrBC,iBAAmB,GACnB5J,EAAE,qCAAqC8J,MAAK,SAASC,IAAKC,YACtDL,mBAAmBK,WAAW7C,OAAS6C,WAAWlC,KAClD8B,iBAAiBI,WAAWlC,MAAQkC,WAAWhH,eAGhD,CACHuF,KAAM,SAAST,UACPmC,WAAajK,EAAE4J,iBAAiB9B,OAAOV,KAAK,oCAC5C6C,WAAW9I,OACJ,CAACqH,KAAMyB,WAAWC,IAAI,GAAG1B,KAAMV,KAAMmC,WAAWC,IAAI,GAAGC,WAEvD,CAAC3B,KAAM,KAAMV,KAAM,OAGlCA,KAAM,SAASsC,oBACJT,mBAAmBS,uBASnC,CAKHjE,KAAM3C,aAAa2C"}PK\W..#type/ddmarker/amd/build/form.min.jsnu[/** * This class provides the enhancements to the drag-drop marker editing form. * * @module qtype_ddmarker/form * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("qtype_ddmarker/form",["jquery","core/dragdrop","qtype_ddmarker/shapes"],(function($,dragDrop,Shapes){function DropZoneManager(dropzoneNo){this.dropzoneNo=dropzoneNo,this.svgEl=null,this.shape=Shapes.make(this.getShapeType(),this.getLabel()),this.updateCoordinatesFromForm()}DropZoneManager.prototype.updateCoordinatesFromForm=function(svg){var coordinates=this.getCoordinates(),currentNumPoints="polygon"===this.shape.getType()&&this.shape.points.length;if(this.shape.getCoordinates()!==coordinates&&this.shape.parse(coordinates,1)){if("polygon"===this.shape.getType()&¤tNumPoints!==this.shape.points.length){var currentyActive=this.isActive();this.removeFromSvg(),svg&&(this.addToSvg(svg),currentyActive&&this.setActive())}else this.updateSvgEl();this.setCoordinatesInForm()}},DropZoneManager.prototype.updateLabel=function(){var label=this.getLabel();this.shape.label!==label&&(this.shape.label=label,this.updateSvgEl())},DropZoneManager.prototype.changeShape=function(svg){var newShapeType=this.getShapeType(),currentyActive=this.isActive();newShapeType!==this.shape.getType()&&(this.removeFromSvg(),this.shape=Shapes.getSimilar(newShapeType,this.shape),svg&&(this.addToSvg(svg),currentyActive&&this.setActive()),this.setCoordinatesInForm())},DropZoneManager.prototype.addToSvg=function(svg){if(null!==this.svgEl)throw new Error("this.svgEl already set");if(this.svgEl=this.shape.makeSvg(svg),this.svgEl){this.svgEl.setAttribute("class","dropzone"),this.svgEl.setAttribute("data-dropzone-no",this.dropzoneNo);var handles=this.shape.getHandlePositions();if(null!==handles){var moveHandle=Shapes.createSvgElement(this.svgEl,"circle");moveHandle.setAttribute("cx",handles.moveHandle.x),moveHandle.setAttribute("cy",handles.moveHandle.y),moveHandle.setAttribute("r",7),moveHandle.setAttribute("class","handle move");for(var i=0;i
    ')},setOptionsForDragItemSelectors:function(){var selector,i,label,dragItemsOptions={0:""},noItems=dragDropForm.form.getFormValue("noitems",[]),selectedValues=[];for(i=1;i<=noItems;i++)""!==(label=dragDropForm.form.getMarkerText(i))&&(dragItemsOptions[i]=$("
    ").text(label).html());for(i=0;i'+dragItemsOptions[value]+"";selector.append(option);var optionnode=selector.find('option[value="'+value+'"]');if(0!==value)if(value!==selectedValues[i]){var noofdrags=dragDropForm.form.getFormValue("drags",[value-1,"noofdrags"]);if(0!==Number(noofdrags))for(var k in selectedValues)if(Number(selectedValues[k])===value){if(1===Number(noofdrags)){optionnode.attr("disabled",!0);break}noofdrags--}}else optionnode.attr("selected",!0)}dragDropForm.dropZones.length>0&&dragDropForm.dropZones[i].updateLabel()}},createShapes:function(){for(var dropzoneNo=0;dropzoneNo'),dropzoneNo=0;dropzoneNo.\n\n/* eslint max-depth: [\"error\", 8] */\n\n/**\n * Library of classes for handling simple shapes.\n *\n * These classes can represent shapes, let you alter them, can go to and from a string\n * representation, and can give you an SVG representation.\n *\n * @module qtype_ddmarker/shapes\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(function() {\n\n \"use strict\";\n\n /**\n * A point, with x and y coordinates.\n *\n * @param {int} x centre X.\n * @param {int} y centre Y.\n * @constructor\n */\n function Point(x, y) {\n this.x = x;\n this.y = y;\n }\n\n /**\n * Standard toString method.\n * @returns {string} \"x;y\";\n */\n Point.prototype.toString = function() {\n return this.x + ',' + this.y;\n };\n\n /**\n * Move a point\n * @param {int} dx x offset\n * @param {int} dy y offset\n */\n Point.prototype.move = function(dx, dy) {\n this.x += dx;\n this.y += dy;\n };\n\n /**\n * Return a new point that is a certain position relative to this one.\n *\n * @param {(int|Point)} offsetX if a point, offset by this points coordinates, else and int x offset.\n * @param {int} [offsetY] used if offsetX is an int, the corresponding y offset.\n * @return {Point} the new point.\n */\n Point.prototype.offset = function(offsetX, offsetY) {\n if (offsetX instanceof Point) {\n offsetY = offsetX.y;\n offsetX = offsetX.x;\n }\n return new Point(this.x + offsetX, this.y + offsetY);\n };\n\n /**\n * Make a point from the string representation.\n *\n * @param {String} coordinates \"x,y\".\n * @return {Point} the point. Throws an exception if input is not valid.\n */\n Point.parse = function(coordinates) {\n var bits = coordinates.split(',');\n if (bits.length !== 2) {\n throw new Error(coordinates + ' is not a valid point');\n }\n return new Point(Math.round(bits[0]), Math.round(bits[1]));\n };\n\n\n /**\n * Shape constructor. Abstract class to represent the different types of drop zone shapes.\n *\n * @param {String} [label] name of this area.\n * @param {int} [x] centre X.\n * @param {int} [y] centre Y.\n * @constructor\n */\n function Shape(label, x, y) {\n this.label = label;\n this.centre = new Point(x || 0, y || 0);\n }\n\n /**\n * Get the type of shape.\n *\n * @return {String} 'circle', 'rectangle' or 'polygon';\n */\n Shape.prototype.getType = function() {\n throw new Error('Not implemented.');\n };\n\n /**\n * Get the string representation of this shape.\n *\n * @return {String} coordinates as they need to be typed into the form.\n */\n Shape.prototype.getCoordinates = function() {\n throw new Error('Not implemented.');\n };\n\n /**\n * Update the shape from the string representation.\n *\n * @param {String} coordinates in the form returned by getCoordinates.\n * @param {number} ratio Ratio to scale.\n * @return {boolean} true if the string could be parsed and the shape updated, else false.\n */\n Shape.prototype.parse = function(coordinates, ratio) {\n void (coordinates, ratio);\n throw new Error('Not implemented.');\n };\n\n /**\n * Move the entire shape by this offset.\n *\n * @param {int} dx x offset.\n * @param {int} dy y offset.\n * @param {int} maxX ensure that after editing, the shape lies between 0 and maxX on the x-axis.\n * @param {int} maxY ensure that after editing, the shape lies between 0 and maxX on the y-axis.\n */\n Shape.prototype.move = function(dx, dy, maxX, maxY) {\n void (maxY);\n };\n\n /**\n * Move one of the edit handles by this offset.\n *\n * @param {int} handleIndex which handle was moved.\n * @param {int} dx x offset.\n * @param {int} dy y offset.\n * @param {int} maxX ensure that after editing, the shape lies between 0 and maxX on the x-axis.\n * @param {int} maxY ensure that after editing, the shape lies between 0 and maxX on the y-axis.\n */\n Shape.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) {\n void (maxY);\n };\n\n /**\n * Update the properties of this shape after a sequence of edits.\n *\n * For example make sure the circle radius is positive, of the polygon centre is centred.\n */\n Shape.prototype.normalizeShape = function() {\n void (1); // To make CiBoT happy.\n };\n\n /**\n * Get the string representation of this shape.\n *\n * @param {SVGElement} svg the SVG graphic to add this shape to.\n * @return {SVGElement} SVG representation of this shape.\n */\n Shape.prototype.makeSvg = function(svg) {\n void (svg);\n throw new Error('Not implemented.');\n };\n\n /**\n * Update the SVG representation of this shape.\n *\n * @param {SVGElement} svgEl the SVG representation of this shape.\n */\n Shape.prototype.updateSvg = function(svgEl) {\n void (svgEl);\n };\n\n /**\n * Make a circle similar to this shape.\n *\n * @return {Circle} a circle that is about the same size and position as this shape.\n */\n Shape.prototype.makeSimilarCircle = function() {\n throw new Error('Not implemented.');\n };\n\n /**\n * Make a rectangle similar to this shape.\n *\n * @return {Rectangle} a rectangle that is about the same size and position as this shape.\n */\n Shape.prototype.makeSimilarRectangle = function() {\n throw new Error('Not implemented.');\n };\n\n /**\n * Make a polygon similar to this shape.\n *\n * @return {Polygon} a polygon that is about the same size and position as this shape.\n */\n Shape.prototype.makeSimilarPolygon = function() {\n throw new Error('Not implemented.');\n };\n\n /**\n * Get the handles that should be offered to edit this shape, or null if not appropriate.\n *\n * @return {Object[]} with properties moveHandle {Point} and editHandles {Point[]}\n */\n Shape.prototype.getHandlePositions = function() {\n return null;\n };\n\n\n /**\n * A shape that is a circle.\n *\n * @param {String} label name of this area.\n * @param {int} [x] centre X.\n * @param {int} [y] centre Y.\n * @param {int} [radius] radius.\n * @constructor\n */\n function Circle(label, x, y, radius) {\n x = x || 15;\n y = y || 15;\n Shape.call(this, label, x, y);\n this.radius = radius || 15;\n }\n Circle.prototype = new Shape();\n\n Circle.prototype.getType = function() {\n return 'circle';\n };\n\n Circle.prototype.getCoordinates = function() {\n return this.centre + ';' + Math.abs(this.radius);\n };\n\n Circle.prototype.makeSvg = function(svg) {\n var svgEl = createSvgShapeGroup(svg, 'circle');\n this.updateSvg(svgEl);\n return svgEl;\n };\n\n Circle.prototype.updateSvg = function(svgEl) {\n svgEl.childNodes[0].setAttribute('cx', this.centre.x);\n svgEl.childNodes[0].setAttribute('cy', this.centre.y);\n svgEl.childNodes[0].setAttribute('r', Math.abs(this.radius));\n svgEl.childNodes[1].setAttribute('x', this.centre.x);\n svgEl.childNodes[1].setAttribute('y', this.centre.y + 15);\n svgEl.childNodes[1].textContent = this.label;\n };\n\n Circle.prototype.parse = function(coordinates, ratio) {\n if (!coordinates.match(/^\\d+(\\.\\d+)?,\\d+(\\.\\d+)?;\\d+(\\.\\d+)?$/)) {\n return false;\n }\n\n var bits = coordinates.split(';');\n this.centre = Point.parse(bits[0]);\n this.centre.x = this.centre.x * parseFloat(ratio);\n this.centre.y = this.centre.y * parseFloat(ratio);\n this.radius = Math.round(bits[1]) * parseFloat(ratio);\n return true;\n };\n\n Circle.prototype.move = function(dx, dy, maxX, maxY) {\n this.centre.move(dx, dy);\n if (this.centre.x < this.radius) {\n this.centre.x = this.radius;\n }\n if (this.centre.x > maxX - this.radius) {\n this.centre.x = maxX - this.radius;\n }\n if (this.centre.y < this.radius) {\n this.centre.y = this.radius;\n }\n if (this.centre.y > maxY - this.radius) {\n this.centre.y = maxY - this.radius;\n }\n };\n\n Circle.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) {\n this.radius += dx;\n var limit = Math.min(this.centre.x, this.centre.y, maxX - this.centre.x, maxY - this.centre.y);\n if (this.radius > limit) {\n this.radius = limit;\n }\n if (this.radius < -limit) {\n this.radius = -limit;\n }\n };\n\n /**\n * Update the properties of this shape after a sequence of edits.\n *\n * For example make sure the circle radius is positive, of the polygon centre is centred.\n */\n Circle.prototype.normalizeShape = function() {\n this.radius = Math.abs(this.radius);\n };\n\n Circle.prototype.makeSimilarRectangle = function() {\n return new Rectangle(this.label,\n this.centre.x - this.radius, this.centre.y - this.radius,\n this.radius * 2, this.radius * 2);\n };\n\n Circle.prototype.makeSimilarPolygon = function() {\n // We make a similar square, so if you go to and from Rectangle afterwards, it is loss-less.\n return new Polygon(this.label, [\n this.centre.offset(-this.radius, -this.radius), this.centre.offset(-this.radius, this.radius),\n this.centre.offset(this.radius, this.radius), this.centre.offset(this.radius, -this.radius)]);\n };\n\n Circle.prototype.getHandlePositions = function() {\n return {\n moveHandle: this.centre,\n editHandles: [this.centre.offset(this.radius, 0)]\n };\n };\n\n\n /**\n * A shape that is a rectangle.\n *\n * @param {String} label name of this area.\n * @param {int} [x] top left X.\n * @param {int} [y] top left Y.\n * @param {int} [width] width.\n * @param {int} [height] height.\n * @constructor\n */\n function Rectangle(label, x, y, width, height) {\n Shape.call(this, label, x, y);\n this.width = width || 30;\n this.height = height || 30;\n }\n Rectangle.prototype = new Shape();\n\n Rectangle.prototype.getType = function() {\n return 'rectangle';\n };\n\n Rectangle.prototype.getCoordinates = function() {\n return this.centre + ';' + this.width + ',' + this.height;\n };\n\n Rectangle.prototype.makeSvg = function(svg) {\n var svgEl = createSvgShapeGroup(svg, 'rect');\n this.updateSvg(svgEl);\n return svgEl;\n };\n\n Rectangle.prototype.updateSvg = function(svgEl) {\n if (this.width >= 0) {\n svgEl.childNodes[0].setAttribute('x', this.centre.x);\n svgEl.childNodes[0].setAttribute('width', this.width);\n } else {\n svgEl.childNodes[0].setAttribute('x', this.centre.x + this.width);\n svgEl.childNodes[0].setAttribute('width', -this.width);\n }\n if (this.height >= 0) {\n svgEl.childNodes[0].setAttribute('y', this.centre.y);\n svgEl.childNodes[0].setAttribute('height', this.height);\n } else {\n svgEl.childNodes[0].setAttribute('y', this.centre.y + this.height);\n svgEl.childNodes[0].setAttribute('height', -this.height);\n }\n\n svgEl.childNodes[1].setAttribute('x', this.centre.x + this.width / 2);\n svgEl.childNodes[1].setAttribute('y', this.centre.y + this.height / 2 + 15);\n svgEl.childNodes[1].textContent = this.label;\n };\n\n Rectangle.prototype.parse = function(coordinates, ratio) {\n if (!coordinates.match(/^\\d+(\\.\\d+)?,\\d+(\\.\\d+)?;\\d+(\\.\\d+)?,\\d+(\\.\\d+)?$/)) {\n return false;\n }\n\n var bits = coordinates.split(';');\n this.centre = Point.parse(bits[0]);\n this.centre.x = this.centre.x * parseFloat(ratio);\n this.centre.y = this.centre.y * parseFloat(ratio);\n var size = Point.parse(bits[1]);\n this.width = size.x * parseFloat(ratio);\n this.height = size.y * parseFloat(ratio);\n return true;\n };\n\n Rectangle.prototype.move = function(dx, dy, maxX, maxY) {\n this.centre.move(dx, dy);\n if (this.centre.x < 0) {\n this.centre.x = 0;\n }\n if (this.centre.x > maxX - this.width) {\n this.centre.x = maxX - this.width;\n }\n if (this.centre.y < 0) {\n this.centre.y = 0;\n }\n if (this.centre.y > maxY - this.height) {\n this.centre.y = maxY - this.height;\n }\n };\n\n Rectangle.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) {\n this.width += dx;\n this.height += dy;\n if (this.width < -this.centre.x) {\n this.width = -this.centre.x;\n }\n if (this.width > maxX - this.centre.x) {\n this.width = maxX - this.centre.x;\n }\n if (this.height < -this.centre.y) {\n this.height = -this.centre.y;\n }\n if (this.height > maxY - this.centre.y) {\n this.height = maxY - this.centre.y;\n }\n };\n\n /**\n * Update the properties of this shape after a sequence of edits.\n *\n * For example make sure the circle radius is positive, of the polygon centre is centred.\n */\n Rectangle.prototype.normalizeShape = function() {\n if (this.width < 0) {\n this.centre.x += this.width;\n this.width = -this.width;\n }\n if (this.height < 0) {\n this.centre.y += this.height;\n this.height = -this.height;\n }\n };\n\n Rectangle.prototype.makeSimilarCircle = function() {\n return new Circle(this.label,\n Math.round(this.centre.x + this.width / 2),\n Math.round(this.centre.y + this.height / 2),\n Math.round((this.width + this.height) / 4));\n };\n\n Rectangle.prototype.makeSimilarPolygon = function() {\n return new Polygon(this.label, [\n this.centre, this.centre.offset(0, this.height),\n this.centre.offset(this.width, this.height), this.centre.offset(this.width, 0)]);\n };\n\n Rectangle.prototype.getHandlePositions = function() {\n return {\n moveHandle: this.centre.offset(this.width / 2, this.height / 2),\n editHandles: [this.centre.offset(this.width, this.height)]\n };\n };\n\n\n /**\n * A shape that is a polygon.\n *\n * @param {String} label name of this area.\n * @param {Point[]} [points] position of the vertices relative to (centreX, centreY).\n * each object in the array should have two\n * @constructor\n */\n function Polygon(label, points) {\n Shape.call(this, label, 0, 0);\n this.points = points ? points.slice() : [new Point(10, 10), new Point(40, 10), new Point(10, 40)];\n this.normalizeShape();\n this.ratio = 1;\n }\n Polygon.prototype = new Shape();\n\n Polygon.prototype.getType = function() {\n return 'polygon';\n };\n\n Polygon.prototype.getCoordinates = function() {\n var coordinates = '';\n for (var i = 0; i < this.points.length; i++) {\n coordinates += this.centre.offset(this.points[i]) + ';';\n }\n return coordinates.slice(0, coordinates.length - 1); // Strip off the last ';'.\n };\n\n Polygon.prototype.makeSvg = function(svg) {\n var svgEl = createSvgShapeGroup(svg, 'polygon');\n this.updateSvg(svgEl);\n return svgEl;\n };\n\n Polygon.prototype.updateSvg = function(svgEl) {\n svgEl.childNodes[0].setAttribute('points', this.getCoordinates().replace(/[,;]/g, ' '));\n svgEl.childNodes[0].setAttribute('transform', 'scale(' + parseFloat(this.ratio) + ')');\n svgEl.childNodes[1].setAttribute('x', this.centre.x);\n svgEl.childNodes[1].setAttribute('y', this.centre.y + 15);\n svgEl.childNodes[1].textContent = this.label;\n };\n\n Polygon.prototype.parse = function(coordinates, ratio) {\n if (!coordinates.match(/^\\d+(\\.\\d+)?,\\d+(\\.\\d+)?(?:;\\d+(\\.\\d+)?,\\d+(\\.\\d+)?)*$/)) {\n return false;\n }\n\n var bits = coordinates.split(';');\n var points = [];\n for (var i = 0; i < bits.length; i++) {\n points.push(Point.parse(bits[i]));\n }\n\n this.points = points;\n this.centre.x = 0;\n this.centre.y = 0;\n this.ratio = ratio;\n this.normalizeShape();\n\n return true;\n };\n\n Polygon.prototype.move = function(dx, dy, maxX, maxY) {\n this.centre.move(dx, dy);\n var bbXMin = maxX,\n bbXMax = 0,\n bbYMin = maxY,\n bbYMax = 0;\n // Computer centre.\n for (var i = 0; i < this.points.length; i++) {\n bbXMin = Math.min(bbXMin, this.points[i].x);\n bbXMax = Math.max(bbXMax, this.points[i].x);\n bbYMin = Math.min(bbYMin, this.points[i].y);\n bbYMax = Math.max(bbYMax, this.points[i].y);\n }\n if (this.centre.x < -bbXMin) {\n this.centre.x = -bbXMin;\n }\n if (this.centre.x > maxX - bbXMax) {\n this.centre.x = maxX - bbXMax;\n }\n if (this.centre.y < -bbYMin) {\n this.centre.y = -bbYMin;\n }\n if (this.centre.y > maxY - bbYMax) {\n this.centre.y = maxY - bbYMax;\n }\n };\n\n Polygon.prototype.edit = function(handleIndex, dx, dy, maxX, maxY) {\n this.points[handleIndex].move(dx, dy);\n if (this.points[handleIndex].x < -this.centre.x) {\n this.points[handleIndex].x = -this.centre.x;\n }\n if (this.points[handleIndex].x > maxX - this.centre.x) {\n this.points[handleIndex].x = maxX - this.centre.x;\n }\n if (this.points[handleIndex].y < -this.centre.y) {\n this.points[handleIndex].y = -this.centre.y;\n }\n if (this.points[handleIndex].y > maxY - this.centre.y) {\n this.points[handleIndex].y = maxY - this.centre.y;\n }\n };\n\n /**\n * Add a new point after the given point, with the same co-ordinates.\n *\n * This does not automatically normalise.\n *\n * @param {int} pointIndex the index of the vertex after which to insert this new one.\n */\n Polygon.prototype.addNewPointAfter = function(pointIndex) {\n this.points.splice(pointIndex, 0,\n new Point(this.points[pointIndex].x, this.points[pointIndex].y));\n };\n\n Polygon.prototype.normalizeShape = function() {\n var i,\n x = 0,\n y = 0;\n\n if (this.points.length === 0) {\n return;\n }\n\n // Computer centre.\n for (i = 0; i < this.points.length; i++) {\n x += this.points[i].x;\n y += this.points[i].y;\n }\n x = Math.round(x / this.points.length);\n y = Math.round(y / this.points.length);\n\n if (x === 0 && y === 0) {\n return;\n }\n\n for (i = 0; i < this.points.length; i++) {\n this.points[i].move(-x, -y);\n }\n this.centre.move(x, y);\n };\n\n Polygon.prototype.makeSimilarCircle = function() {\n return this.makeSimilarRectangle().makeSimilarCircle();\n };\n\n Polygon.prototype.makeSimilarRectangle = function() {\n var p,\n minX = 0,\n maxX = 0,\n minY = 0,\n maxY = 0;\n for (var i = 0; i < this.points.length; i++) {\n p = this.points[i];\n minX = Math.min(minX, p.x);\n maxX = Math.max(maxX, p.x);\n minY = Math.min(minY, p.y);\n maxY = Math.max(maxY, p.y);\n }\n return new Rectangle(this.label,\n this.centre.x + minX, this.centre.y + minY,\n Math.max(maxX - minX, 10), Math.max(maxY - minY, 10));\n };\n\n Polygon.prototype.getHandlePositions = function() {\n var editHandles = [];\n for (var i = 0; i < this.points.length; i++) {\n editHandles.push(this.points[i].offset(this.centre.x, this.centre.y));\n }\n\n this.centre.x = this.centre.x * parseFloat(this.ratio);\n this.centre.y = this.centre.y * parseFloat(this.ratio);\n\n return {\n moveHandle: this.centre,\n editHandles: editHandles\n };\n };\n\n\n /**\n * Not a shape (null object pattern).\n *\n * @param {String} label name of this area.\n * @constructor\n */\n function NullShape(label) {\n Shape.call(this, label);\n }\n NullShape.prototype = new Shape();\n\n NullShape.prototype.getType = function() {\n return 'null';\n };\n\n NullShape.prototype.getCoordinates = function() {\n return '';\n };\n\n NullShape.prototype.makeSvg = function(svg) {\n void (svg);\n return null;\n };\n\n NullShape.prototype.updateSvg = function(svgEl) {\n void (svgEl);\n };\n\n NullShape.prototype.parse = function(coordinates) {\n void (coordinates);\n return false;\n };\n\n NullShape.prototype.makeSimilarCircle = function() {\n return new Circle(this.label);\n };\n\n NullShape.prototype.makeSimilarRectangle = function() {\n return new Rectangle(this.label);\n };\n\n NullShape.prototype.makeSimilarPolygon = function() {\n return new Polygon(this.label);\n };\n\n\n /**\n * Make a new SVG DOM element as a child of svg.\n *\n * @param {SVGElement} svg the parent node.\n * @param {String} tagName the tag name.\n * @return {SVGElement} the newly created node.\n */\n function createSvgElement(svg, tagName) {\n var svgEl = svg.ownerDocument.createElementNS('http://www.w3.org/2000/svg', tagName);\n svg.appendChild(svgEl);\n return svgEl;\n }\n\n /**\n * Make a group SVG DOM elements containing a shape of the given type as first child,\n * and a text label as the second child.\n *\n * @param {SVGElement} svg the parent node.\n * @param {String} tagName the tag name.\n * @return {SVGElement} the newly created g element.\n */\n function createSvgShapeGroup(svg, tagName) {\n var svgEl = createSvgElement(svg, 'g');\n createSvgElement(svgEl, tagName).setAttribute('class', 'shape');\n createSvgElement(svgEl, 'text').setAttribute('class', 'shapeLabel');\n return svgEl;\n }\n\n /**\n * @alias module:qtype_ddmarker/shapes\n */\n return {\n /**\n * A point, with x and y coordinates.\n *\n * @param {int} x centre X.\n * @param {int} y centre Y.\n * @constructor\n */\n Point: Point,\n\n /**\n * A point, with x and y coordinates.\n *\n * @param {int} x centre X.\n * @param {int} y centre Y.\n * @constructor\n */\n Shape: Shape,\n\n /**\n * A shape that is a circle.\n *\n * @param {String} label name of this area.\n * @param {int} [x] centre X.\n * @param {int} [y] centre Y.\n * @param {int} [radius] radius.\n * @constructor\n */\n Circle: Circle,\n\n /**\n * A shape that is a rectangle.\n *\n * @param {String} label name of this area.\n * @param {int} [x] top left X.\n * @param {int} [y] top left Y.\n * @param {int} [width] width.\n * @param {int} [height] height.\n * @constructor\n */\n Rectangle: Rectangle,\n\n /**\n * A shape that is a polygon.\n *\n * @param {String} label name of this area.\n * @param {Point[]} [points] position of the vertices relative to (centreX, centreY).\n * each object in the array should have two\n * @constructor\n */\n Polygon: Polygon,\n\n /**\n * Not a shape (null object pattern).\n *\n * @param {String} label name of this area.\n * @constructor\n */\n NullShape: NullShape,\n\n /**\n * Make a new SVG DOM element as a child of svg.\n *\n * @param {SVGElement} svg the parent node.\n * @param {String} tagName the tag name.\n * @return {SVGElement} the newly created node.\n */\n createSvgElement: createSvgElement,\n\n /**\n * Make a shape of the given type.\n *\n * @param {String} shapeType\n * @param {String} label\n * @return {Shape} the requested shape.\n */\n make: function(shapeType, label) {\n switch (shapeType) {\n case 'circle':\n return new Circle(label);\n case 'rectangle':\n return new Rectangle(label);\n case 'polygon':\n return new Polygon(label);\n default:\n return new NullShape(label);\n }\n },\n\n /**\n * Make a shape of the given type that is similar to the shape of the original type.\n *\n * @param {String} shapeType the new type of shape to make\n * @param {Shape} shape the shape to copy\n * @return {Shape} the similar shape of a different type.\n */\n getSimilar: function(shapeType, shape) {\n if (shapeType === shape.getType()) {\n return shape;\n }\n switch (shapeType) {\n case 'circle':\n return shape.makeSimilarCircle();\n case 'rectangle':\n return shape.makeSimilarRectangle();\n case 'polygon':\n return shape.makeSimilarPolygon();\n default:\n return new NullShape(shape.label);\n }\n }\n };\n});\n"],"names":["define","Point","x","y","Shape","label","centre","Circle","radius","call","this","Rectangle","width","height","Polygon","points","slice","normalizeShape","ratio","NullShape","createSvgElement","svg","tagName","svgEl","ownerDocument","createElementNS","appendChild","createSvgShapeGroup","setAttribute","prototype","toString","move","dx","dy","offset","offsetX","offsetY","parse","coordinates","bits","split","length","Error","Math","round","getType","getCoordinates","maxX","maxY","edit","handleIndex","makeSvg","updateSvg","makeSimilarCircle","makeSimilarRectangle","makeSimilarPolygon","getHandlePositions","abs","childNodes","textContent","match","parseFloat","limit","min","moveHandle","editHandles","size","i","replace","push","bbXMin","bbXMax","bbYMin","bbYMax","max","addNewPointAfter","pointIndex","splice","p","minX","minY","make","shapeType","getSimilar","shape"],"mappings":";;;;;;;;;;AA4BAA,gCAAO,oBAWMC,MAAMC,EAAGC,QACTD,EAAIA,OACJC,EAAIA,WA2DJC,MAAMC,MAAOH,EAAGC,QAChBE,MAAQA,WACRC,OAAS,IAAIL,MAAMC,GAAK,EAAGC,GAAK,YAqIhCI,OAAOF,MAAOH,EAAGC,EAAGK,QACzBN,EAAIA,GAAK,GACTC,EAAIA,GAAK,GACTC,MAAMK,KAAKC,KAAML,MAAOH,EAAGC,QACtBK,OAASA,QAAU,YA2GnBG,UAAUN,MAAOH,EAAGC,EAAGS,MAAOC,QACnCT,MAAMK,KAAKC,KAAML,MAAOH,EAAGC,QACtBS,MAAQA,OAAS,QACjBC,OAASA,QAAU,YAoInBC,QAAQT,MAAOU,QACpBX,MAAMK,KAAKC,KAAML,MAAO,EAAG,QACtBU,OAASA,OAASA,OAAOC,QAAU,CAAC,IAAIf,MAAM,GAAI,IAAK,IAAIA,MAAM,GAAI,IAAK,IAAIA,MAAM,GAAI,UACxFgB,sBACAC,MAAQ,WAgLRC,UAAUd,OACfD,MAAMK,KAAKC,KAAML,gBA8CZe,iBAAiBC,IAAKC,aACvBC,MAAQF,IAAIG,cAAcC,gBAAgB,6BAA8BH,gBAC5ED,IAAIK,YAAYH,OACTA,eAWFI,oBAAoBN,IAAKC,aAC1BC,MAAQH,iBAAiBC,IAAK,YAClCD,iBAAiBG,MAAOD,SAASM,aAAa,QAAS,SACvDR,iBAAiBG,MAAO,QAAQK,aAAa,QAAS,cAC/CL,aAtqBXtB,MAAM4B,UAAUC,SAAW,kBAChBpB,KAAKR,EAAI,IAAMQ,KAAKP,GAQ/BF,MAAM4B,UAAUE,KAAO,SAASC,GAAIC,SAC3B/B,GAAK8B,QACL7B,GAAK8B,IAUdhC,MAAM4B,UAAUK,OAAS,SAASC,QAASC,gBACnCD,mBAAmBlC,QACnBmC,QAAUD,QAAQhC,EAClBgC,QAAUA,QAAQjC,GAEf,IAAID,MAAMS,KAAKR,EAAIiC,QAASzB,KAAKP,EAAIiC,UAShDnC,MAAMoC,MAAQ,SAASC,iBACfC,KAAOD,YAAYE,MAAM,QACT,IAAhBD,KAAKE,aACC,IAAIC,MAAMJ,YAAc,gCAE3B,IAAIrC,MAAM0C,KAAKC,MAAML,KAAK,IAAKI,KAAKC,MAAML,KAAK,MAsB1DnC,MAAMyB,UAAUgB,QAAU,iBAChB,IAAIH,MAAM,qBAQpBtC,MAAMyB,UAAUiB,eAAiB,iBACvB,IAAIJ,MAAM,qBAUpBtC,MAAMyB,UAAUQ,MAAQ,SAASC,YAAapB,aAEpC,IAAIwB,MAAM,qBAWpBtC,MAAMyB,UAAUE,KAAO,SAASC,GAAIC,GAAIc,KAAMC,QAa9C5C,MAAMyB,UAAUoB,KAAO,SAASC,YAAalB,GAAIC,GAAIc,KAAMC,QAS3D5C,MAAMyB,UAAUZ,eAAiB,aAUjCb,MAAMyB,UAAUsB,QAAU,SAAS9B,WAEzB,IAAIqB,MAAM,qBAQpBtC,MAAMyB,UAAUuB,UAAY,SAAS7B,SASrCnB,MAAMyB,UAAUwB,kBAAoB,iBAC1B,IAAIX,MAAM,qBAQpBtC,MAAMyB,UAAUyB,qBAAuB,iBAC7B,IAAIZ,MAAM,qBAQpBtC,MAAMyB,UAAU0B,mBAAqB,iBAC3B,IAAIb,MAAM,qBAQpBtC,MAAMyB,UAAU2B,mBAAqB,kBAC1B,MAmBXjD,OAAOsB,UAAY,IAAIzB,MAEvBG,OAAOsB,UAAUgB,QAAU,iBAChB,UAGXtC,OAAOsB,UAAUiB,eAAiB,kBACvBpC,KAAKJ,OAAS,IAAMqC,KAAKc,IAAI/C,KAAKF,SAG7CD,OAAOsB,UAAUsB,QAAU,SAAS9B,SAC5BE,MAAQI,oBAAoBN,IAAK,sBAChC+B,UAAU7B,OACRA,OAGXhB,OAAOsB,UAAUuB,UAAY,SAAS7B,OAClCA,MAAMmC,WAAW,GAAG9B,aAAa,KAAMlB,KAAKJ,OAAOJ,GACnDqB,MAAMmC,WAAW,GAAG9B,aAAa,KAAMlB,KAAKJ,OAAOH,GACnDoB,MAAMmC,WAAW,GAAG9B,aAAa,IAAKe,KAAKc,IAAI/C,KAAKF,SACpDe,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOJ,GAClDqB,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOH,EAAI,IACtDoB,MAAMmC,WAAW,GAAGC,YAAcjD,KAAKL,OAG3CE,OAAOsB,UAAUQ,MAAQ,SAASC,YAAapB,WACtCoB,YAAYsB,MAAM,gDACZ,MAGPrB,KAAOD,YAAYE,MAAM,iBACxBlC,OAASL,MAAMoC,MAAME,KAAK,SAC1BjC,OAAOJ,EAAIQ,KAAKJ,OAAOJ,EAAI2D,WAAW3C,YACtCZ,OAAOH,EAAIO,KAAKJ,OAAOH,EAAI0D,WAAW3C,YACtCV,OAASmC,KAAKC,MAAML,KAAK,IAAMsB,WAAW3C,QACxC,GAGXX,OAAOsB,UAAUE,KAAO,SAASC,GAAIC,GAAIc,KAAMC,WACtC1C,OAAOyB,KAAKC,GAAIC,IACjBvB,KAAKJ,OAAOJ,EAAIQ,KAAKF,cAChBF,OAAOJ,EAAIQ,KAAKF,QAErBE,KAAKJ,OAAOJ,EAAI6C,KAAOrC,KAAKF,cACvBF,OAAOJ,EAAI6C,KAAOrC,KAAKF,QAE5BE,KAAKJ,OAAOH,EAAIO,KAAKF,cAChBF,OAAOH,EAAIO,KAAKF,QAErBE,KAAKJ,OAAOH,EAAI6C,KAAOtC,KAAKF,cACvBF,OAAOH,EAAI6C,KAAOtC,KAAKF,SAIpCD,OAAOsB,UAAUoB,KAAO,SAASC,YAAalB,GAAIC,GAAIc,KAAMC,WACnDxC,QAAUwB,OACX8B,MAAQnB,KAAKoB,IAAIrD,KAAKJ,OAAOJ,EAAGQ,KAAKJ,OAAOH,EAAG4C,KAAOrC,KAAKJ,OAAOJ,EAAG8C,KAAOtC,KAAKJ,OAAOH,GACxFO,KAAKF,OAASsD,aACTtD,OAASsD,OAEdpD,KAAKF,QAAUsD,aACVtD,QAAUsD,QASvBvD,OAAOsB,UAAUZ,eAAiB,gBACzBT,OAASmC,KAAKc,IAAI/C,KAAKF,SAGhCD,OAAOsB,UAAUyB,qBAAuB,kBAC7B,IAAI3C,UAAUD,KAAKL,MAClBK,KAAKJ,OAAOJ,EAAIQ,KAAKF,OAAQE,KAAKJ,OAAOH,EAAIO,KAAKF,OACpC,EAAdE,KAAKF,OAA0B,EAAdE,KAAKF,SAGlCD,OAAOsB,UAAU0B,mBAAqB,kBAE3B,IAAIzC,QAAQJ,KAAKL,MAAO,CACvBK,KAAKJ,OAAO4B,QAAQxB,KAAKF,QAASE,KAAKF,QAASE,KAAKJ,OAAO4B,QAAQxB,KAAKF,OAAQE,KAAKF,QACtFE,KAAKJ,OAAO4B,OAAOxB,KAAKF,OAAQE,KAAKF,QAASE,KAAKJ,OAAO4B,OAAOxB,KAAKF,QAASE,KAAKF,WAGhGD,OAAOsB,UAAU2B,mBAAqB,iBAC3B,CACHQ,WAAYtD,KAAKJ,OACjB2D,YAAa,CAACvD,KAAKJ,OAAO4B,OAAOxB,KAAKF,OAAQ,MAoBtDG,UAAUkB,UAAY,IAAIzB,MAE1BO,UAAUkB,UAAUgB,QAAU,iBACnB,aAGXlC,UAAUkB,UAAUiB,eAAiB,kBAC1BpC,KAAKJ,OAAS,IAAMI,KAAKE,MAAQ,IAAMF,KAAKG,QAGvDF,UAAUkB,UAAUsB,QAAU,SAAS9B,SAC/BE,MAAQI,oBAAoBN,IAAK,oBAChC+B,UAAU7B,OACRA,OAGXZ,UAAUkB,UAAUuB,UAAY,SAAS7B,OACjCb,KAAKE,OAAS,GACdW,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOJ,GAClDqB,MAAMmC,WAAW,GAAG9B,aAAa,QAASlB,KAAKE,SAE/CW,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOJ,EAAIQ,KAAKE,OAC3DW,MAAMmC,WAAW,GAAG9B,aAAa,SAAUlB,KAAKE,QAEhDF,KAAKG,QAAU,GACfU,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOH,GAClDoB,MAAMmC,WAAW,GAAG9B,aAAa,SAAUlB,KAAKG,UAEhDU,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOH,EAAIO,KAAKG,QAC3DU,MAAMmC,WAAW,GAAG9B,aAAa,UAAWlB,KAAKG,SAGrDU,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOJ,EAAIQ,KAAKE,MAAQ,GACnEW,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOH,EAAIO,KAAKG,OAAS,EAAI,IACxEU,MAAMmC,WAAW,GAAGC,YAAcjD,KAAKL,OAG3CM,UAAUkB,UAAUQ,MAAQ,SAASC,YAAapB,WACzCoB,YAAYsB,MAAM,4DACZ,MAGPrB,KAAOD,YAAYE,MAAM,UACxBlC,OAASL,MAAMoC,MAAME,KAAK,SAC1BjC,OAAOJ,EAAIQ,KAAKJ,OAAOJ,EAAI2D,WAAW3C,YACtCZ,OAAOH,EAAIO,KAAKJ,OAAOH,EAAI0D,WAAW3C,WACvCgD,KAAOjE,MAAMoC,MAAME,KAAK,gBACvB3B,MAAQsD,KAAKhE,EAAI2D,WAAW3C,YAC5BL,OAASqD,KAAK/D,EAAI0D,WAAW3C,QAC3B,GAGXP,UAAUkB,UAAUE,KAAO,SAASC,GAAIC,GAAIc,KAAMC,WACzC1C,OAAOyB,KAAKC,GAAIC,IACjBvB,KAAKJ,OAAOJ,EAAI,SACXI,OAAOJ,EAAI,GAEhBQ,KAAKJ,OAAOJ,EAAI6C,KAAOrC,KAAKE,aACvBN,OAAOJ,EAAI6C,KAAOrC,KAAKE,OAE5BF,KAAKJ,OAAOH,EAAI,SACXG,OAAOH,EAAI,GAEhBO,KAAKJ,OAAOH,EAAI6C,KAAOtC,KAAKG,cACvBP,OAAOH,EAAI6C,KAAOtC,KAAKG,SAIpCF,UAAUkB,UAAUoB,KAAO,SAASC,YAAalB,GAAIC,GAAIc,KAAMC,WACtDpC,OAASoB,QACTnB,QAAUoB,GACXvB,KAAKE,OAASF,KAAKJ,OAAOJ,SACrBU,OAASF,KAAKJ,OAAOJ,GAE1BQ,KAAKE,MAAQmC,KAAOrC,KAAKJ,OAAOJ,SAC3BU,MAAQmC,KAAOrC,KAAKJ,OAAOJ,GAEhCQ,KAAKG,QAAUH,KAAKJ,OAAOH,SACtBU,QAAUH,KAAKJ,OAAOH,GAE3BO,KAAKG,OAASmC,KAAOtC,KAAKJ,OAAOH,SAC5BU,OAASmC,KAAOtC,KAAKJ,OAAOH,IASzCQ,UAAUkB,UAAUZ,eAAiB,WAC7BP,KAAKE,MAAQ,SACRN,OAAOJ,GAAKQ,KAAKE,WACjBA,OAASF,KAAKE,OAEnBF,KAAKG,OAAS,SACTP,OAAOH,GAAKO,KAAKG,YACjBA,QAAUH,KAAKG,SAI5BF,UAAUkB,UAAUwB,kBAAoB,kBAC7B,IAAI9C,OAAOG,KAAKL,MACfsC,KAAKC,MAAMlC,KAAKJ,OAAOJ,EAAIQ,KAAKE,MAAQ,GACxC+B,KAAKC,MAAMlC,KAAKJ,OAAOH,EAAIO,KAAKG,OAAS,GACzC8B,KAAKC,OAAOlC,KAAKE,MAAQF,KAAKG,QAAU,KAGpDF,UAAUkB,UAAU0B,mBAAqB,kBAC9B,IAAIzC,QAAQJ,KAAKL,MAAO,CAC3BK,KAAKJ,OAAQI,KAAKJ,OAAO4B,OAAO,EAAGxB,KAAKG,QACxCH,KAAKJ,OAAO4B,OAAOxB,KAAKE,MAAOF,KAAKG,QAASH,KAAKJ,OAAO4B,OAAOxB,KAAKE,MAAO,MAGpFD,UAAUkB,UAAU2B,mBAAqB,iBAC9B,CACHQ,WAAYtD,KAAKJ,OAAO4B,OAAOxB,KAAKE,MAAQ,EAAGF,KAAKG,OAAS,GAC7DoD,YAAa,CAACvD,KAAKJ,OAAO4B,OAAOxB,KAAKE,MAAOF,KAAKG,WAmB1DC,QAAQe,UAAY,IAAIzB,MAExBU,QAAQe,UAAUgB,QAAU,iBACjB,WAGX/B,QAAQe,UAAUiB,eAAiB,mBAC3BR,YAAc,GACT6B,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,IACpC7B,aAAe5B,KAAKJ,OAAO4B,OAAOxB,KAAKK,OAAOoD,IAAM,WAEjD7B,YAAYtB,MAAM,EAAGsB,YAAYG,OAAS,IAGrD3B,QAAQe,UAAUsB,QAAU,SAAS9B,SAC7BE,MAAQI,oBAAoBN,IAAK,uBAChC+B,UAAU7B,OACRA,OAGXT,QAAQe,UAAUuB,UAAY,SAAS7B,OACnCA,MAAMmC,WAAW,GAAG9B,aAAa,SAAUlB,KAAKoC,iBAAiBsB,QAAQ,QAAS,MAClF7C,MAAMmC,WAAW,GAAG9B,aAAa,YAAa,SAAWiC,WAAWnD,KAAKQ,OAAS,KAClFK,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOJ,GAClDqB,MAAMmC,WAAW,GAAG9B,aAAa,IAAKlB,KAAKJ,OAAOH,EAAI,IACtDoB,MAAMmC,WAAW,GAAGC,YAAcjD,KAAKL,OAG3CS,QAAQe,UAAUQ,MAAQ,SAASC,YAAapB,WACvCoB,YAAYsB,MAAM,iEACZ,UAGPrB,KAAOD,YAAYE,MAAM,KACzBzB,OAAS,GACJoD,EAAI,EAAGA,EAAI5B,KAAKE,OAAQ0B,IAC7BpD,OAAOsD,KAAKpE,MAAMoC,MAAME,KAAK4B,iBAG5BpD,OAASA,YACTT,OAAOJ,EAAI,OACXI,OAAOH,EAAI,OACXe,MAAQA,WACRD,kBAEE,GAGXH,QAAQe,UAAUE,KAAO,SAASC,GAAIC,GAAIc,KAAMC,WACvC1C,OAAOyB,KAAKC,GAAIC,YACjBqC,OAASvB,KACTwB,OAAS,EACTC,OAASxB,KACTyB,OAAS,EAEJN,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,IACpCG,OAAS3B,KAAKoB,IAAIO,OAAQ5D,KAAKK,OAAOoD,GAAGjE,GACzCqE,OAAS5B,KAAK+B,IAAIH,OAAQ7D,KAAKK,OAAOoD,GAAGjE,GACzCsE,OAAS7B,KAAKoB,IAAIS,OAAQ9D,KAAKK,OAAOoD,GAAGhE,GACzCsE,OAAS9B,KAAK+B,IAAID,OAAQ/D,KAAKK,OAAOoD,GAAGhE,GAEzCO,KAAKJ,OAAOJ,GAAKoE,cACZhE,OAAOJ,GAAKoE,QAEjB5D,KAAKJ,OAAOJ,EAAI6C,KAAOwB,cAClBjE,OAAOJ,EAAI6C,KAAOwB,QAEvB7D,KAAKJ,OAAOH,GAAKqE,cACZlE,OAAOH,GAAKqE,QAEjB9D,KAAKJ,OAAOH,EAAI6C,KAAOyB,cAClBnE,OAAOH,EAAI6C,KAAOyB,SAI/B3D,QAAQe,UAAUoB,KAAO,SAASC,YAAalB,GAAIC,GAAIc,KAAMC,WACpDjC,OAAOmC,aAAanB,KAAKC,GAAIC,IAC9BvB,KAAKK,OAAOmC,aAAahD,GAAKQ,KAAKJ,OAAOJ,SACrCa,OAAOmC,aAAahD,GAAKQ,KAAKJ,OAAOJ,GAE1CQ,KAAKK,OAAOmC,aAAahD,EAAI6C,KAAOrC,KAAKJ,OAAOJ,SAC3Ca,OAAOmC,aAAahD,EAAI6C,KAAOrC,KAAKJ,OAAOJ,GAEhDQ,KAAKK,OAAOmC,aAAa/C,GAAKO,KAAKJ,OAAOH,SACrCY,OAAOmC,aAAa/C,GAAKO,KAAKJ,OAAOH,GAE1CO,KAAKK,OAAOmC,aAAa/C,EAAI6C,KAAOtC,KAAKJ,OAAOH,SAC3CY,OAAOmC,aAAa/C,EAAI6C,KAAOtC,KAAKJ,OAAOH,IAWxDW,QAAQe,UAAU8C,iBAAmB,SAASC,iBACrC7D,OAAO8D,OAAOD,WAAY,EACvB,IAAI3E,MAAMS,KAAKK,OAAO6D,YAAY1E,EAAGQ,KAAKK,OAAO6D,YAAYzE,KAGzEW,QAAQe,UAAUZ,eAAiB,eAC3BkD,EACAjE,EAAI,EACJC,EAAI,KAEmB,IAAvBO,KAAKK,OAAO0B,YAKX0B,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,IAChCjE,GAAKQ,KAAKK,OAAOoD,GAAGjE,EACpBC,GAAKO,KAAKK,OAAOoD,GAAGhE,KAExBD,EAAIyC,KAAKC,MAAM1C,EAAIQ,KAAKK,OAAO0B,QAC/BtC,EAAIwC,KAAKC,MAAMzC,EAAIO,KAAKK,OAAO0B,QAErB,IAANvC,GAAiB,IAANC,OAIVgE,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,SAC3BpD,OAAOoD,GAAGpC,MAAM7B,GAAIC,QAExBG,OAAOyB,KAAK7B,EAAGC,MAGxBW,QAAQe,UAAUwB,kBAAoB,kBAC3B3C,KAAK4C,uBAAuBD,qBAGvCvC,QAAQe,UAAUyB,qBAAuB,mBACjCwB,EACAC,KAAO,EACPhC,KAAO,EACPiC,KAAO,EACPhC,KAAO,EACFmB,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,IACpCW,EAAIpE,KAAKK,OAAOoD,GAChBY,KAAOpC,KAAKoB,IAAIgB,KAAMD,EAAE5E,GACxB6C,KAAOJ,KAAK+B,IAAI3B,KAAM+B,EAAE5E,GACxB8E,KAAOrC,KAAKoB,IAAIiB,KAAMF,EAAE3E,GACxB6C,KAAOL,KAAK+B,IAAI1B,KAAM8B,EAAE3E,UAErB,IAAIQ,UAAUD,KAAKL,MAClBK,KAAKJ,OAAOJ,EAAI6E,KAAMrE,KAAKJ,OAAOH,EAAI6E,KACtCrC,KAAK+B,IAAI3B,KAAOgC,KAAM,IAAKpC,KAAK+B,IAAI1B,KAAOgC,KAAM,MAG7DlE,QAAQe,UAAU2B,mBAAqB,mBAC/BS,YAAc,GACTE,EAAI,EAAGA,EAAIzD,KAAKK,OAAO0B,OAAQ0B,IACpCF,YAAYI,KAAK3D,KAAKK,OAAOoD,GAAGjC,OAAOxB,KAAKJ,OAAOJ,EAAGQ,KAAKJ,OAAOH,gBAGjEG,OAAOJ,EAAIQ,KAAKJ,OAAOJ,EAAI2D,WAAWnD,KAAKQ,YAC3CZ,OAAOH,EAAIO,KAAKJ,OAAOH,EAAI0D,WAAWnD,KAAKQ,OAEzC,CACH8C,WAAYtD,KAAKJ,OACjB2D,YAAaA,cAcrB9C,UAAUU,UAAY,IAAIzB,MAE1Be,UAAUU,UAAUgB,QAAU,iBACnB,QAGX1B,UAAUU,UAAUiB,eAAiB,iBAC1B,IAGX3B,UAAUU,UAAUsB,QAAU,SAAS9B,YAE5B,MAGXF,UAAUU,UAAUuB,UAAY,SAAS7B,SAIzCJ,UAAUU,UAAUQ,MAAQ,SAASC,oBAE1B,GAGXnB,UAAUU,UAAUwB,kBAAoB,kBAC7B,IAAI9C,OAAOG,KAAKL,QAG3Bc,UAAUU,UAAUyB,qBAAuB,kBAChC,IAAI3C,UAAUD,KAAKL,QAG9Bc,UAAUU,UAAU0B,mBAAqB,kBAC9B,IAAIzC,QAAQJ,KAAKL,QAmCrB,CAQHJ,MAAOA,MASPG,MAAOA,MAWPG,OAAQA,OAYRI,UAAWA,UAUXG,QAASA,QAQTK,UAAWA,UASXC,iBAAkBA,iBASlB6D,KAAM,SAASC,UAAW7E,cACd6E,eACC,gBACM,IAAI3E,OAAOF,WACjB,mBACM,IAAIM,UAAUN,WACpB,iBACM,IAAIS,QAAQT,sBAEZ,IAAIc,UAAUd,SAWjC8E,WAAY,SAASD,UAAWE,UACxBF,YAAcE,MAAMvC,iBACbuC,aAEHF,eACC,gBACME,MAAM/B,wBACZ,mBACM+B,MAAM9B,2BACZ,iBACM8B,MAAM7B,oCAEN,IAAIpC,UAAUiE,MAAM/E"}PK\VV+type/ddmarker/amd/build/question.min.js.mapnu[{"version":3,"file":"question.min.js","sources":["../src/question.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Question class for drag and drop marker question type, used to support the question and preview pages.\n *\n * @module qtype_ddmarker/question\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine([\n 'jquery',\n 'core/dragdrop',\n 'qtype_ddmarker/shapes',\n 'core/key_codes',\n 'core_form/changechecker',\n 'core_filters/events',\n], function(\n $,\n dragDrop,\n Shapes,\n keys,\n FormChangeChecker,\n filterEvent\n) {\n\n \"use strict\";\n\n /**\n * Object to handle one drag-drop markers question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n * @param {Object[]} visibleDropZones the geometry of any drop-zones to show.\n * Objects have fields shape, coords and markertext.\n * @constructor\n */\n function DragDropMarkersQuestion(containerId, readOnly, visibleDropZones) {\n var thisQ = this;\n this.containerId = containerId;\n this.visibleDropZones = visibleDropZones;\n this.shapes = [];\n this.shapeSVGs = [];\n this.isPrinting = false;\n this.questionAnswer = {};\n if (readOnly) {\n this.getRoot().addClass('qtype_ddmarker-readonly');\n }\n thisQ.allImagesLoaded = false;\n thisQ.getNotYetLoadedImages().one('load', function() {\n thisQ.waitForAllImagesToBeLoaded();\n });\n thisQ.waitForAllImagesToBeLoaded();\n }\n\n /**\n * Draws the svg shapes of any drop zones that should be visible for feedback purposes.\n */\n DragDropMarkersQuestion.prototype.drawDropzones = function() {\n if (this.visibleDropZones.length > 0) {\n var bgImage = this.bgImage();\n\n this.getRoot().find('div.dropzones').html('');\n var svg = this.getRoot().find('svg.dropzones');\n\n var nextColourIndex = 0;\n for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {\n var colourClass = 'color' + nextColourIndex;\n nextColourIndex = (nextColourIndex + 1) % 8;\n this.addDropzone(svg, dropZoneNo, colourClass);\n }\n }\n };\n\n /**\n * Adds a dropzone shape with colour, coords and link provided to the array of shapes.\n *\n * @param {jQuery} svg the SVG image to which to add this drop zone.\n * @param {int} dropZoneNo which drop-zone to add.\n * @param {string} colourClass class name\n */\n DragDropMarkersQuestion.prototype.addDropzone = function(svg, dropZoneNo, colourClass) {\n var dropZone = this.visibleDropZones[dropZoneNo],\n shape = Shapes.make(dropZone.shape, ''),\n existingmarkertext,\n bgRatio = this.bgRatio();\n if (!shape.parse(dropZone.coords, bgRatio)) {\n return;\n }\n\n existingmarkertext = this.getRoot().find('div.markertexts span.markertext' + dropZoneNo);\n if (existingmarkertext.length) {\n if (dropZone.markertext !== '') {\n existingmarkertext.html(dropZone.markertext);\n filterEvent.notifyFilterContentUpdated(existingmarkertext);\n } else {\n existingmarkertext.remove();\n }\n } else if (dropZone.markertext !== '') {\n var classnames = 'markertext markertext' + dropZoneNo;\n this.getRoot().find('div.markertexts').append('' +\n dropZone.markertext + '');\n var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);\n if (markerspan.length) {\n var handles = shape.getHandlePositions();\n var positionLeft = handles.moveHandle.x - (markerspan.outerWidth() / 2) - 4;\n var positionTop = handles.moveHandle.y - (markerspan.outerHeight() / 2);\n markerspan\n .css('left', positionLeft)\n .css('top', positionTop);\n markerspan\n .data('originX', markerspan.position().left / bgRatio)\n .data('originY', markerspan.position().top / bgRatio);\n this.handleElementScale(markerspan, 'center');\n }\n filterEvent.notifyFilterContentUpdated(markerspan);\n }\n\n var shapeSVG = shape.makeSvg(svg[0]);\n shapeSVG.setAttribute('class', 'dropzone ' + colourClass);\n\n this.shapes[this.shapes.length] = shape;\n this.shapeSVGs[this.shapeSVGs.length] = shapeSVG;\n };\n\n /**\n * Draws the drag items on the page (and drop zones if required).\n * The idea is to re-draw all the drags and drops whenever there is a change\n * like a widow resize or an item dropped in place.\n */\n DragDropMarkersQuestion.prototype.repositionDrags = function() {\n var root = this.getRoot(),\n thisQ = this;\n\n root.find('div.draghomes .marker').not('.dragplaceholder').each(function(key, item) {\n $(item).addClass('unneeded');\n });\n\n root.find('input.choices').each(function(key, input) {\n var choiceNo = thisQ.getChoiceNoFromElement(input),\n imageCoords = thisQ.getImageCoords(input);\n if (imageCoords.length) {\n var drag = thisQ.getRoot().find('.draghomes' + ' span.marker' + '.choice' + choiceNo).not('.dragplaceholder');\n drag.remove();\n for (var i = 0; i < imageCoords.length; i++) {\n var dragInDrop = drag.clone();\n // Convert image coords to screen coords.\n const screenCoords = thisQ.convertToWindowXY(imageCoords[i]);\n dragInDrop.data('pagex', screenCoords.x).data('pagey', screenCoords.y);\n // Save image coords to the drag item so we can use it later.\n dragInDrop.data('imageCoords', imageCoords[i]);\n // We always save the coordinates in the 1:1 ratio.\n // So we need to set the scale ratio to 1 for the initial load.\n dragInDrop.data('scaleRatio', 1);\n thisQ.sendDragToDrop(dragInDrop, false, true);\n }\n thisQ.getDragClone(drag).addClass('active');\n thisQ.cloneDragIfNeeded(drag);\n }\n });\n\n // Save the question answer.\n thisQ.questionAnswer = thisQ.getQuestionAnsweredValues();\n };\n\n /**\n * Get the question answered values.\n *\n * @return {Object} Contain key-value with key is the input id and value is the input value.\n */\n DragDropMarkersQuestion.prototype.getQuestionAnsweredValues = function() {\n let result = {};\n this.getRoot().find('input.choices').each((i, inputNode) => {\n result[inputNode.id] = inputNode.value;\n });\n\n return result;\n };\n\n /**\n * Check if the question is being interacted or not.\n *\n * @return {boolean} Return true if the user has changed the question-answer.\n */\n DragDropMarkersQuestion.prototype.isQuestionInteracted = function() {\n const oldAnswer = this.questionAnswer;\n const newAnswer = this.getQuestionAnsweredValues();\n let isInteracted = false;\n\n // First, check both answers have the same structure or not.\n if (JSON.stringify(newAnswer) !== JSON.stringify(oldAnswer)) {\n isInteracted = true;\n return isInteracted;\n }\n // Check the values.\n Object.keys(newAnswer).forEach(key => {\n if (newAnswer[key] !== oldAnswer[key]) {\n isInteracted = true;\n }\n });\n\n return isInteracted;\n };\n\n /**\n * Determine what drag items need to be shown and\n * return coords of all drag items except any that are currently being dragged\n * based on contents of hidden inputs and whether drags are 'infinite' or how many\n * drags should be shown.\n *\n * @param {jQuery} inputNode\n * @returns {Point[]} image coordinates of however many copies of the drag item should be shown.\n */\n DragDropMarkersQuestion.prototype.getImageCoords = function(inputNode) {\n var imageCoords = [],\n val = $(inputNode).val();\n if (val !== '') {\n var coordsStrings = val.split(';');\n for (var i = 0; i < coordsStrings.length; i++) {\n imageCoords[i] = Shapes.Point.parse(coordsStrings[i]);\n }\n }\n return imageCoords;\n };\n\n /**\n * Converts the relative x and y position coordinates into\n * absolute x and y position coordinates.\n *\n * @param {Point} point relative to the background image.\n * @returns {Point} point relative to the page.\n */\n DragDropMarkersQuestion.prototype.convertToWindowXY = function(point) {\n var bgImage = this.bgImage();\n // The +1 seems rather odd, but seems to give the best results in\n // the three main browsers at a range of zoom levels.\n // (Its due to the 1px border around the image, that shifts the\n // image pixels by 1 down and to the left.)\n return point.offset(bgImage.offset().left + 1, bgImage.offset().top + 1);\n };\n\n /**\n * Utility function converting window coordinates to relative to the\n * background image coordinates.\n *\n * @param {Point} point relative to the page.\n * @returns {Point} point relative to the background image.\n */\n DragDropMarkersQuestion.prototype.convertToBgImgXY = function(point) {\n var bgImage = this.bgImage();\n return point.offset(-bgImage.offset().left - 1, -bgImage.offset().top - 1);\n };\n\n /**\n * Is the point within the background image?\n *\n * @param {Point} point relative to the BG image.\n * @return {boolean} true it they are.\n */\n DragDropMarkersQuestion.prototype.coordsInBgImg = function(point) {\n var bgImage = this.bgImage();\n var bgPosition = bgImage.offset();\n\n return point.x >= bgPosition.left && point.x < bgPosition.left + bgImage.width()\n && point.y >= bgPosition.top && point.y < bgPosition.top + bgImage.height();\n };\n\n /**\n * Get the outer div for this question.\n * @returns {jQuery} containing that div.\n */\n DragDropMarkersQuestion.prototype.getRoot = function() {\n return $(document.getElementById(this.containerId));\n };\n\n /**\n * Get the img that is the background image.\n * @returns {jQuery} containing that img.\n */\n DragDropMarkersQuestion.prototype.bgImage = function() {\n return this.getRoot().find('img.dropbackground');\n };\n\n DragDropMarkersQuestion.prototype.handleDragStart = function(e) {\n var thisQ = this,\n dragged = $(e.target).closest('.marker');\n\n var info = dragDrop.prepare(e);\n if (!info.start) {\n return;\n }\n\n dragged.addClass('beingdragged').css('transform', '');\n\n var placed = !dragged.hasClass('unneeded');\n if (!placed) {\n var hiddenDrag = thisQ.getDragClone(dragged);\n if (hiddenDrag.length) {\n hiddenDrag.addClass('active');\n dragged.offset(hiddenDrag.offset());\n }\n }\n\n dragDrop.start(e, dragged, function() {\n void (1);\n }, function(x, y, dragged) {\n thisQ.dragEnd(dragged);\n });\n };\n\n /**\n * Functionality at the end of a drag drop.\n * @param {jQuery} dragged the marker that was dragged.\n */\n DragDropMarkersQuestion.prototype.dragEnd = function(dragged) {\n var placed = false,\n choiceNo = this.getChoiceNoFromElement(dragged),\n bgRatio = this.bgRatio(),\n dragXY;\n\n dragged.data('pagex', dragged.offset().left).data('pagey', dragged.offset().top);\n dragXY = new Shapes.Point(dragged.data('pagex'), dragged.data('pagey'));\n if (this.coordsInBgImg(dragXY)) {\n this.sendDragToDrop(dragged, true);\n placed = true;\n // Since we already move the drag item to new position.\n // Remove the image coords if this drag item have it.\n // We will get the new image coords for this drag item in saveCoordsForChoice.\n if (dragged.data('imageCoords')) {\n dragged.data('imageCoords', null);\n }\n // It seems that the dragdrop sometimes leaves the drag\n // one pixel out of position. Put it in exactly the right place.\n var bgImgXY = this.convertToBgImgXY(dragXY);\n bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);\n dragged.data('originX', bgImgXY.x).data('originY', bgImgXY.y);\n }\n\n if (!placed) {\n this.sendDragHome(dragged);\n this.removeDragIfNeeded(dragged);\n } else {\n this.cloneDragIfNeeded(dragged);\n }\n\n this.saveCoordsForChoice(choiceNo);\n };\n\n /**\n * Save the coordinates for a dropped item in the form field.\n * @param {Number} choiceNo which copy of the choice this was.\n */\n DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo) {\n let imageCoords = [];\n var items = this.getRoot().find('div.droparea span.marker.choice' + choiceNo),\n thiQ = this,\n bgRatio = this.bgRatio();\n\n if (items.length) {\n items.each(function() {\n var drag = $(this);\n if (!drag.hasClass('beingdragged') && !drag.data('imageCoords')) {\n if (drag.data('scaleRatio') !== bgRatio) {\n // The scale ratio for the draggable item was changed. We need to update that.\n drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);\n }\n var dragXY = new Shapes.Point(drag.data('pagex'), drag.data('pagey'));\n if (thiQ.coordsInBgImg(dragXY)) {\n var bgImgXY = thiQ.convertToBgImgXY(dragXY);\n bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);\n imageCoords[imageCoords.length] = bgImgXY;\n }\n } else if (drag.data('imageCoords')) {\n imageCoords[imageCoords.length] = drag.data('imageCoords');\n }\n });\n }\n\n this.getRoot().find('input.choice' + choiceNo).val(imageCoords.join(';'));\n if (this.isQuestionInteracted()) {\n // The user has interacted with the draggable items. We need to mark the form as dirty.\n questionManager.handleFormDirty();\n // Save the new answered value.\n this.questionAnswer = this.getQuestionAnsweredValues();\n }\n };\n\n /**\n * Handle key down / press events on markers.\n * @param {KeyboardEvent} e\n */\n DragDropMarkersQuestion.prototype.handleKeyPress = function(e) {\n var drag = $(e.target).closest('.marker'),\n point = new Shapes.Point(drag.offset().left, drag.offset().top),\n choiceNo = this.getChoiceNoFromElement(drag);\n\n switch (e.keyCode) {\n case keys.arrowLeft:\n case 65: // A.\n point.x -= 1;\n break;\n case keys.arrowRight:\n case 68: // D.\n point.x += 1;\n break;\n case keys.arrowDown:\n case 83: // S.\n point.y += 1;\n break;\n case keys.arrowUp:\n case 87: // W.\n point.y -= 1;\n break;\n case keys.space:\n case keys.escape:\n point = null;\n break;\n default:\n return; // Ingore other keys.\n }\n e.preventDefault();\n\n if (point !== null) {\n point = this.constrainToBgImg(point);\n drag.offset({'left': point.x, 'top': point.y});\n drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);\n var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));\n drag.data('originX', dragXY.x / this.bgRatio()).data('originY', dragXY.y / this.bgRatio());\n if (this.coordsInBgImg(new Shapes.Point(drag.offset().left, drag.offset().top))) {\n if (drag.hasClass('unneeded')) {\n this.sendDragToDrop(drag, true);\n var hiddenDrag = this.getDragClone(drag);\n if (hiddenDrag.length) {\n hiddenDrag.addClass('active');\n }\n this.cloneDragIfNeeded(drag);\n }\n }\n } else {\n drag.css('left', '').css('top', '');\n drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);\n this.sendDragHome(drag);\n this.removeDragIfNeeded(drag);\n }\n drag.focus();\n this.saveCoordsForChoice(choiceNo);\n };\n\n /**\n * Makes sure the dragged item always exists within the background image area.\n *\n * @param {Point} windowxy\n * @returns {Point} coordinates\n */\n DragDropMarkersQuestion.prototype.constrainToBgImg = function(windowxy) {\n var bgImg = this.bgImage(),\n bgImgXY = this.convertToBgImgXY(windowxy);\n bgImgXY.x = Math.max(0, bgImgXY.x);\n bgImgXY.y = Math.max(0, bgImgXY.y);\n bgImgXY.x = Math.min(bgImg.width(), bgImgXY.x);\n bgImgXY.y = Math.min(bgImg.height(), bgImgXY.y);\n return this.convertToWindowXY(bgImgXY);\n };\n\n /**\n * Returns the choice number for a node.\n *\n * @param {Element|jQuery} node\n * @returns {Number}\n */\n DragDropMarkersQuestion.prototype.getChoiceNoFromElement = function(node) {\n return Number(this.getClassnameNumericSuffix(node, 'choice'));\n };\n\n /**\n * Returns the numeric part of a class with the given prefix.\n *\n * @param {Element|jQuery} node\n * @param {String} prefix\n * @returns {Number|null}\n */\n DragDropMarkersQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) {\n var classes = $(node).attr('class');\n if (classes !== undefined && classes !== '') {\n var classesarr = classes.split(' ');\n for (var index = 0; index < classesarr.length; index++) {\n var patt1 = new RegExp('^' + prefix + '([0-9])+$');\n if (patt1.test(classesarr[index])) {\n var patt2 = new RegExp('([0-9])+$');\n var match = patt2.exec(classesarr[index]);\n return Number(match[0]);\n }\n }\n }\n return null;\n };\n\n /**\n * Handle when the window is resized.\n */\n DragDropMarkersQuestion.prototype.handleResize = function() {\n var thisQ = this,\n bgRatio = this.bgRatio();\n if (this.isPrinting) {\n bgRatio = 1;\n }\n\n this.getRoot().find('div.droparea .marker').not('.beingdragged').each(function(key, drag) {\n $(drag)\n .css('left', parseFloat($(drag).data('originX')) * parseFloat(bgRatio))\n .css('top', parseFloat($(drag).data('originY')) * parseFloat(bgRatio));\n thisQ.handleElementScale(drag, 'left top');\n });\n\n this.getRoot().find('div.droparea svg.dropzones')\n .width(this.bgImage().width())\n .height(this.bgImage().height());\n\n for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {\n var dropZone = thisQ.visibleDropZones[dropZoneNo];\n var originCoords = dropZone.coords;\n var shape = thisQ.shapes[dropZoneNo];\n var shapeSVG = thisQ.shapeSVGs[dropZoneNo];\n shape.parse(originCoords, bgRatio);\n shape.updateSvg(shapeSVG);\n\n var handles = shape.getHandlePositions();\n var markerSpan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);\n markerSpan\n .css('left', handles.moveHandle.x - (markerSpan.outerWidth() / 2) - 4)\n .css('top', handles.moveHandle.y - (markerSpan.outerHeight() / 2));\n thisQ.handleElementScale(markerSpan, 'center');\n }\n };\n\n /**\n * Clone the drag.\n */\n DragDropMarkersQuestion.prototype.cloneDrags = function() {\n var thisQ = this;\n this.getRoot().find('div.draghomes span.marker').each(function(index, draghome) {\n var drag = $(draghome);\n var placeHolder = drag.clone();\n placeHolder.removeClass();\n placeHolder.addClass('marker');\n placeHolder.addClass('choice' + thisQ.getChoiceNoFromElement(drag));\n placeHolder.addClass(thisQ.getDragNoClass(drag, false));\n placeHolder.addClass('dragplaceholder');\n drag.before(placeHolder);\n });\n };\n\n /**\n * Get the drag number of a drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {Number} the drag number.\n */\n DragDropMarkersQuestion.prototype.getDragNo = function(drag) {\n return this.getClassnameNumericSuffix(drag, 'dragno');\n };\n\n /**\n * Get the drag number prefix of a drag.\n *\n * @param {jQuery} drag the drag.\n * @param {Boolean} includeSelector include the CSS selector prefix or not.\n * @return {String} Class name\n */\n DragDropMarkersQuestion.prototype.getDragNoClass = function(drag, includeSelector) {\n var className = 'dragno' + this.getDragNo(drag);\n if (this.isInfiniteDrag(drag)) {\n className = 'infinite';\n }\n\n if (includeSelector) {\n return '.' + className;\n }\n\n return className;\n };\n\n /**\n * Get drag clone for a given drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {jQuery} the drag's clone.\n */\n DragDropMarkersQuestion.prototype.getDragClone = function(drag) {\n return this.getRoot().find('.draghomes' + ' span.marker' +\n '.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true) + '.dragplaceholder');\n };\n\n /**\n * Get the drop area element.\n * @returns {jQuery} droparea element.\n */\n DragDropMarkersQuestion.prototype.dropArea = function() {\n return this.getRoot().find('div.droparea');\n };\n\n /**\n * Animate a drag back to its home.\n *\n * @param {jQuery} drag the item being moved.\n */\n DragDropMarkersQuestion.prototype.sendDragHome = function(drag) {\n drag.removeClass('beingdragged')\n .addClass('unneeded')\n .css('top', '')\n .css('left', '')\n .css('transform', '');\n var placeHolder = this.getDragClone(drag);\n placeHolder.after(drag);\n placeHolder.removeClass('active');\n };\n\n /**\n * Animate a drag item into a given place.\n *\n * @param {jQuery} drag the item to place.\n * @param {boolean} isScaling Scaling or not.\n * @param {boolean} initialLoad Whether it is the initial load or not.\n */\n DragDropMarkersQuestion.prototype.sendDragToDrop = function(drag, isScaling, initialLoad = false) {\n var dropArea = this.dropArea(),\n bgRatio = this.bgRatio();\n drag.removeClass('beingdragged').removeClass('unneeded');\n var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));\n if (isScaling) {\n drag.data('originX', dragXY.x / bgRatio).data('originY', dragXY.y / bgRatio);\n drag.css('left', dragXY.x).css('top', dragXY.y);\n } else {\n drag.data('originX', dragXY.x).data('originY', dragXY.y);\n drag.css('left', dragXY.x * bgRatio).css('top', dragXY.y * bgRatio);\n }\n // We need to save the original scale ratio for each draggable item.\n if (!initialLoad) {\n // Only set the scale ratio for a current being-dragged item, not for the initial loading.\n drag.data('scaleRatio', bgRatio);\n }\n dropArea.append(drag);\n this.handleElementScale(drag, 'left top');\n };\n\n /**\n * Clone the drag at the draghome area if needed.\n *\n * @param {jQuery} drag the item to place.\n */\n DragDropMarkersQuestion.prototype.cloneDragIfNeeded = function(drag) {\n var inputNode = this.getInput(drag),\n noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')),\n displayedDragsInDropArea = this.getRoot().find('div.droparea .marker.choice' +\n this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).length,\n displayedDragsInDragHomes = this.getRoot().find('div.draghomes .marker.choice' +\n this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder').length;\n\n if ((this.isInfiniteDrag(drag) ||\n !this.isInfiniteDrag(drag) && displayedDragsInDropArea < noOfDrags) && displayedDragsInDragHomes === 0) {\n var dragClone = drag.clone();\n dragClone.addClass('unneeded')\n .css('top', '')\n .css('left', '')\n .css('transform', '');\n this.getDragClone(drag)\n .removeClass('active')\n .after(dragClone);\n questionManager.addEventHandlersToMarker(dragClone);\n }\n };\n\n /**\n * Remove the clone drag at the draghome area if needed.\n *\n * @param {jQuery} drag the item to place.\n */\n DragDropMarkersQuestion.prototype.removeDragIfNeeded = function(drag) {\n var dragsInHome = this.getRoot().find('div.draghomes .marker.choice' +\n this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder');\n var displayedDrags = dragsInHome.length;\n while (displayedDrags > 1) {\n dragsInHome.first().remove();\n displayedDrags--;\n }\n };\n\n /**\n * Get the input belong to drag.\n *\n * @param {jQuery} drag the item to place.\n * @returns {jQuery} input element.\n */\n DragDropMarkersQuestion.prototype.getInput = function(drag) {\n var choiceNo = this.getChoiceNoFromElement(drag);\n return this.getRoot().find('input.choices.choice' + choiceNo);\n };\n\n /**\n * Return the background ratio.\n *\n * @returns {number} Background ratio.\n */\n DragDropMarkersQuestion.prototype.bgRatio = function() {\n var bgImg = this.bgImage();\n var bgImgNaturalWidth = bgImg.get(0).naturalWidth;\n var bgImgClientWidth = bgImg.width();\n\n return bgImgClientWidth / bgImgNaturalWidth;\n };\n\n /**\n * Scale the drag if needed.\n *\n * @param {jQuery} element the item to place.\n * @param {String} type scaling type\n */\n DragDropMarkersQuestion.prototype.handleElementScale = function(element, type) {\n var bgRatio = parseFloat(this.bgRatio());\n if (this.isPrinting) {\n bgRatio = 1;\n }\n $(element).css({\n '-webkit-transform': 'scale(' + bgRatio + ')',\n '-moz-transform': 'scale(' + bgRatio + ')',\n '-ms-transform': 'scale(' + bgRatio + ')',\n '-o-transform': 'scale(' + bgRatio + ')',\n 'transform': 'scale(' + bgRatio + ')',\n 'transform-origin': type\n });\n };\n\n /**\n * Check if the given drag is in infinite mode or not.\n *\n * @param {jQuery} drag The drag item need to check.\n */\n DragDropMarkersQuestion.prototype.isInfiniteDrag = function(drag) {\n return drag.hasClass('infinite');\n };\n\n /**\n * Waits until all images are loaded before calling setupQuestion().\n *\n * This function is called from the onLoad of each image, and also polls with\n * a time-out, because image on-loads are allegedly unreliable.\n */\n DragDropMarkersQuestion.prototype.waitForAllImagesToBeLoaded = function() {\n var thisQ = this;\n // This method may get called multiple times (via image on-loads or timeouts.\n // If we are already done, don't do it again.\n if (this.allImagesLoaded) {\n return;\n }\n\n // Clear any current timeout, if set.\n if (this.imageLoadingTimeoutId !== null) {\n clearTimeout(this.imageLoadingTimeoutId);\n }\n\n // If we have not yet loaded all images, set a timeout to\n // call ourselves again, since apparently images on-load\n // events are flakey.\n if (this.getNotYetLoadedImages().length > 0) {\n this.imageLoadingTimeoutId = setTimeout(function() {\n this.waitForAllImagesToBeLoaded();\n }, 100);\n return;\n }\n\n // We now have all images. Carry on, but only after giving the layout a chance to settle down.\n this.allImagesLoaded = true;\n this.cloneDrags();\n this.repositionDrags();\n this.drawDropzones();\n // Wait for all dynamic content loaded by filter to be completed.\n document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete, (elements) => {\n elements.detail.nodes.forEach((element) => {\n thisQ.changeAllMakerToFilteredContent(element);\n });\n });\n };\n\n /**\n * Change all the maker related to the item that has been changed by filter to correct size and content.\n *\n * @param {object} filteredElement the element has been modified by filter.\n */\n DragDropMarkersQuestion.prototype.changeAllMakerToFilteredContent = function(filteredElement) {\n let currentFilteredItem = $(filteredElement);\n const parentIsMarker = currentFilteredItem.parent().closest('span.marker');\n const isMarker = currentFilteredItem.hasClass('marker');\n const root = this.getRoot();\n // The filtered element or parent element should a drag or drop item.\n if (!parentIsMarker && !isMarker) {\n return;\n }\n if (parentIsMarker) {\n currentFilteredItem = currentFilteredItem.parent().closest('span.marker');\n }\n if (root.find(currentFilteredItem).length <= 0) {\n // If the maker doesn't belong to this question\n // In case we have multiple questions in the same page.\n return;\n }\n const dragNo = this.getDragNo(currentFilteredItem);\n const choiceNo = this.getChoiceNoFromElement(currentFilteredItem);\n const listOfContainerToBeModifed = [\n 'div.draghomes .marker:not(.dragplaceholder).dragno' + dragNo + '.choice' + choiceNo,\n 'div.droparea .marker:not(.dragplaceholder).dragno' + dragNo + '.choice' + choiceNo,\n 'div.draghomes .marker:not(.dragplaceholder).infinite.choice' + choiceNo,\n 'div.droparea .marker:not(.dragplaceholder).infinite.choice' + choiceNo\n ];\n let listOfModifiedDragDrop = [];\n // We want to keep all the handler and event for filtered item, so using clone is the only choice.\n const filteredDragDropClone = currentFilteredItem.clone();\n listOfContainerToBeModifed.forEach(function(selector) {\n root.find(selector).each(function(i, node) {\n const originalClass = $(node).attr('class');\n const originalStyle = $(node).attr('style');\n // Replace the class and style of the maker we want to replace for the clone.\n filteredDragDropClone.attr('class', originalClass);\n filteredDragDropClone.attr('style', originalStyle);\n // Add event for the clone.\n questionManager.addEventHandlersToMarker(filteredDragDropClone);\n // Insert into DOM.\n $(node).before(filteredDragDropClone);\n listOfModifiedDragDrop.push(node);\n });\n });\n listOfModifiedDragDrop.forEach(function(node) {\n $(node).remove();\n });\n };\n\n /**\n * Get any of the images in the drag-drop area that are not yet fully loaded.\n *\n * @returns {jQuery} those images.\n */\n DragDropMarkersQuestion.prototype.getNotYetLoadedImages = function() {\n return this.getRoot().find('.ddmarker img.dropbackground').not(function(i, imgNode) {\n return this.imageIsLoaded(imgNode);\n });\n };\n\n /**\n * Check if an image has loaded without errors.\n *\n * @param {HTMLImageElement} imgElement an image.\n * @returns {boolean} true if this image has loaded without errors.\n */\n DragDropMarkersQuestion.prototype.imageIsLoaded = function(imgElement) {\n return imgElement.complete && imgElement.naturalHeight !== 0;\n };\n\n /**\n * Singleton that tracks all the DragDropToTextQuestions on this page, and deals\n * with event dispatching.\n *\n * @type {Object}\n */\n var questionManager = {\n\n /**\n * {boolean} ensures that the event handlers are only initialised once per page.\n */\n eventHandlersInitialised: false,\n\n /**\n * {Object} ensures that the marker event handlers are only initialised once per question,\n * indexed by containerId (id on the .que div).\n */\n markerEventHandlersInitialised: {},\n\n /**\n * {boolean} is printing or not.\n */\n isPrinting: false,\n\n /**\n * {boolean} is keyboard navigation.\n */\n isKeyboardNavigation: false,\n\n /**\n * {Object} all the questions on this page, indexed by containerId (id on the .que div).\n */\n questions: {}, // An object containing all the information about each question on the page.\n\n /**\n * Initialise one question.\n *\n * @param {String} containerId the id of the div.que that contains this question.\n * @param {boolean} readOnly whether the question is read-only.\n * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback.\n */\n init: function(containerId, readOnly, visibleDropZones) {\n questionManager.questions[containerId] =\n new DragDropMarkersQuestion(containerId, readOnly, visibleDropZones);\n if (!questionManager.eventHandlersInitialised) {\n questionManager.setupEventHandlers();\n questionManager.eventHandlersInitialised = true;\n }\n if (!questionManager.markerEventHandlersInitialised.hasOwnProperty(containerId)) {\n questionManager.markerEventHandlersInitialised[containerId] = true;\n // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.\n var questionContainer = document.getElementById(containerId);\n if (questionContainer.classList.contains('ddmarker') &&\n !questionContainer.classList.contains('qtype_ddmarker-readonly')) {\n // TODO: Convert all the jQuery selectors and events to native Javascript.\n questionManager.addEventHandlersToMarker($(questionContainer).find('div.draghomes .marker'));\n questionManager.addEventHandlersToMarker($(questionContainer).find('div.droparea .marker'));\n }\n }\n },\n\n /**\n * Set up the event handlers that make this question type work. (Done once per page.)\n */\n setupEventHandlers: function() {\n $(window).on('resize', function() {\n questionManager.handleWindowResize(false);\n });\n window.addEventListener('beforeprint', function() {\n questionManager.isPrinting = true;\n questionManager.handleWindowResize(questionManager.isPrinting);\n });\n window.addEventListener('afterprint', function() {\n questionManager.isPrinting = false;\n questionManager.handleWindowResize(questionManager.isPrinting);\n });\n setTimeout(function() {\n questionManager.fixLayoutIfThingsMoved();\n }, 100);\n },\n\n /**\n * Binding the event again for newly created element.\n *\n * @param {jQuery} element Element to bind the event\n */\n addEventHandlersToMarker: function(element) {\n element\n .on('mousedown touchstart', questionManager.handleDragStart)\n .on('keydown keypress', questionManager.handleKeyPress)\n .focusin(function(e) {\n questionManager.handleKeyboardFocus(e, true);\n })\n .focusout(function(e) {\n questionManager.handleKeyboardFocus(e, false);\n });\n },\n\n /**\n * Handle mouse down / touch start events on markers.\n * @param {Event} e the DOM event.\n */\n handleDragStart: function(e) {\n e.preventDefault();\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleDragStart(e);\n }\n },\n\n /**\n * Handle key down / press events on markers.\n * @param {Event} e\n */\n handleKeyPress: function(e) {\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleKeyPress(e);\n }\n },\n\n /**\n * Handle when the window is resized.\n * @param {boolean} isPrinting\n */\n handleWindowResize: function(isPrinting) {\n for (var containerId in questionManager.questions) {\n if (questionManager.questions.hasOwnProperty(containerId)) {\n questionManager.questions[containerId].isPrinting = isPrinting;\n questionManager.questions[containerId].handleResize();\n }\n }\n },\n\n /**\n * Handle focus lost events on markers.\n * @param {Event} e\n * @param {boolean} isNavigating\n */\n handleKeyboardFocus: function(e, isNavigating) {\n questionManager.isKeyboardNavigation = isNavigating;\n },\n\n /**\n * Sometimes, despite our best efforts, things change in a way that cannot\n * be specifically caught (e.g. dock expanding or collapsing in Boost).\n * Therefore, we need to periodically check everything is in the right position.\n */\n fixLayoutIfThingsMoved: function() {\n if (!questionManager.isKeyboardNavigation) {\n this.handleWindowResize(questionManager.isPrinting);\n }\n // We use setTimeout after finishing work, rather than setInterval,\n // in case positioning things is slow. We want 100 ms gap\n // between executions, not what setInterval does.\n setTimeout(function() {\n questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting);\n }, 100);\n },\n\n /**\n * Given an event, work out which question it effects.\n * @param {Event} e the event.\n * @returns {DragDropMarkersQuestion|undefined} The question, or undefined.\n */\n getQuestionForEvent: function(e) {\n var containerId = $(e.currentTarget).closest('.que.ddmarker').attr('id');\n return questionManager.questions[containerId];\n },\n\n /**\n * Handle when the form is dirty.\n */\n handleFormDirty: function() {\n const responseForm = document.getElementById('responseform');\n FormChangeChecker.markFormAsDirty(responseForm);\n }\n };\n\n /**\n * @alias module:qtype_ddmarker/question\n */\n return {\n /**\n * Initialise one drag-drop markers question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {String} bgImgUrl the URL of the background image.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n * @param {String[]} visibleDropZones the geometry of any drop-zones to show.\n */\n init: questionManager.init\n };\n});\n"],"names":["define","$","dragDrop","Shapes","keys","FormChangeChecker","filterEvent","DragDropMarkersQuestion","containerId","readOnly","visibleDropZones","thisQ","this","shapes","shapeSVGs","isPrinting","questionAnswer","getRoot","addClass","allImagesLoaded","getNotYetLoadedImages","one","waitForAllImagesToBeLoaded","prototype","drawDropzones","length","bgImage","find","html","outerWidth","outerHeight","svg","nextColourIndex","dropZoneNo","colourClass","addDropzone","existingmarkertext","dropZone","shape","make","bgRatio","parse","coords","markertext","notifyFilterContentUpdated","remove","classnames","append","markerspan","handles","getHandlePositions","positionLeft","moveHandle","x","positionTop","y","css","data","position","left","top","handleElementScale","shapeSVG","makeSvg","setAttribute","repositionDrags","root","not","each","key","item","input","choiceNo","getChoiceNoFromElement","imageCoords","getImageCoords","drag","i","dragInDrop","clone","screenCoords","convertToWindowXY","sendDragToDrop","getDragClone","cloneDragIfNeeded","getQuestionAnsweredValues","result","inputNode","id","value","isQuestionInteracted","oldAnswer","newAnswer","isInteracted","JSON","stringify","Object","forEach","val","coordsStrings","split","Point","point","offset","convertToBgImgXY","coordsInBgImg","bgPosition","width","height","document","getElementById","handleDragStart","e","dragged","target","closest","prepare","start","hasClass","hiddenDrag","dragEnd","dragXY","placed","bgImgXY","sendDragHome","removeDragIfNeeded","saveCoordsForChoice","items","thiQ","join","questionManager","handleFormDirty","handleKeyPress","keyCode","arrowLeft","arrowRight","arrowDown","arrowUp","space","escape","preventDefault","constrainToBgImg","focus","windowxy","bgImg","Math","max","min","node","Number","getClassnameNumericSuffix","prefix","classes","attr","undefined","classesarr","index","RegExp","test","match","exec","handleResize","parseFloat","originCoords","updateSvg","markerSpan","cloneDrags","draghome","placeHolder","removeClass","getDragNoClass","before","getDragNo","includeSelector","className","isInfiniteDrag","dropArea","after","isScaling","initialLoad","getInput","noOfDrags","displayedDragsInDropArea","displayedDragsInDragHomes","dragClone","addEventHandlersToMarker","dragsInHome","displayedDrags","first","bgImgNaturalWidth","get","naturalWidth","element","type","imageLoadingTimeoutId","clearTimeout","setTimeout","addEventListener","eventTypes","filterContentRenderingComplete","elements","detail","nodes","changeAllMakerToFilteredContent","filteredElement","currentFilteredItem","parentIsMarker","parent","isMarker","dragNo","listOfContainerToBeModifed","listOfModifiedDragDrop","filteredDragDropClone","selector","originalClass","originalStyle","push","imgNode","imageIsLoaded","imgElement","complete","naturalHeight","eventHandlersInitialised","markerEventHandlersInitialised","isKeyboardNavigation","questions","init","setupEventHandlers","hasOwnProperty","questionContainer","classList","contains","window","on","handleWindowResize","fixLayoutIfThingsMoved","focusin","handleKeyboardFocus","focusout","question","getQuestionForEvent","isNavigating","currentTarget","responseForm","markFormAsDirty"],"mappings":"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"}PK\:@@type/ddmarker/shapes.phpnu[. /** * Drag-and-drop markers classes for dealing with shapes on the server side. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Base class to represent a shape. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_ddmarker_shape { /** @var bool Indicates if there is an error */ protected $error = false; /** @var string The shape class prefix */ protected static $classnameprefix = 'qtype_ddmarker_shape_'; public function __construct($coordsstring) { } public function inside_width_height($widthheight) { foreach ($this->outlying_coords_to_test() as $coordsxy) { if ($coordsxy[0] < 0 || $coordsxy[0] > $widthheight[0] || $coordsxy[1] < 0 || $coordsxy[1] > $widthheight[1]) { return false; } } return true; } abstract protected function outlying_coords_to_test(); /** * Returns the center location of the shape. * * @return array X and Y location */ abstract public function center_point(); /** * Test if all passed parameters consist of only numbers. * * @return bool True if only numbers */ protected function is_only_numbers() { $args = func_get_args(); foreach ($args as $arg) { if (0 === preg_match('!^[0-9]+$!', $arg)) { return false; } } return true; } /** * Checks if the point is within the bounding box made by top left and bottom right * * @param array $pointxy Array of the point (x, y) * @param array $xleftytop Top left point of bounding box * @param array $xrightybottom Bottom left point of bounding box * @return bool */ protected function is_point_in_bounding_box($pointxy, $xleftytop, $xrightybottom) { if ($pointxy[0] < $xleftytop[0]) { return false; } else if ($pointxy[0] > $xrightybottom[0]) { return false; } else if ($pointxy[1] < $xleftytop[1]) { return false; } else if ($pointxy[1] > $xrightybottom[1]) { return false; } return true; } /** * Gets any coordinate error * * @return string|bool String of the error or false if there is no error */ public function get_coords_interpreter_error() { if ($this->error) { $a = new stdClass(); $a->shape = self::human_readable_name(true); $a->coordsstring = self::human_readable_coords_format(); return get_string('formerror_'.$this->error, 'qtype_ddmarker', $a); } else { return false; } } /** * Check if the location is within the shape. * * @param array $xy $xy[0] is x, $xy[1] is y * @return boolean is point inside shape */ abstract public function is_point_in_shape($xy); /** * Returns the name of the shape. * * @return string */ public static function name() { return substr(get_called_class(), strlen(self::$classnameprefix)); } /** * Return a human readable name of the shape. * * @param bool $lowercase True if it should be lowercase. * @return string */ public static function human_readable_name($lowercase = false) { $stringid = 'shape_'.self::name(); if ($lowercase) { $stringid .= '_lowercase'; } return get_string($stringid, 'qtype_ddmarker'); } public static function human_readable_coords_format() { return get_string('shape_'.self::name().'_coords', 'qtype_ddmarker'); } public static function shape_options() { $grepexpression = '!^'.preg_quote(self::$classnameprefix, '!').'!'; $shapes = preg_grep($grepexpression, get_declared_classes()); $shapearray = array(); foreach ($shapes as $shape) { $shapearray[$shape::name()] = $shape::human_readable_name(); } $shapearray['0'] = ''; asort($shapearray); return $shapearray; } /** * Checks if the passed shape exists. * * @param string $shape The shape name * @return bool */ public static function exists($shape) { return class_exists((self::$classnameprefix).$shape); } /** * Creates a new shape of the specified type. * * @param string $shape The shape to create * @param string $coordsstring The string describing the coordinates * @return object */ public static function create($shape, $coordsstring) { $classname = (self::$classnameprefix).$shape; return new $classname($coordsstring); } } /** * Class to represent a rectangle. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_shape_rectangle extends qtype_ddmarker_shape { /** @var int Width of shape */ protected $width; /** @var int Height of shape */ protected $height; /** @var int Left location */ protected $xleft; /** @var int Top location */ protected $ytop; public function __construct($coordsstring) { $coordstring = preg_replace('!^\s*!', '', $coordsstring); $coordstring = preg_replace('!\s*$!', '', $coordsstring); $coordsstringparts = preg_split('!;!', $coordsstring); if (count($coordsstringparts) > 2) { $this->error = 'toomanysemicolons'; } else if (count($coordsstringparts) < 2) { $this->error = 'nosemicolons'; } else { $xy = explode(',', $coordsstringparts[0]); $widthheightparts = explode(',', $coordsstringparts[1]); if (count($xy) !== 2) { $this->error = 'unrecognisedxypart'; } else if (count($widthheightparts) !== 2) { $this->error = 'unrecognisedwidthheightpart'; } else { $this->width = trim($widthheightparts[0]); $this->height = trim($widthheightparts[1]); $this->xleft = trim($xy[0]); $this->ytop = trim($xy[1]); } if (!$this->is_only_numbers($this->width, $this->height, $this->ytop, $this->xleft)) { $this->error = 'onlyusewholepositivenumbers'; } $this->width = (int) $this->width; $this->height = (int) $this->height; $this->xleft = (int) $this->xleft; $this->ytop = (int) $this->ytop; } } protected function outlying_coords_to_test() { return [[$this->xleft, $this->ytop], [$this->xleft + $this->width, $this->ytop + $this->height]]; } public function is_point_in_shape($xy) { return $this->is_point_in_bounding_box($xy, array($this->xleft, $this->ytop), array($this->xleft + $this->width, $this->ytop + $this->height)); } public function center_point() { return array($this->xleft + round($this->width / 2), $this->ytop + round($this->height / 2)); } } /** * Class to represent a circle. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_shape_circle extends qtype_ddmarker_shape { /** @var int X center */ protected $xcentre; /** @var int Y center */ protected $ycentre; /** @var int Radius of circle */ protected $radius; public function __construct($coordsstring) { $coordstring = preg_replace('!\s!', '', $coordsstring); $coordsstringparts = explode(';', $coordsstring); if (count($coordsstringparts) > 2) { $this->error = 'toomanysemicolons'; } else if (count($coordsstringparts) < 2) { $this->error = 'nosemicolons'; } else { $xy = explode(',', $coordsstringparts[0]); if (count($xy) !== 2) { $this->error = 'unrecognisedxypart'; } else { $this->radius = trim($coordsstringparts[1]); $this->xcentre = trim($xy[0]); $this->ycentre = trim($xy[1]); } if (!$this->is_only_numbers($this->xcentre, $this->ycentre, $this->radius)) { $this->error = 'onlyusewholepositivenumbers'; } $this->xcentre = (int) $this->xcentre; $this->ycentre = (int) $this->ycentre; $this->radius = (int) $this->radius; } } protected function outlying_coords_to_test() { return [[$this->xcentre - $this->radius, $this->ycentre - $this->radius], [$this->xcentre + $this->radius, $this->ycentre + $this->radius]]; } public function is_point_in_shape($xy) { $distancefromcentre = sqrt(pow(($xy[0] - $this->xcentre), 2) + pow(($xy[1] - $this->ycentre), 2)); return $distancefromcentre <= $this->radius; } public function center_point() { return array($this->xcentre, $this->ycentre); } } /** * Class to represent a polygon. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_shape_polygon extends qtype_ddmarker_shape { /** * @var array Arrary of xy coords where xy coords are also in a two element array [x,y]. */ public $coords; /** * @var array min x and y coords in a two element array [x,y]. */ protected $minxy; /** * @var array max x and y coords in a two element array [x,y]. */ protected $maxxy; public function __construct($coordsstring) { $this->coords = array(); $coordstring = preg_replace('!\s!', '', $coordsstring); $coordsstringparts = explode(';', $coordsstring); if (count($coordsstringparts) < 3) { $this->error = 'polygonmusthaveatleastthreepoints'; } else { $lastxy = null; foreach ($coordsstringparts as $coordsstringpart) { $xy = explode(',', $coordsstringpart); if (count($xy) !== 2) { $this->error = 'unrecognisedxypart'; } if (!$this->is_only_numbers(trim($xy[0]), trim($xy[1]))) { $this->error = 'onlyusewholepositivenumbers'; } $xy[0] = (int) $xy[0]; $xy[1] = (int) $xy[1]; if ($lastxy !== null && $lastxy[0] == $xy[0] && $lastxy[1] == $xy[1]) { $this->error = 'repeatedpoint'; } $this->coords[] = $xy; $lastxy = $xy; if (isset($this->minxy)) { $this->minxy[0] = min($this->minxy[0], $xy[0]); $this->minxy[1] = min($this->minxy[1], $xy[1]); } else { $this->minxy[0] = $xy[0]; $this->minxy[1] = $xy[1]; } if (isset($this->maxxy)) { $this->maxxy[0] = max($this->maxxy[0], $xy[0]); $this->maxxy[1] = max($this->maxxy[1], $xy[1]); } else { $this->maxxy[0] = $xy[0]; $this->maxxy[1] = $xy[1]; } } // Make sure polygon is not closed. if ($this->coords[count($this->coords) - 1][0] == $this->coords[0][0] && $this->coords[count($this->coords) - 1][1] == $this->coords[0][1]) { unset($this->coords[count($this->coords) - 1]); } } } protected function outlying_coords_to_test() { return array($this->minxy, $this->maxxy); } public function is_point_in_shape($xy) { // This code is based on the winding number algorithm from // http://geomalgorithms.com/a03-_inclusion.html // which comes with the following copyright notice: // Copyright 2000 softSurfer, 2012 Dan Sunday // This code may be freely used, distributed and modified for any purpose // providing that this copyright notice is included with it. // SoftSurfer makes no warranty for this code, and cannot be held // liable for any real or imagined damage resulting from its use. // Users of this code must verify correctness for their application. $point = new qtype_ddmarker_point($xy[0], $xy[1]); $windingnumber = 0; foreach ($this->coords as $index => $coord) { $start = new qtype_ddmarker_point($this->coords[$index][0], $this->coords[$index][1]); if ($index < count($this->coords) - 1) { $endindex = $index + 1; } else { $endindex = 0; } $end = new qtype_ddmarker_point($this->coords[$endindex][0], $this->coords[$endindex][1]); if ($start->y <= $point->y) { if ($end->y >= $point->y) { // An upward crossing. $isleft = $this->is_left($start, $end, $point); if ($isleft == 0) { return true; // The point is on the line. } else if ($isleft > 0) { // A valid up intersect. $windingnumber += 1; } } } else { if ($end->y <= $point->y) { // A downward crossing. $isleft = $this->is_left($start, $end, $point); if ($isleft == 0) { return true; // The point is on the line. } else if ($this->is_left($start, $end, $point) < 0) { // A valid down intersect. $windingnumber -= 1; } } } } return $windingnumber != 0; } /** * Tests if a point is left / on / right of an infinite line. * * @param qtype_ddmarker_point $start first of two points on the infinite line. * @param qtype_ddmarker_point $end second of two points on the infinite line. * @param qtype_ddmarker_point $point the oint to test. * @return number > 0 if the point is left of the line. * = 0 if the point is on the line. * < 0 if the point is right of the line. */ protected function is_left(qtype_ddmarker_point $start, qtype_ddmarker_point $end, qtype_ddmarker_point $point) { return ($end->x - $start->x) * ($point->y - $start->y) - ($point->x - $start->x) * ($end->y - $start->y); } public function center_point() { $center = array(round(($this->minxy[0] + $this->maxxy[0]) / 2), round(($this->minxy[1] + $this->maxxy[1]) / 2)); if ($this->is_point_in_shape($center)) { return $center; } else { return null; } } } /** * Class to represent a point. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_point { /** @var int X location */ public $x; /** @var int Y location */ public $y; public function __construct($x, $y) { $this->x = $x; $this->y = $y; } /** * Return the distance between this point and another */ public function dist($other) { return sqrt(pow($this->x - $other->x, 2) + pow($this->y - $other->y, 2)); } } PK\Z::&type/ddmarker/tests/fixtures/mkmap.pngnu[PNG  IHDR KߋIDATx1@I'I$ X- :;Ti&N&4bxh`/{_Ff<˃lL3ms13 3323333̒&QnW9TzJq^-|JErhWLChԴ{D%eٓd#.,dOpvc>%U\{m pS,Je|}M73IUw?6!#~֥ЦV57/oIՓ̋>cgh&WN8hۭZŔhܻGf0qe^w6ϐ#ULכ77!\f&ocrυzDk[mg'ܺږ]P sKKzUyUU~yUB68z:3[^ & *X4c&"KMMuBeMu ٥.}Ӗ*y#SF qjhq<`pby/.8=SP3ҝ_Cy댞$3+>=NKK;:rpnlc'Gb}}̣G7i*IJaPѲ ݈s47g AT)QKIYRYuaHK[ݻ ,~Z'0T|zOF7zv\ o9;fkjk댋p|B5WW/,TEݝ#o=4;*?VU%vwWW'ibth$5ZDDqm})-&fM 7ʫ'3G ٹAqs lvԖf@ Ewdt&vn_0}Oep, .NF=:^^TPPҖ] S>FF gkCݩ[Z1ֆ$cwf v`vw.ƶ/k)))*y:FZ&&<hKO&xgX++}nrgX#G 5o7ק5aCU"tpj,^K&ш]_uu=--ܘ2MmE62VQIGAupSd[X:t6Uծ%x"9ICB.m{WO{&1|"y I7q`-hDοIϬ߹sF yyΝJI MXܳR2:+u90P,$7X':fjq/RA,%%5G67gI˒]\l6\ݜtJtU <}_A09AAXX*fe%-9{4kxּ:F0:hEYn3.4]΁/o}Zer˭׻+KFUإߺfcNZș`Nj%$u6'l4*(a{dTE055k**{m׮R r6tZ[onLopXb{M vXYQ^>n`Oo6Ӕ_Z F7wήWxbqZFk523#"BIʆ0k&`d 5469[̈́ό^`(ˤiN=Y<݉TI)FC;7j_l9Z'&`p/7ܤ,'GAQ>֭Wצ+bcwWo CkRmE-)H([WźU5ixW@gxx ` *=kְtY]d9T*+%4r3EPXӅJMô@_ K˸Ъd12''2K4TWb{v4Dl֨Ҏ'l=^YrG?罟p~`{%Nl۩qv+vR6' WڧŒxVAI^^LggNGG^Oġ'o kE, ΁埙kemilbcc븰XmX^^(WT2ptDF0l\$ Phb5.v"M]¾Ǝ/.Vc. DXy}}j=W@̈́}6c0L u\n}yjNa䏕56rvl(`cƐ#r]F7 F.Ycu;G`ڝ[y[QKlܫ Sev@P݉qmE2Ei>W|g:Y` +x25qpr*p,芪əAA&^.S>iE tt3Kvw[ޞ1X[62ZNd dǛM0l<޵:ΝvBvrb !>Ak.$gzqfFИSɚK"#6T"x]x[iFAzwtg,ycֽ+m}+wJ0{ZեH OUO0x7T(Hy_V vo펹{0 +1Hx#bܗsnn@VVkbMvNO WjqlFU]c0}uZm^ J|iV(0"x|?O`q +',G?BL;-QJBՕ~܇6d5&E 5jPJI[fdU65}- WlL/LA0i0@ۤ(^V~rҔ9MRG`*Q}Q.us ǰ믤{KSp!ҵ?] ~;>`95oR,Ea?%~&qZZxEJJZd;1,$_DFFFz'$\ff6uS{k XvcYth&+ՂYX*-$&gdfeE~vn^dz=b-tz|n FiOo_~ccQ3sl!Z0AWO“#ф% &&+cҒRWCs2=TD_3%fdO.:]wHSNE_Gm:c \:shxHj_nV F2Ox,3"mq dq/qA^T'Ro_m[[vaqS{{AaaLH7J)+RZ[yG0-=|lO3cLYS7%e$)Ϛ  薕``|H|.YI}~kD%eXke(LHKM,x ͕//< HRqhketoXINL5>r5--7,.cctDGxevvM0_`3sK`LMvw ΉQUVSQ՞,ՔU e<}2If ʪZ:&2r:LL z]Nl".-,KssKFgPRút`̗&LFFXlH_TLP|BxDD`VrN򢅅U ׯ|xﱱ⌬*5ߟa seP<6VN 7<@!g %1=55 TwUOo%թˍ88WW\9CF2D X8"K*ܼeE!`)]>D{3'KN`R +Ywk>J|HzCiBSArbZ7x/<LRBlʓ wusE`0I0~ E ~#-@:#ou 5nz~M6sx֬4;qTEmLkk{`o^^lFfTDd`Vvxn^ԅJqq ZiL@Ǻ̌"TV&YS\YⓋbmllL'gל(VV:;s- (EE2+ pXu8CLL4դ4sF_<(?2xGFR 0_VSkkVFN;JJJڛzH0сY_> 9-DͦA 3+ Fޫ |8)Ï$|#Cwc=iÁ͛$@ 7c#+z}+Yy~~>^^4zIMM-4Mh-AK4l솆@.aWWR(oQQjKŮgLL.o! 7l9 lZI=wN$11׫ F\R}&\,Fx3sL||8آ2Y[JLaaqqqYaqW@02Zfz2*K`c}:M{ S :i',h ؎Tn>ҔE&M\'fQ7Q/HA؂b7T=,iW5=6U,{+ݟॄpTRLl`EE,  QFHll6댺KF>jEƣ|VL%(5}_ Oԓ'f9siSNE+V 5-hԚ^ۺT |UR&Z@YM4,6 ӣ;ic%$l,:¢`wG81l`f 004 55CI$$EĆ^$: `J|pёˋ AbؼA^^@B唴p-(. &R1P S11Tj*ގ陑G,1--WWeeЕ_XY+;+'fi}f \eUٹUsZ]/e}/bB*vSʫ%:".fJ?͛=Am{hojTA09 }lԌv74ElZ3-Q칖`m ~E j Tis rK혘kCt LUT%#1QRjj8Xg`f:yu=>5//n'm#E2SZZ 1` " ӟ@q^^Tqq,!0??:/?460ggL:x# C0qoy_c'gWElфMVw):y]Q٣}Ԇ4ľͶwEN wPqGYܡnZa~-`t.s-+6YsJ%__/%SsZr$WLWWLv!XMI:6|Rs 9=%@,BZ>%PU{p5x v`>sXܯX$8//Ucfn~a`bL+E|$5<0^>b-B&s Z!c ##r6PaܝZ=ߋݯYE~ aJ; r[^PCxSUD OstxKi)f3LB .o_rPÏϻ ހ RSk,s:%jɁZ۔P]Vz,-Askb8s65dD3!DGOU*a#ae swĂy67!#+ȪȧVo Ŧ-5t}h6`,n3|R辩 ߴ6y }[WF.*ERe_]oq;zZ[煎UHk;5Suد)GEq_^@:wW3*&H'P%9j//JIPɗ0YV 6;vbVC{,2ڲJK^|hb*:YhbEII hZ6d9$%Dry%Rj:'W6I'д`ɣ&D5E]CV~}zMBO6RhZaS2DhT釴Kȝ9D[7k n)_9R/qeHXyayN̜߻׽j %a֜"İ13K=yJu[*V-f2GAOfssལ7*&2rtl\R#n"+FZZPWdgsdnYdƒ_' |$ܖ[Pʮr}uYVn=KOM࡛M34YP 0eO\aYP+iO~ sX2d-5wMӗSF)TAV1Îa҂$tu)7 _2],M <ޢ7} t(I!yA.Z^O\ IId՛Tm$M[cQ[آAtu3B*2X4)v$:<7ZZRp?%)])11 Qo"ɦ!+co1{uT1$y7H>IC$b (ΫؒZ1PyW?64u_7iK4'YyaP \dIJc?%1]le5~p 415DsrGb{'H >%@W0%Dt<n;Ι*,Eԇ[ul\e^C2O6zY\pLrr06>؈O Nahuo xCޥOu>xwg({{3*()K¯8Q.<)Q|% R4!N iDNstYN|GűA)ǎ4+ \fO}ˤF>\y|hff R%{J^A6s(l2s( *iU-=d1 J la5Zn:F+K)˧VͽiTVͫ!bCRgY LhoP2%$wwǾl6v(1lb1k6U(]_3`m&C 12RbmPstF~ZajhL`3ߺ5߆ %.=ZcǴLJh!}Ōۏ1gVih' ખ.،kgMNp*^%wE Q| :+TJJ+q $FV5ob' IuOsn>9aռaV֩XZSsD6aZ{C<1 %9SD񐰂y0 .HIQ,, XHD9,2ؕF5Y[k06sr)-MV*dHBxCޛ[kh|M0.\}:i?McK'ݙ-e-MvqZsI;-=?YΆE\z$_D`9]o- 瘢/K#.ҞUQ94Zod>s^"ZUkb" [,aխrp1 /;$5۷lǑ?^|}cSy/]wJ' $mEED"x$0nQWUqYU]愪[WeVH"4IaIc/S' {r1{y1qAWS G[Z9qCq!&33833B PtW77!)wvf4CCUST [pu2Yd/67kE̋fY=䯟`lmYrkl41am !X¾cfԬ94cдU[ RV?n])7`@2.4뺜D>PRHRT=U,}ZIMUIIHtNQ̈́%3md&b*ٓVQhe~NQщb6{{ą(%pae{d`sLܤ.))F2r>ˠݵzZ#<*,)*khRE6_'wdHhPKwwgGeD't HHG/  H=`PS {ߟ%/Q09R b7E776Tֱ/[c-|kR6FE$lkDKҲD4}}Dr}Aaf5Fu=*:pjHS>]8@刨@†c:h&1]N%kk1,m{ӫv6Wi߿Bq*_;-ӹnz8:]2J0Ge_Ѳ))"2dH0Bemgi"])I@Y[xv V2dMțJ[&oW@_abdn@!& ͬڵ V]E0WW[ ǗgN 1}XGص_OeMK+q|M BNNs?8 6O|s40^᳟iE`sv܁g"PLcL'w}#VYeD-.>40C2D_,vH,߄򏮶gB\#ЪMό2 !uoY^{@Mdn/J<=dO)Ur4Hy}wEERx^?j]~iB[OlG~v|}ccszQp!˙xLgtW˴_A5}ysu6_+p}3+! "excR HʝR4 q-ejy: EJ"K~L5L0orɽJt;z'n& :hڇ0C[eiH>Ê:I0--Y 6Aj]*7BPiv'.^WB`;O\P|{+t⽏y rC@i-~1~Y88jR%lb|ٷ:;մDм?Xn4KLʛF2?u޼g dCfX>X(Y t1d=3v9Evy?7noߡD(W'GӎI/Sw\d6|OL^^%`fٙ?4?I'Z6kpuTHr3J0j{R#̂յo]\JezJlrZw mju" zrJ0QNь7i$GuBmNc"`*Ye)j˭b`^-PsB1=՛Gcw _Qq]#`W$iտLZ9<ڬHTQ15F`X o2*먆{o_%d\ºq=`5JKY)1={o+ؘrt.{$nw ۅϩ_2T H7k|ȸQ*Q="S-x5|d>Rn1ór6 %l"5B=ȁ6`n!zn"aY 6qwVh-|2 $#d7!K}W5 ZӃ>V[Ton/=*q2]dLQTe@`2A&l<O34kK!&fFyrqUȑӨna0LҖޒ>ڋ m,V)3yϑSI-N9#?X'Tc~)҃j-"wٷa 4dǢ 9%w 5?)PʽƐ1%Xg©16fx'eJ&f WmlSDst洼4.0T[)WwEyw[5U‚ ?-"oО`n2ƀQ /+71!w\Hѿ3`/GV5gC!0EO?vy.(j5M."P9i dtѥ1嗳*]$vArV^2"譮~ 6RRBR Xd}PcKY'R 0Ov I2iUuJzN,Lشlo?RT`<`@j#8/16wGɩ؆%GaL;}CDU+  n䭀,q`炮<](.<͐Ee+'3 d <5ܶ;Wr+,ZI\J7xz.zf!5P8&6@5z1WEtqOxi*O啗P5p ?a I) ݝ2=]T/7o-/:vt;Ɯā]ЌQ69LӢaDñ.2|eΚֺ=JkK*fHS2.My>A6VAIe +psb`b*@HɎ몼Ј /U [ԡ-aD  WJPb9G 0zWt\YOrCK>0<%r ,TqkFtk߽N]CdRNhPE AiM*AzV |2lEŻgTP<_f%ebb񵠡!][۔>#,TVnT7Ursҕ뺝frKqPw/Q 7ީ4y>a)'6n{x /hu,^ZYB&Q Ee{2<“D,d 9GbK !Od6l!*Sr ^rA0f<|Z%{0ydxxHU __JH1{9:9iHhSUEpnQ]k"ڮ1oL H8] 6vwTv ̡;2~-/`X;C-@WWJ o0 7X[=bh08\q`ІJ7Y5l溟+7t>ݢE08,@BÜTc%y:A~|+rb41rx{\_660O<1eqFIP.i-C3)QS).Fv겕Pڎ.vLd*dD)>1W-I(~u䔫BI5I|&вFaE eDm*k࿂ȚV$USRJ}u\eXTSR,@S^QWوBX6@ᡙOt-zcr57oQZ~s5n]]gNU5Ilyj~?Tzegcʚh)p@m#nMnUo7EF,3%~VVSHiIH[W>I5fδD=z;bh*$0L2ܨ\o3^S2'~iP~řM5F1^uQ3 6S.+@qDS +<ŻUps%$cġ1 ))q O{}epJ|/K 6"K֖kCMzzpkkl`^ЎA[Ugᬨ}7,a=nҙ,OCڑ3SV9+pv3 @^iC6W% Frp'rQ{ބ-"dT?p *s|x$oTR1t͠j_>MDMLT 66}\t*O;.5-Nhe5|ʰNf25Y-c=Tkұ; ӵt7MLlVZ% .L*,3gKARJHdNcvtw9ӰVm-jhRN KuuԬ^9BBϮ(y01 F#f S}Sͣ EFk[_{JwJ9rjZ+=`;WOE +;9Q!9Yp/3L,)MвRb3/>$L 3yR9͡m"w9c. ~ 쌈97~.Mbb*D1T`p @=1by=ޏV5t9 Z%S02VOe-nl*uߠ FC3/`#u3YdǯDLNNQ*(YPKu A~!a߲smUc m=eYR16M,<2+Fs{ťݽ&8yy+wm;H4@7<.,Tl4bRDh1O32Jcc<j^UGk4u'}* Wl0 oQz5I([}ӣ;6*}o/-"ZarW&Us jZ-%b7x^RK3'ת1ĒKOw>H.lrU#jus62ªZd и87~p:|g~ug*!a55)n> lE5NL)l2[FG$ԙVѵσ; 7A0CCE Qs ̲\\]\O [h.TdbcZ~[sf֡bnBsPf.BPKUUAz. nnn%I]] /Ϗښ[Lx'ecAII)??UِP߁B77 ܔ -bOW!Xi<6^Os:ȁCJ`&)"S3ՊvI<;rzfUzcdT98{xd0ǖ |PҌO\nukz a:ϻ'LE4l9-Ǣm4T3cPsQ׳ȓ5u!yƎJܑ)lk9CҦH]NJR L^DRuꆄ˧W.r JʨXȒ]t)q'.m'"gFI)l?zX㔔t(68\[lR89wAW{rj^ *Lv ]} NN 8Nf$F]RFDпDмkp9t{{GK߽ͭیֶ$kk/]DssᑲB+``XY;إGegG[X (<4kUegG`0F1ZZ {f:\7>gR0' !X [d.-fƦu%0 xƽ*{`D3خ ؍,8ֲ EU#[X;+HA"yS'%3 |'S:IjhVU';HdfF%DrrT:ljcKhťIj6+uم]F!Oegu|%'b@W=bn1 "]#?% [6̷iYQޅ+;7}jG? 198eKut̍ȕU==n/gdF mnQWVjM-кyd\PPYpbniFW<*}}ӅŚԈUZN#:.lޡDk?m%jAlǴDzn`#{ET%3,nۣ$p;n,RgP[=M5_]N_$ɐdncOށgZu1s4)o&tNsNRk)Ks2Myo\zO̯|~DgFCezX󩒼,E.%XlsFFe-FgMI#abK&^lM0 هdfE?6#`pc~gik]%DnXx^oƶ;Ԉ~Skk̰>^A?g`vȌB-pA*Ds%8J\&wTݾ<XPP]ӐrI. j=RZRsʹ޲֎E͇7@Q[ۈ؞*2 E8ІPPr멙$]%%r@HYLEE'2KUV564.tIJ^^wk;; :5-\C0<g`&i6}W`C. JN²R֚]Q49SzhcZR6)Ԉ1f`4ӸyU!q6I1!I[f;,C`UNyt0ߴ~$bzļկ7sF-BÎrZP#Yc 'gCBC|]UcW&(V/cn+KhE3:_W2 (VOA񗫥6_L`U nvp|C!qzpK!`ޞ_Hnu/J^ղ/{%uZZqi@IDƇWEW`zFdHoNnVoo>࣫kܚ/)ZSsflT9jB|][ TԌ**TTkqg< shk{EF\4TE4 "洭)%a=#hmn5|$9K؎Cp>([MC?KQQ5L~h  -K65[tt̨11R&ZYYdXZ*)allmm\2ٽ(QAihOeN'i.!A"ef;'斯IZ_tr~~e  (#[Z2SSèMYN΁AdޏyT])K8s܈J{o6}씶h)U ^P sb_sr eINIIo)8l+hXpWd/ZJgף@@׋xſ)ID)䁿.B( mZ%!Q??)a[ 5B*Z{^jQ?y).lEkhNgMQ2&^5# R5.^J@jn ,ꅅjs ;eU}T` xAl&,AiKKPpIMmr`n~EaaIg`VaB /?"0L^fg>2_z~ 4%+ ' 3]\tBWV 杚4TUӛTS7Doo7SSˎZuMc߄ZNƭ_0( xz 2g`[e#r>mt#HC(c =uN׏k?f J%誏K1Sż$E"䄓TĘ~;p5p/츦W ZHJRοF?`%3V4` Q4cp,s~k\qK7n˄{"h V |hmM7%Γ-7D4Tj'4)C5VM,2ՔN=b{xw,T#PGƄP]s]=h\&6ffVw7x{;[ZڡfiaYƦDR5sdQB2%%lcNlH!M,,m c3)+G\]=cCu݌j/a˯f3>+#k&ÐF~G'g{_qr`nE0S7f'X< VN=Ul Y$5ɓ , ʗ@o)8G$^+/-/LX-yd+͋\sJ(巏+$&[\}LfcyL t j 0vs~Ոf9[䂹}]D+qO4Ivʔ'?%f(o]y^V=ϫÊOi`K ˵m & AA=j5 ,`s! yi)6,l݇|}htzsW^ ^ϛ.p2lK 1Ӡ]{y&~"Y: Mc.ϭ<)6FsROϣ^U*3`Ջ4^YP-R'M_F)ɏ) D= }1c|!"9Bj\[ rKWVhCXΕN m= {E ɺ:d= DzPoz)ǰWWrd6veocJffTqu!EGZ$5NwBut&`/QnW552\oŐƞG 6Lrj:TEփJݠy=-˽Y#4:˗.ASnkck® +k{%co̺GLMdt4$^JzRmyy']yxMtrWIG0F䕯ސ3a*xĹ 'C֡8^*)ߺI[<^fĢ}M|&2C?Ѷ?酒+7l0D@~uH f@!<T݂PX66V:gr lJr^A38yZ?`%:6" hG1wGFJY]Y"ޑLNNkM_&OMQ~'Fn:l:n <ׯ#n=6i:_ۂ&(;?ze&g 9y{zBߢ1T Y/>2O)NdWٰ{HIg-:oyv](s6Q#ffffffff-eL̔|x$˲Iޟ3sΝ;w>ssjڬYC"byϏIXQ4=qS==jE7՗IhEΎ`7xr8>[ę&!N0"&Ç͙x_Nv|&ocx{Q3{Q-y]R]]' } h^"B%j a\YGcqQOxjłNvu嵶˪k2##Cgjo̩K ){o8(WKGٕWG[ #"rS_Vb3{&&&KC`NA#C@݇Mǒ.&Ebgkmx4ص6 ! Í==-)ӉÃconqL m1RsRoVvoR\P[1Q6 Fث2>I3[ט14r*zXn.{`JG~u"H W:>z?k*!XIekȎ:.]Op<#z'.=W/֢9(CnhH zw d8:8PEe^޹y)hVܹ3WJhzttEַn ȷ>ʎnm -")INJN 嫥-,KKjk/ 0QxL5ML MRU"w{{1B™F^ЏHu kBB> lGAq&YjJ;}.1̓#c (5G#{޺}6^gF5m],7{w(Ut8F?X~n4//߼hNǟp 9cmcpWrI)p|7w`Kfqǩ J&J9tÜ@q;]GXw7s;d SƾڮIa)ߏr%1,r5 u桨Sb_;7 Mw5zqÙ4RbslfPd|Ȝ$C4tt5)ɠ8hNNΠkNNUGף-"X8W &f{&<+-1'V X7L0Nj<GT"MJ p8f8b,+#RK'K9p.+Nj[6d&14=##~go?kk=ocaʲXKiY,,5yvwo~:rXhF`]' |?7GeX|ͧWƣ;y#pлc\qcƃ2#fSh!>$x.) :U,Lgz5oKxcgSGoM8d'0&36z>$s(ZEpKm:lEU- {lTWkc`76f<EGUt("1IX=f~0z]=Auw$̓fiiccb 0(߿xxxyyU(`dt 4!0Iuf RŰ>ǐQݳ~S yk ` <;G~kM*iaCl*x$;|Tx|LLT^P& B$d|DI_6^xFNf cڷE{/א&8>Cݧy -B!'tB@4x|\ iǏaZmK~|辜 =˜`/?TơݶHh鮆oo6u@9|#;\b٥EK$.^ +ʿEgg1LKó<ْ_ld-+ ޽72?84\QZydcXXC/jNGE2ҤOO$,=32❜\ a*|x>[Mr-VW02 gg7=>Y?"2Y謬8V[,5޻,3bo9*/H~%6K;fhF=-kyҹS:DJo弽W].5~pHgY[DD64d>~Iwl nVC slR#d>զ3'Cɛ-w.8 ʽ4Ń{uxwȿLVz.\>).tcd{5]j_[DJU5G;Kq QI=u:鶓+m._nsoUBLL! QT‹viWX\;SOǨ܈VLמ)5M<!QoNt#3FX_u!\5tUFʊkZ ̵ͅ>g]<}rj|Qő;2 󶋗!X|ksgAKy&$FG8:TM03Z\@8J3=SWO Occĥ&#VgwڠkdD*wRv>-7F>i4(} "/2 fTgm<ƃr[֋;Qs,F[ ܽ'p"'1G\ZO J7ahj +`Ѹ;=~'61*Ͽ>?FA;ՋQy `RHi+yQA*f&YV 4endhng(RT mZYd|0MD_ y6Xh|PLs/{|d 6أ/{?އA\Ȼt s'Hqǩ24EXAG)bزȄo=TN!p{l OW~K9yf\QT -R#ҪNզj4y@h%ĿT" ~IdfGRv~=S/ŷC ݸL7$0Ȏ :Y9 /れť:CHjӸE] /j-m ˫ ϯ5tP?BH:{0'O6|xԄHscxbNAMM1 _T4Mi|wrbtmՊ؀[k_ZkLV[ 5uuRCHL8ٌi nyYZa gd౧7w75eIn:Үۍ[zp8cda&G+GS6xrm+)9#Kj֙,iK)w 0~<_Ra.7Դc7 (^ĩ_o+]1PD|}7ꅕ @TUHy;VC3-N钹TPLl6,N_0~ ]Kԏ$ֵ |%KL;* RC9dB]/mXi"kCf7/lp?^ qۣ-gQ<0Ph*6oq7|lwT[kP)hX)czD_& !|{wK9֧G>vن"z'/&8=j>{1+騾$ґا3EKDӞE\1 զBqfhs\ӄOMk>|>q.t/D9W\-P!ҘҔX.(ٌ{Yv"oޅOh[g;2#%˷!qx*\Ͻ]d':譠!glh):'gx<`x7Ob/ 'fQ)mCCBy(ó\Y{IibTL_&͎c+QYq|3:2ĥFdLkQ~eM\s+Wna`khX+&țڥ`/߅$obSj axbFFdMwxu|5a#leOǕ+yn*ml*utŧNbZG]O^5 `D^W֊d$ }Si^̅ -6¥x? ]s1?!"c<,=(@HǙ%2 O?:N3 l:r}tQqwvW\ZKL^pqs+kW]vKה6|l~%|>2g47581']JviфW#ND,]Qr2\`J34 E,NU!UG qd",6;RnJÈlŠP֋l沵xsDo 91hl6vq%.Ɂ-fY=E~So A+$S:Q7e2"Xlܱ b;Nu<" X!ow"0Ϝipܛ_V>L1h=#?ҸiGi/ׅf؅1ВЍ3 XQiӃ(؄xf䌟z&RNvCB'J$ Z -@b/[?(qb3+Kh*ň82FP5=$Tj"F5>3(Qh+s@.+mq+ucbB#̢fA_kkw*`ba@ELc gX[:;zhj#''&Rcm]HbcjU}2 2p/<>F0Um;]fA5`di@3>Br w#nvmm PłC `b#kzgUUfc;]>|jOBB~(1@٭3_@_)t?)W`"8PUSԔJɗx׷>iΧ0Xq=ysм^L]o >H_^V=BZItډ ??SJy (ǥz%܌^ X1RyPu#q!}AH`Ѿv=,Qsg jlb}|rz '*O~ r^7Ԅ1!?EXs=Z&X/K4muǼx(tڲ"L%klCt (s`f(|\j YK߀!.Ks~JJ -HYd u~OkңJGfO]{W<)~I2m?%w'a-[|X:7wGʎ<|}VAycYV6 tc~߄ ƗM4M#Md5~>3RWW X} ZNlINKtgpbAFJ&g4X٫ .LKnLmL+w3v "\iQ.bSWs[hX҅nWㇶe"|SiI5gzMHvfWw^"vC6%v-}!GX##k<ʎWN`S^)-o)Z Pnlo//n.;DdهF1;&xWbyڳ(=]#α5DƦwflCx%b)Q-??j zH Ɣ&0HqO( Xnbf3v'S|tRfHkkȉa,R`4H IpY:e-?oFsJ}>=*}*`*>vk"$a4O-HŶ lQyrXU)t9s|T8+m-aes+IYD@oI[K؉OF \n_H,j/<Ɵ/s w@b0`C:$hndj2&[PƏDVkk&rNiyO"8gUY*N\Ϊ1up NŲOYB9 oi[vq"⏫1rqrO;;>߄Xm`[]2-S_[{l݉3=,WtmrTM|q} fK? e$VE%ɷ w/ [/v""?[38eWJq (hD?nA@ 6*j_Y5& n Bձ̌eE=H7)/]ƌw NJ&2J؆ H;ʑmW,q a{\iy0>igا.g|E-2 9ʅ[bAm^rBt#.@9E5;j dH Cm;^2A`\'*gqI yR!1,|,!_GRi{)"C׬wp% qɿ֙RlMF&HCEX!'g!~:_"\}x4dN)Wo#t Nmk: cآIΝ_"#6_^55ʵ+;q 4PЉC%oo[8ֈ*ΝDl *H@ f(ǏOT0ySl]'uܟV?pѭ,}K9n+.#iihc @-MpEN0ɨLc5$ =En&o lC( < 74Bxޝ֐O3ͶК.s`cqD4)$wX\"~}5g62Z%6MWZ5Yԥ*sD7J?298*+ݚɋT S LY&v;4T0i3ܗRB€ѦK8niĖiȚq㻮^'03!8Bim)hҝ3nF%OֵG{-aL$eOT2)xOU(J)[^t0ΞYq/鷑hUDLc;/?%^Kxi+V$-r ϭ=u3jE7Q+Ʊ̓$6A!!AQa3?S_~#Qc H6XYVTe|Cz*Z:F[ͮ<.iIe 9[|J°FĉT>O3'=0I!8 (8.T䎑}F66}ٌb݃Zo؂) Ŝ\ VLw-m=kа́3ș%x~Tm/&\Ɓ%.5cۿ~˜wQEIhfnъ1%P9g<(p;Z^oq7C.aFԗ 0(#ax5#h<(w^E3)9#߳9AO4+U1!3K/4E> ÜWJ*.m=c`@1Ͼ\,t鳬fǏz(=)k#sjs?BnU RƊݔ(1C!q瑱Ϗr- ł2G]Phhj2B6uQ3qyK+?! xs- Ȯir'}&2 y<$)P͑s5ȥ(]t@.cݚ]lqtg:CQ!cֻFo.^#h5,~b_ܙq`EEIlHƶmOLzx..QM9= F]]fRZ.1mr5gz! ._tI{x|ZW2047<Z]kaגB5709"352ڞ^YVI4PT`ict?n10m DSikWܓc>#2%)p@jD+ eՠXК/-j-3) iGF`'Ϝv}*!"']q}mR8Ł! W[d&ފU~zDMjɓ$@K0~ L Zg~)2 GϕS n!d T}Y-zN ?J%OCB{4|r0U^"cB%w O[ta6 .jNk@hj +:M5)L_w>801t3YF)Ri*m_QqZ=~ .cO4><#C69YF7S @F>2t4.MzlD)*Oh敜B(}ƃݽ%ށL K+E-l0+PWπʴc ε572S5E))AAAm:y##'>!WS95]w}}`լ77cL)5=hT݉.:ΞhYĔ7|_DNGseDdA5Cr^_CpqkWƾv'WbݿxrW- >[?Ê&]7+V( 2qU7`O~x4-.?E6ϏQ`LC0ټZLhQ\ ?T) wYaAO'a,BiP|"z4ñO l74@R^l͒Zن5 `s=2d scG<' q0:d=O; gNvlB̍nI%fE3KUH60ASdssr㋲쑯PMZCg,ܦE 9O*J9r;t A - R7!!YSy'?ڵ9X 8U;+ACS1'wZ-!Dt#f)$4͇i:KA-u/ Ea#(W¢y3]oq4r Y1k !;h +o1rr vqɍ_Z.~MwH`Qmち \Nk=4d>$ Rp\ʺ"ԖP }AP@?uj/pFB\n7sTCRZsd߮0l !⬵$.%, prO)JKVE{ZJ0`tf`PBw2p* _ǽjOGxVS37xbiyERoVdODt]%zb0ִsjړPƻd?ЛRu9);gquFe'cF|xAj[;: /HdS,ޫ?s1e)1(>##|=^?`kQ7o4& 0mbߥ]1bhp0+ɝfO28-;)޺=+W;֮,u=ie/BGE5C.&2n:3XzځSlvP>Ib.cC #$,OgqK-bxڧd#+#;yy ]=M]SR;*P;kH_T_xJ'XF4No l]fSgBDان-ޯahU6u'<&$>ȡ}n4GĦV'v#6@F_*SP\W*N:dm.wzU9-"|{0M?S y%7%8xDwPF_i& 8̓BcJJRff45ඃ!uiB~.qEه8RN:wK #]Y fg(^Ē Z$5_#?OYy$u|R|S{\?f"ڡiXſ1샵NDôL^3,l  *ά EhF˜s=F. ,AptږHf;gխ\c+ϸ|1u'X&!x C,:+ lm G8l ޯB[~8nї/Q+dDʅ^`n'%s;5Z@?WĞWWgowӁ mel^K5C =D ~P17Wxbz)PTNEϲB‚۔W@ELV h8~Kφz[?ш)g L˻{^6lAip)28.UlSޱM6C OlIۼe2.2*eǖA1dP%UA莐yNPJRy&gTgk/Iyq (?0Vyև󻅲eٟPx 0/ nhQڗ̜.[9c1*Z8DƆjOSjs=8QIwbRXՋl{\ǥrgalW$%:1})tsϝ(5$jmw1L}̰\Y$媫ї`1^e{dSNݸD)7Zch "^JF +(em7g!yB֜I^'{x"QC&+$C-#pUHo /pBW!9YˌipK%N }ݷ?bJOޢ2*r[ɸ`N`+YHq8p#UW-vFqCskk mwsU@@ߧ bOAz| /IH۵-VRh.Yrr$c[!]fjQ Q^ŗ1"Z'VY.W96a`B =%;$HT7c%c`~ dC?-ئ~ނ'_]׬r \鲖 WskuLYAn 3?d^djfC=3+aM.󪹒7w'[W* =c$D:F\1RsQ!;R$./]-KpQ9Z͗ok[|ӕx$2^gɾ&5x#vTC*ףY5+ l;{+q3.|)6c;޾WwmDg&K%M0 y'*[W*qO0I׀X()gEQlcњo!J_p4wT;qg?YD$q3Qm88]s %N(UY+s`,A w=Kbt{ vp-mq2SDW/oQ#3h[#zlndR!M!gҠ{X"/J17y銲ocᛀ1=~פ /PpQXņ{UCaU"'mWZ !?p$tI :w{\AYlD=p |k3 S-:\ô )(JPYhm˥ۧ1 LI kQ;wVmכֿ p1̃aEPrxfTk<}t:Sg%j4 xYh4 ~_ҽjs܍cү0CkoL<, %"9#=}`G|uTWCB)ѩ\޳됱 pH8o3y4ε+T |+WYM704=E{c(k>#blGin~?Mi":z'Oow<Ś ͞тsc%?>%m#'tcW܋~K DQFKDo,{kN_S0T`T_mm^@ OSт[䁰XåmxD:(YYl*뫽lvbcYR4UlͦpAGl^{uNhGto` \H>?%E@Dg:uur]d^)%m8{Vdrk2/ m]ĝ_Mٍ{_)x 0pqH! a-m^[R RRFSZ1u1J5f܈% '7 Q7n!N_ ~mHCFv1j'$4) OQy5jKBQ_?ίԡ\)r`Hv>A/?DsC326/ 0CB5:!qxqmmSMw5o TWZ9ſBrsGG_ji)BX+͋kRcwѧl@Uh8g<{BiOF%~u`Mo^VDY}^<)J8 A Mk&ng愭|$̧Xlj.G*Hۿ3lSƋwַU~Ř6M\_'3Jp!0[f?xf4ZmD[iaxR'44a2a:2̑U|=+WiDŽ5R%3SANJ_Nm!vkN?j&#⍐Ձ r5rG 35MT EN2&#.+cvPnf(K7>t7-u;SdFP%Cth%t g; "ϦSZZ/ |&uSMi$Ǹ}sC3a[~GIg' c.43 CDȆ;s9<X?GJqeP# t?ac\) X{m6X6^TR|v.-5ku2F2#~0ӜubKhuHC Y hM{6L;R { O1\,=yR7$~A]ZWOOD24(HWT|eYgj|`AH{-B؞2wou5 iLcl :k@8":rc_*#N:OυG9€| SNiPˑ=\hCZX*N744#1rgI@N՛4t(2̱$snں'E*}OyGIf$:)8%dZPn5 c G0缹zڪHgp _htD+!sLKd ||Qi/Ltyhh'?4JixwHl1 6 VW;`GBmuBR]i t9}m6,>ݬj"`x||)X"ReIr<1KmakRdtLb;]x6ak7& :($a 64 bR08&E+" K7x[c!c“osWB-DqtȋNFNJN$ OZj09RܢsV˿%u.҃n) M S5c( q@y-򰪨(%==!>># AS?8RC?|!yt;KGF˖m0֡;*>E\Sr.ӵ螵@qz ׾uhy9<Ltw:y=dQ 8F 䒞p) -**c-G|>WQp2g[=Ǩڮ. BJb4sKz9\5>oFAjV5 (^5dg95v3lrI{sQݎ/o|cF"dzHc{&bc$l@t.ˠ cb˵f )2BE؆Җ@B}~36~c3(iUl]TTe_XPJ=حie-upR.8 G[0=ܴw;CzEB)reEږ!kMƇǔbu*|z䕳!}P 1}jVG4툛LԆ z:p'4؅:vn:R#6t H__7$<+iG#>j<}Jg4WYij )Vb-?$5~h2WbC|#jNYH% g&=vWÑx 0eǎ+pfOww WQ]R| dB||}ff7 ֑!aM5Yv$?}=Dv"DTC Eb~/՝ʈrCYP6\]:׳95A)])7Caf@1 W<^m+ r۫RJVsVDU}jK6~g1v․:F³A*jv9r+Lh=˷(2R= sRy1{ڄx9clb4`-L;H B},` Ox?THhTm8fOfu{Җ۠+_."E_ތ7"Y\y!@zՠK5)Ֆ/MɂG"QԜX@h>C*uɗ"C?QtIz@siH=D.!erU|0??߃*\CF CEXbM3ulA!p؞5Bۧ%;z2vr /?|0m/u~$ptGS sMSS+.s Ѿ} aظrGGw;;`LuuJ]Cn}}sZȵ]@&a#XTQ,i&ΑbT+DKܴdžLpR*1š:-rDYbf#"ތ3.אmRR/O̦WTvElADd 0 ˦x&wrpӛ@cQM6 U5-7vÎBc9N̓ۚ"g,FV9RRgX 1K[KXCK \/>ȋ/EϬZO`:Ȉ㡭 h_Vdp\1xў#YE-9;J1 .ehL%q<{sXVCRƼ//O1G:f,qk x D*L&qy66:Ӧ-"?Shw07H:RMh lhO{agZ=ɄK׾NdckF}O_-ԪHkU\CG gc6ŏn[TT]t}@);ch.$kiKb&i.g˜-Semt OzrJ#a '+*J>sCpr NhNLrHL -335t@;oI.4+[$ 33{jcn\d΅mմ ʹc>hHfaT K2BJ8? zo(rЭr9[6b˼ Ǣ|B*:$40\cZQFARRR89*xhڇe)b\Xq#2B^>ŽlJH'-B1i֚Ѯ$;]hyn^yCE|O >4q  Un|ib7_10. OwK%~? `ݳg?V&s_`U[ JFF@ɑ#͛FEBz0νmG0Nb\lY 'iѴ`!`zf&boU޶bݼ;G=MǘB2ďMmwbĖŶ!zHs cJKD5sB6[@΀' B9hNrMɓ-Ŗihkӻ5)0~qK}=0ƆwW[ӟjLJֶ|0ll] =^%w~_G)(dȀRG ۅaqC jOpaQSqOL}9uXy 7TReXϢ89G CS}n'.VKzoI}w9 έيQUPNhk澸Ծ~!#\i`n^zHtO~9yA13D?upif)4ubO^5{;&˚?7!KfFe4ieVX {槤bٵ[ߜ 94$x]f590ͯ6Ah+Breׇ{zcde(=1'険w18\s--j$FF'?ѨD NgWP?FYccRr fxP(9*I-n)H`(N=:0={`9KfhIp6Zq+]kS67+(Z݋oES{#c(Y%1Mwx`Hnmdd\FK 8 %{͑S ,j`:S>Ԅ0bBmMdȆHobj$N#ehW}R}ϩGե'}%t1C[2 a&V䪾?,V,%2~%iqJX'΅HeRO`UmhhE~<ϋU{v]ķFf5\k@@ +#ͨdy"VS|= 6M⅕"mYTz5{4& l!ʿwxη i M I Un1-9PlXp{_ 0eIVVֽ}e$ [Idt=gА@h^ߐr bL2ېHpvUAUWb[өu원go{'e(FL&ϱr ,E7U,s=&Ԣn.[u9m R29x4$CA NĐʧ0nvTw{zz򓒣b΍M9d bjhj6eNbhИB0TOu"}>(-& B])LC{׽]O^]0=WX3@YŖ;}cрToVEK`赜D-BC8GJc`I`葴_56yeZr\]$8R+gl}/Q0k?<$1p٩iI}2>t1$y90Me+m˒2e`\FXs.8:3?wqI]nI;Z+q|Ǘ=m )b>;BrWLa2~9)u̝;B?3E"'' @!6"!ƍȷܿ?xӡ盝رcؔ.5C=dwdm@ QXiDU:AYj 9"4}EzhC̜ۆDŽcv^:\`̤J )5Xk#%ҐCnPn-faȚp*ɠa;w: RK[':kۓȆcdQoo ..W1vMZ[{򧓓ǭ[}e"P#O ȫS\'w:ӹ+H>Wk74sY#=5ȶcȕ; /AC(700 Jah0k;<87oVqJ|Ȳfm>cK5^\H9orw{l>utHHOOZ;yhAf",;:8U8߾G1Ms}2{"7HЏ]5*u% .^,Fd|`qs'$oo -BT:ԟ>q-jLɿo[PAڍ9==_q/Lĕ뻍\LǨ'mo\e"uB)E=ln 9bG`ag,{Asdrv ~H~-Vن%)֨+UzEʍW€`'h>sb)}ONyUȔ,CII ;^odue*(ta-tO6LI/\ cbW b`ffNŅ=x0]._wvtx&&j(/OJǃd55@[[{}3k(,^{Ts\ m=B|_r恆V$mwhbvjϽ)9N]%…x4 ?_|`[.mU P#nmO^+'nxЬ(&a4iss;[8%(U'͝ Zg׵=W#i#W峌=‹fLP#yt׬ Çïkkxe:] _ F$OLSƩݻ71 ot h(_aLzϸȻ_ ,۽#|i;ǘhq+ jTiQ|><ǐ q-`\C`4ZML} dHh\f*vkD ^֜AёP7Gd؄d](bPg\{c^E]Q!yD~ Rsr̟-I-PMzzfgkLSp-[/_W{{o?{ 9=5a<"<>BDo[V¸(Ѝ|֔* 9ۆ!̿o܍\&hUίE+^NdTXNdAr{f2#+΅obn~ ќ\<:k̩[ONxr5e dVk׃GB+:9ihyFh䄔{? ܼ9I2̎5wŤH60-ӵ0Otzr嗬`9*;y 3"tYL+ͬ[\Yi~ ƒWD p \5Wd$TچW_j|P'/1trtCsߒJOZ:P^X9iv9ETouV|tuuiȱ|PaAR1? U7P&,b@6`gϬ& يB)HEN7tnɞ D.q"S6FzH@L5#0; 0H=ս?=1d:j nG%]$t7n&~"(dP!p-rG\RxU0)z$Kpwt^:*H;RS+"DKZ<,c:,'>ߪ>޽OTF8mSmLCOjv[OYƜq%kvM}\ ES<>%UA4NKMD{%9j^ Aa( M@ W]mswG:vTM_/Wfa,ajZ?A H%iò`OΝ mX s,Rʐq% i軍Z1D D%&:%V2@__AsJ +*@roS,9k t Zۋbdg[{ƣ|, dg*q&zV9Py1P6~W`9\ٟdc3_  B$G$80PL֮=6r78$W:wuX|Tpڲ3f'3?P+3BgCń1TP·gm:E:3j0+BG-=^ PލhIS7X8צu_=0(Kn(;T*{ʃcpMRRO.Z+Ik1jd@)HBw@{?Y&+ znYbxiħ8P_A==2>g90%v3FQ ᥍MT?5FqOpŊtA@(W5܂XZ=6*ZZfշN)~^+߼/ͩU"Ծpn~k0=(RI\뽨l0铷+#.c6{|۠tR;{B\d }Da}\ʐ&qݫr$\pYD3plHޠ=r5hCن?ջ/-5o{ʯ LLV0Ots;tNnӧ+kذbeKJbjL5NG*; 5Sÿ:t9\L)l<@ ˏBmzK kY-Sd(WYql{ -TMjv5RL@;b>c>+o"mzo긺޵v破yyW5v1ATҷ?,DPxZI+jx/5m9H /n6xr۠L>(ovbp4Z7t`fd!zRfUs sl& N  C|;8CGGo# pB8x=a\tw+^L2IGEj,f)"%!CŭɥYJ|i-Sj.'So!K_0Wn>\a-;3OUw߀{_~Q*M\Z::VI"I}x/yh].!\R}xčVDL· GVD1PY<")FGutɳ4 ڨhA_,(!( Nۏʎ-m3I% =Wc#7SS*)14b @=X2C @Hb*ky%K tQwOo 1np.X+eAwjb@>Q Jhdm`j3E55]gZ7|m8CÚe_ Bw:;mF)iĜ2[/=IO-g4_xbvoIo`6H)ә;/4ʍHQ&b޸bC{ nݑ0RmRRcӆе$g5[lWFG \R m)jolbd˖{u^5O2/&x0]Dt!`m |=XtqzwyOK֟#ϊ9{k0PȈa]ټ>9irum  B(֌ѰglK:T2OZ( ֳjZ*>/` gc a2cc<ݷUen\qRMAḂbK<ǎ|UB(i:Z81[4z]6> rR%3'7tʩ@`Amr隼`^ͷ vmϖFs-Bq!L_{уP o dY'Gt,K.Ed]4~sX]TMXp27e5B fc4>C f\sx&`Qxjxi3MCXG57`AxyDc%L4. ;%vkf"*42u֛eГcJ)oœ &Q:⸸`)DEGc ?slӧ[_xuoD6Qy9"}(n0sww4pҶbD*(YtF%SdOvBrᾁR=ը6lx-.)*24u֢u>AT1M}{`lژWϩ-3΂KFPTz||y̴WnEfL 祐 ՐDh9XKעvM-Vw3-#3w~4he9@ale ǘغܵDžh*M~{ ͖c}!c%.JP%ÏL^ R˱n$U$i8=! eWF< #9RVb%W"8r!?jg!FN'?P5Ҡ0hY"&ˍQௌE,',(k~m[i34eL⠡ȣKU4 &;Ef ϔ:>{#+T}# *4ܰ9#0PgROУ/G=fGo|iab?԰ {܏~)sZ3ЎVj_Mrߢ> j9KHQ˷kN]!'baЇ jEu'ۇ:萀g.P0idapNUx-!jpP2ţ~J1:ao,k 7j-YռtuLhQt}d8{sZfMG0)* ixL&QgNj℄g$uRl E7ͿܮD%IlsU#AdYZg> +Me1viT\.9n1 l/5r)cKqA<6}4ЃX8eȋ.T:{BQt{gWcǫ jYR6<+:PC<poP7wI]s X쭽VK0e2!\}(ܿso/fg- 'zNĉ({=>07qb_/`..N M٢5%:-Qdނ" rıE~Lt S`P 9 4u3sL=a6I =]9M̂@f2ņ>y+[&^ -om1MxWe@2yâS)n/ Ho.x |3~B" z= }_-t/֎^<$u'B%byDaK3 ɩwLǪ`j54MXg6<˱fY'w x#yI5'1g՜-'V]4q?Je􍡃g7|S[mٛz |q>%+͘d9h)y):*<6ٯ^݅g3~ ?7I~Oŭ4s)˗3{Be-a ]]U7,5Y.ȅec!a50%6u8j7 A1ۂK+g P%Dܚof&ۥ`bC[]Ҡމ?Z>t լoV]r[]$v8&6ںy>̩h\82cҤ3_8rKs Oy ߣsrORGrmLCTxğ]rv{x>d>!.бYY̅ޱq\ǰga_ GR5㠆aHqc󅛛rUbg}zЎ1ʎETTX}?☊?qww1!)nmVf"T  vn&:"$|Wi] *e|쌠P1-(iPiq& Lؐѥ @"o K]z uG5+B.hjI1wS(`x驣'TȭATQ;2;G]4H@_g6# t>zcd )PÄfۍ+G/b\كS 8JňzPx 8b&H7qYh.AEPKOj`Flhg 1X[[0Dh3'7q=}Ieq|M?8RW?BL1 \'qYΆv^\[uNl:{?Ďl#k"4hrF7>*AYl\n J۳vLM箃4^q o,Xvk#{<.r&CZFnY,rCsX6PB^Q؏?6Rd0MCGG+tooiiԔ :C+6im-@7=[r6=%>y;]̀ĖXyƒ٥A3:_|VUfܝ@Yx%J-N"7k/];QY2WR}4N$^HsPy٪haEg%t(j Z8ehA *io֖ >/H~qڝԾcZ4{[ i{6=%&!-6i}' N)R)ʇiq빋 m|ydL|R" v)oQN7/.l&yƱQ,,UB!;%6))8IRJ 199ׯbE ½|iAW[AE ήN1o߷} h¸sO"]D!uL" K">dOmᦼ؀eRWe!"'gdemcX @srI74}x.ҥ0Ϊ-mUMO@sqPE k?Yl»:Ie;:}Wq;1v)3 [cE!ݖCo YS:=u-aJSdf)f0o;L3 DGꐌ%>tUa干c.NzңG9q h @a=>>t;wfK _XϷ56tw@[: LI{Gπ*h>#Zf|j9d<;z& PO- !lMgλm~5ΤS8PBUC-!X@ɮJë.նzE`hhoTh+ ,\6U[gs&?P~ 㝋]yZX,{&23x~<X̅5Ѵ(0IB>2%8蒭G`Sg7ɀ9st۬RDPlՠڅe֝088#NĴ5cDH1F0 "㶳$4,Y7'VƮsʚ1%aa~p復ŃJXDo`E&p{8De>F `YZ'ut-ujG6<%]06\Bqŧ]^~qͻpk6tu+d'N4;0 e<1(Z:m -Z >}Rj kk}%)jZ"@mHT+mLx֎,D|Ĭcƨxϡh9d` Mѭx?sI?yiX~y;g0,ehL83{j ]dZYXWPV]3<(h[ڧa1(2]}ݝW0X9j- oeהO7Uu̾ÂfBWFc8e=??jjҨT[4ĝ:%*wv!ŸRblǬYj f9:f{eFZ]\ ID5^#Ëd~uWEAj/L.&,I\fVG4R'lwV3)w >ܬ~$"s>" cwȰ3T'tЙLa& 1⾛[3cmG$H/48^$p| 4G la]젦M?DBQ'1Eqb&p옱GoN]R獇O.ki@O)x5:;\̂[g#s*rӼ_Vcbh3 }'ֈWMv1'7K–bcm$& VC$lyYYG%IebZfU W v!j&--plPdr$0Yfuê`cj|py&{z'qS:ǽ eO 0C? fr/io kpxY!56N%mAqu]#, S Yyz{E29qqQ$""[dy}ͭt-6\M"s.^l zojjE ξWvD݅.J+d1x<28.gj\ BWC4{On qWYǕzU3}LQ60Ϫ42SwlIכӏO;m`oD1ȕ8څd8 o+ߡ x~G|%6?jl^鏲41Ѫ\R%Y+ C֤NibyN1]7n! f•녓 NqhjFf$+ğ /5> I:FI]Zw'9cQ>\ ?sE' ܴ<oԫHl":p55-ͳDY$hh Y2+Ţ^SR>Zꕲ[Ânpà{ũ;q/s }{hۙ}Ҝ,دj VDrl{/稖1lZ|rZ2, /G0ixu8lߏȾo0N-qXhbzTʼn5?pwj֓}A͗_X߫jL]*PRL7'm 0~::=X,&MՕWZZB:손YY 菹8%-78 w!HfNf0SK!x*;;{X?C3TG#)xyRghh;[JW`~bsgZmh6fCb'/7ȑH͘ndY^u3/r0Ptn11"+ZrɥxȭTY bLRIxteE|ϪǑmFGg]bNo>8BP9{7Z#gD}+;\w`^2( ݻ6EF)PU6<#:#u~ȼIʏ +hr.i5|c7k[צBo$!1J]T/Wi湦VPrGFʼnk,7''Zl3uͶiEBOav}]'C讫H]C hfnZ>kL xfDgvu48߸ѭ3}t4U%>6(.gj>nkV&RG_[|L]OJkB@-B#=(^' /9 K[L%$qLgZ eXWzj1Uf T,-Laɠ&S@3-T *mzZW/-M~!AHW @!LpWe5\'h"1ذL*j:nC/7;xqڔljM9*\8V:{`ݝCB+*Z'&*k`>Fچ{)3n!߼Y>]cWߍ6Һm1jsjjL R>s*v#F_ibS^Vٔ+ʠg¶DI<50Jum"&z/`~04{, mޠY ;vd׿q`?RvqU+Y1w #r7ۍ2GgFrʝ[(~e&VWA7}|,_qi7R+ϮB18"&釦Ts+M SJh_1'&kMB3"˛s@+P$з*E^Z,K#>7<'ѡ .ozK\M@21v) oK5rɪ}Fw|}'4<舦aj.Qm+ J$F܂`}c.P{PROlDܓyg,uz]W['V ,"G yp!P{[9PC>ȌE<#Ѱ|7tKjEE\j(R7/Hڵހ@mCg/l`u; *>`qu8Qg iQ[pWu_LV0)64H\п?Z o\ӗ7B8 E47Wͻv9OZb zYSIDpR2ӗT)NٵJnwM*N/%&sB %KJ`0= !<Ŗ =!a@3krePa0jd{u64B;>GOĞ(eVy}c8QS{Puͭ{&t^HVQb%09u-!)s_ʖ8d^t)81՞g SCg;]J *=|*\?=WY842:0bplj'g/!رz5x/"+H̊%p 70 Ts WtX(R5L}I'%%\j m:1Zp|uYlOH)Zed.'՗w/+SU@0 GiwJgOnhFG{G!$^`OM`:᧾eLvOXObdr|7aBh* <ű!xMB ;wo_w/鰎%UIduA~M "p{?$uS`B%`NGTuAԄ0 hHD=FR\u]PSnjn)ob!oz9b]Xu"1 GL9]:wK׀c|90Ayc cXpG%ކK(AwW@$1<#,lb}}BteDr XL%qSzz8xqi/(kAI4rDP%saYILvh -Yd[׆TxDS( 6 YK7{/ήC7 *1aALWAܘ))A^8(ՙ34vPyϕ'^rY«Y!IJH[{Ԡ*s .!ǰ1I>4a2؁H'y+qb&9EW+( sw01)mqq*vZb 2]")(L}c?F9!qK0Lk؂nZw{J#aw=:Im_4ѱ#K- qHGLLDvU5 )*&ubZɷ E$u͓ HNVڭ - NYH)}2&,cZ6+`8qVՋ2lHIg9*79}3A},-s\LCW}R j`%7 1C y6\BdҝS_VMMup K7%n7Qޠ 1q)\SJj-ĥ~fTƧU\wQ8`bp4U6^obhmm7>YwqޥMM t` 9.y}i1w"4H #eo%{AyHۼopL` OM,aɟUȍ/,_"={f,T_oz/| pLuŧϴ/4llqdQSK`6/)44+5\̘--K[촰ZS]̝= 4 >8lgbx$Z@;*}#zI,ڌ۬[hCCM-iheף &J$[ReDz~95})gU,_>Y/0!~iW2zBcS(!$7݃[B#"嘒4W$5x䱿MxgX+|q3R79%.4;U=NG$"* -=NAe|X|Oψ'jೳ^r7=3Y]Z /ɦy +n-{dvs%+IfVV昣ַ>>y~5dֱJ &S%;LYs4̀+Lpe׎=nd[]!d: p/ z,3ytGmZ r'>e3 Y2*ӮF^iNמizPkd\!78&!ou=R+ X]"qm]8Qb0t`'WZB6129.4#Si"!WLgYtvJ={*<01Q7@#΋%r,?]k}-0}t> {TE], vT W \1GpD.]reJ&DZĆPH^,daĤh)utdh;'D TrtiZPB_ډ8-o+7X³CC?P?+ZX?Ҝ̿i9rïRkAR̺B9,-ĖM͙CQ.v@n![]ԑg0HգkDg`P_2MegZzE2);bY p+*-Wn%#h81 `}s?kx/V=rC_=]]P_W6;ʚch#o?2r\9`֠#>_jÁZhۿՇI] =.xj(cp"۫ch%Jܢ2b )' 6yg5-ZpE! GJ]= oTѕpd\f /9:+oƐ3tUSaoze'^JNZYzcR![Y)yz|+p≜:yѤ-/{g4M8s`=FjlLCu h7ji-ut"k,)9?K^L/ر*G pai3&c!0);04>l?PF QVN1[dqx V{zS"ǎױ8zzb :X<ܧ/Xǿ- 'ڍ^/{s_ٺ j 5KxdͱqA0ǝ'lWMH^F :#23t}@_<}lm4C9<0ΜDM탚Vh4SYPIgF)< k$±_q@>G,$oO'߈ nqWb$:Cj:#ZcFWZTZAg2Nq7P*j\9 (`]lPI f" tվ`Jjۇ\jv9S LLPL~W((19ST+}eyXdĽle22Z`bLl93]CR=4` 3@M|P%*9sw\ nISmF9 yN~ _HHf,,?-B(7n:_z7)) {L% vYZnvr+o,ώ"o'W̳L(.>Ժ%ca9zJRIƌҝݧmr@bhnDI"Kg*=B7 iRZ 1җF!Rz"p?p\AC%H஥}-my0K& _|q6X8hY kǠ5w骳Jl#:Q?>M ]r8ޔTcFTu^m;q֝!,̏ZԫyuyQ`[\#Kcx$uXٸ׆L#{f`ݽD9+|6;;Ag wڛo}X n{尖QQjɜكDOrKbX/0Yt`zzVR$.op_k,4ass5zs6 0N^r *qx0!aeigδ"^ii~Gaf<>\91QFTT${:g"gc_B\]>n%ܐtL cRn1D,TuĎYp6zgz'6 ˔ahm'.7oX7-k!L F;M nE՝wnmvyMC,@K;аt%և @sOSwLY3TDv;>?\(/O8u%OfWCsF=tBmr(Ȩ0v<Qz޸v#Civ́9 Td::5//o߾p\kqq reݺrŗbzH"8&$3A=ij hXn>hӅdޢ}ݼ54}RoWG6A ^lӈg.y 0UgC`e}r|ri@ijj땘"ѩȮk]%7{lmGN6dmmKhFzһ=a\P9Rt(WwX೤OuZϯB8r)4ʧD#Z:0xNL(Ƈͮ-5\T28{49>O-ߢބ~ंe:իNY}>( S[Bۮ#,b! G˧vwdhV62bgڻa3:c drt+>ffIψtq2}w&:kI|cϷ5c.GDE)͋Dہm_ &|q?!w&..`iicmt\Wj95kƾ92sJd7l(z)7c_1ܽ | _|dŭy9s]Wb:: ]\=,\"F X#b"F4Tghj\U cfH”%%^q6k-dկ gFZĞ#/:&&j "s frm!oD\_|~H|+I=4'YÐؗ(ũkGta |`G1۳!, ^"߰>|b5u :0*^")kHYĶa}mIMVs(>u@『cl1U Z"Վ !BC:-x;4Dߊ㬰8#c$m3dbAM@IsO`Au)gw]` maP5uC\ zzo cX ~#8+Ř/lbAΫ zh0(8`q2-wjwRR8H 27sDӖ%m C,j{ >(&I0qt]L]\L<#6k[O/I(O}|̈́UU=KrhRq}R\؁O= :Z alSB>`DvO)KGW̚Ejz6$  Ѻ` dlVu@o]̧]|N,ː%F' 5Q=fU0s\>4u:X Zo濵W|h/|4+M{ơm|׸exϘX^ژubDnskg0挝4uBy-DXcS և5z왭V 7t G SH-<4`ګh WwD-U!Ix#zP= Z{VCDwbŽN x-@jα jH,-o;m_ 53P3SJ}XKaS非I]$%i[FdPG{-WV [vv\[[`^ ksRXVL_, AƱHzhJna壯OQ4t %CM݆fdŃ(=̱~[eʹdܓM>8^k[AuÖkM/0P $2 |D0Gl:$Ѐ8{vxN@Ņަ<4lW$Ą URSs^TT TUNtr1yZA̜(psd Qb.ZbmǠTS]h8S1JTu8Lk $% 渢USQes(?#{?`'B9&6<V"tBѰNJD Җ.:XݬyO_£]FI(𧻓р$]},38a)-I] ,sdvbӄ*c4UX"άr9t.IrHϙ8KOc1R7z>`,8}oPAfj%ޗI=K6*7oq-vh\EXh_EN%.WRIĜ\#]\T_.O:;gj ȦV;߼`[[m{JU#P?C' >m:C iD(̝;}쳞`@"bX@ё>_<@Bqs5'G(xMgLTelva9ϝ);㑥n3J~}Yqq{qvv,YWWgEFRPXV?D$O/oEZRRV|$R2~!`=,AxM)Œq[s u a闌VxS"78 q=$[Qy>)vxqD}/V}]9X`bB>I6 oKLZdk`tx`Sgʆ2 tw 0'$P?NX6z{o oddY;N=HGFOO4?_olb=ۊkxff'`j u ccHæ%`cjfY^^Jvv,43;}1CԟK30wVd?'sY\/, 6LO#:ޝ< ]; W2/y#\4cdzᾅOX=(s*r4Ѷ1}ڮ'({kk#e`߁u܋뤦5-S"ҳo>`h"Y,JIr2l9讆 `LV죣aôLŔgWQꜱ"q"EnD?KZ~uLm>{$[Ϝ@* )bXB,TqJחEkmnzoP`iDktkˍťW"cưW@G'@mh_\in  3K"[etcOs DZ }dA1r>Lg`? !Z"Qcf R[#,{!!ŵrw1\n j{,#Kٓ7 Zk:2$ٍoP(A޾/cw$rGw`SVd|Cc/ҾǎIV1!t6 fб(+K";;g9`yD ٌcs{z ).N 9=f>3ٯ|5+wԍY~ނmT>}ٲz3kQbD ֐3G>Xpq d.P/ȩ}Hdzڭf'S.ǝ Pd$ (ŠtUΦ ϺA\>gz>*ZT|h_@o?q9ty%vAv|E}Ѕ Ѥf>v1wמ<ϳs ðޒu9RlGg`8Mb ^؎E4Qm-X]m{ ֢1T$fF |W0T*v*-O;k@v:''b|30љ퀟JP ;1kzAB]^lcpyV[QRoꡁrD\d sd#o50\XcK||Ph Ccǵ~l=Qw)☩* ( ؈ */0*AhM>{hȣ>eu#v5hOzf!GBv_ CCW>BBB=<¢ct&*$&(sVt;bEg%vF6U5lP 0g϶FYK[0-SSp:++0ĸpk旊_,ԑ!vI}4l11+ v]XL몥3 61f[Gΐ 73Bٚ%اi`_b_Gf#*]I }!C u&ʥ3m~.l|ePD!5Ǎ|j{+ t52gIlY=3!  $<[$PrK) *Yz!'v=A0"ss{=b|~ LAFΒKZ""C54Eu huu'VDFfVwJS%CK/$-J RdMjZ_Om3Pp L F᭣s lT2H)V~:r)-K}OOUBpΎ`֫d, r(_܉V )(-e~'5}ʷ3*']e|@k>J6{k/8f]::L\54R[a0:; O\|X!E( /V|MbB'B}dP)]::VLLr\;Q՟wo˃41w>tJ.i%LXw2*NI-}+l lcsgh JNZz$r{g&0%_S2BOBJ 4ў%DJP8txÏ!am͝"Ph +|e`6j~|c֙X8,bqLH x$O ir%5 ӿzMUa;@:+k;Dܙ.C^]SA0G@ɱ=#&$DaF:ӂN7`ip%ڤ.av8ƼظHKk'5( ZdzjirɆ!3dRX6v%aڥnzDs ,dd>z4 KKX 1c| q建 L &.ƍ]] `z8tL_*w)b'^5@gug66/F 1OJHBwTHWʟdV!:^%ۤ>EpwP9.<zS-A?bzLC.IpBa<A ;asKJG<8k+A~?SH] [[{{g5)>?dt/TnTvҩ2e\G kn#` *0a9.^گeYql3*.v"HȚw K:"%F]P Z-,mm0 L&Ǿ,Q@"@Jac !*ss#—Ip'mJ.<$"%qd8"_Z41lXً_ Hc [>iav8novo ⵶v>}hsvv^^E~.>mxJ m54P'.FRQ,1wwM2?h"M,wk'KkƩrBS)85k/<8d_L[ȚeCtLR fB< ʪ && 0JP$!C&W15ȗ0GН''ёۇ?_̦o+..ⰦqtCEIWEtɚE7WKkG]yp$T)x#=WpQ3 dZ^W{< @wPuNn 0i((VdxP׺U:Ν]RPA:KOcK٭hT,u9pb>Y5BhD@]LU/0 tjkz&{&Y>Ft< 0v?"Rsxxxφx"?ZO@OH_b.2i ¼@-xe e]UU6FRR9*I/Ij9qI0rtf"K(\Gnxsd["uo1R~= ֫=pswSŋȃإ˝33uNk''OAa+ Ɏi8L v٪OкGEH>]i]Wߒ,L7p~ 0cԴxkk&iF&bP# x"%eMx4zxyIM*6ff|$¯ˋ ._eK|so5 Uw N"#,zF*Iw |!u)\䥫'H"j0A%l'X"V|(QC$Xg'6{kwTRFzBQjH%$fNGQ8ץVK0adݮ5f4qM ?B9Y`l,.ON3 f`@ܸ>xqv$֙>՗Tf_as̏7uQ```u {8SC3<31֥FP{{i4IHh0x.;@'7(Bl*6ujˇ&͎g/[ ,$~i15B˻VL@c䖋W AmiWBw4~{JoZf_y/}PrM,&'_}&4pWկv㎬CSȓMW#ޭWc~'~ڮ?r): wS'N^!LxdpssVZz|C%tCܿ?YVZJUARȷlgmddX|/w{F̼#fU%n /+ߌex.Trm]HmA4H#4l5^'⩺g?8j="9Et,hf!ܢ(*Ԟ82ͪA^0VI>Rm38::Q}qI*&_%>0ȿ5XlsCc{P+\\'!?s%^76_x09ܧGgf;/yD1<+h$t*jh3㚖MO|A:(w]/SsY.Z#h:{"f9&G!{0pWQaY[;emzb'<" *fJBRLlBzu#"" ˸4FXb7~n*94`12[QP;nV+ ޘDc5GǍnG"Nbb^iE(_уvt1?E@Q!Mrw4yY] G[c-dȴ` :]znnt+u۳xȽv2^2b Uh1 >hq) }wt@68U .x~BƵۏݮi+W{'#HkCj<à~Zgjl D]N|/=ZWYf^e"<#mY6s2rʎkԍ&?Z.4h~6XmXrXXKW;"B9LՐzs qgpuu;Hʥ7 %U13ď3-kX&j$go0 ;M7s^+;x*uR|/!! lX_XXTjGѢP_ĭ`ЊwDlbnOuy=y&w\÷ n}vݒh5ٜ.r8* h+gA Ĵ5\.@b߿!mom,D7x[!8y\p:,gd]GߥGjE`v%j3PNOS9Ɗ̞b8. 1#τ̾"k-K-b ]=<<r; V/uXzr 4@vuV,%l;56=e~EzNxt`ӉYPu9w3ne7f2֏/^~cu#}\V7wğjk%y"mdR[[Nt`"A-Gz+.OhJ9qQn~ kt:g=5M_'H 8A&- lЈl X늊SY,S_?7ñA&n &E0Kܩ@l)8'~4+_W{R}"^GL6~ԉ+g1ycF/3 WN}h4jrK|;C L>l7'UC"qپz:hj^8:] r 1`-bt›iR9.fV塽?utjw"'4Y7ۇ13Uڄf~4`{ri B>Рl4}Èn)I0P:pm-"4u~\Bs4ޱUd` dr m ܑѴꮰNb4( t27^aD o< >s F~irg96L6ѡw~82hpKy 52<FJjdzz 6W w | 54 |5445e=p),Z l14TT]ibb:k:`f$XzFbQ|zqM3 j"AVԏC#" !q )O:=Di7,SCh!K iu>S(Z@f)Ok g ӾOr!u,7* i0Z! CP6ѳixa=ӈ$+i]J Y&܂:{ =R ڽ8 FлN(̩hWzhr7;/-ǎ7/8 Q[EQAl69U# or60g곳cL X|u3)|Ԗ[w4))AM;6, 1(t[:j=S,̀';K>#?2 s"lωe+ls}`LTL E[s33C #  eQaY_ hݷv\>n($)WyǠ7 6 ~5YGU & 1[u򆛝bz5S*u"d&'~e=ωdC2VBuh46N3pŦj3qVѡ/ G#]PE!˜w>~$<>$_^"?Ы:n_(<+IB'hBw!.Btnu=Lu`ZŁgS>x=ޜt4)*Ih5Vg7.~ lr:=pج[9QA7 Fco^jx8á?Y+t0JO]6FߧC;f$A#]1~uK.#6>L"tt%q!6cFJ07WZ9":FFt#TE؁eRf0 1P Uť)e]#=bG7垛=T4#@kl*?XoYaa*. ~jgNN$ggUMLV},Ԭ_6ŒmHM@b왖89Y5`ūQ;vr0`?xW^e"^>@x7̩iMkL^뼱!L|#[/kԏ9ݝ{ T"##0h4)$ t'İM͹h|Aɕf&[؈/ᙖ==p r16tGCq}D1M?Ӆ4&8{hfs"qB(ŅPg DFꨦzgh4ۻ,QQ*wc0ޏCWT>|Qf69*ܺ<,ЈGLpA'_ՙkh[gfy8ORJǗÄm}c9T@ mBH v+ >IHtO::AiB $R%fKo~p5[>lL.\Ex#wr L,gRlt%NM!遐P?mC+&2@ ckW6^8xhS) 5U]-Cbtٵđኝ?r*=CdMg9'%)`yr[ )SK2 ݗ?Tws̸hxW4QZlO{9l wc=QP~ڟ`Yz 'CbӘкܸp4xF(ڃjk.b#e0̌ Fb,\`6]/NN =O:z${19[.Q8ͳ s!A.%[]d~@ VW+UEWL(%o<+<`5ޯ~%Bt܇d=X# ڄfz@>&^1>)қZ Ž-(>8jԅkYAs3ADβѩ5# nic bysFaQ Byi%LFit|\TPYi@6&j\1bds뮬uXe%C38s%g`*2fPگ`vL|mfاcQ5f@?57 _>. bs"+%%\3lm]UD˭lݱ 6L$ /z&{MWjx{'Z~yprшvȗg׏ ^}wdy+f{a>6Pl1JbrD/>th\xF/{m}νHT. ZQ[?}P(.̇Qg>.vzxw6xϴ1K,XEHb~<*"1rE.yڷF6<#3 R_:}}=sSKj7p !i)_;܉Ȝp/`CWTq"2/+5Z^gw)X:2RIొU“:ؘcϯ:,J\.?p3#Pxn.;$̨$Z͈#ˬ7X~yb'Og,D4.كOemSTi(_,E(p 1}?ivL޻ٓfY$K(T0;VEhFü<rj\4 s-R$a[U  KOfY-9ЃAɄ(0D5 k졐4x|%cXw/ 2޺"LUL,] ͤ#"D$ 5U|!!`G7%_`A7vH&PߡJy NIeaϚ%b cs !!bݷYMkjNQLVZ#tg8c}̡xz,UeʋC1@)|"֤Ѐ?}LXDαcs m+/0 Xk!g9n5 f. ZRL@Aw>#3>TGL'vO 2 73Mn郶Чp|N),"%/YļbʤSIBCS:T⑂0ʹ( fqXCOYM %"NE3MWDa (,/$UAOh]?= A^O}O"n>IA#XXfWrek{,m:67OO@e͏>, g2&ާ|IR>;?k7obO of2$8^>c2.9xעRʇSYfG)2=Rt^"Gtl$(8Cn~m[ZA;Co9:3> RPBM)32_ScZmp%m9hϩSy4|-p?nж^iMP<\V=0~ҫ0| 0GIiWRF7&SDÊE@JQ-2{-#ZzYF71YͮbH0Z,m訳mQʦ"[I:/ EHz$0D[ӾYOlô3NYՠ](H+qhќDgyW $R^޹f <;SAp"6/ 0aUx$oHm&Wu[r~n=U34\[%SyI Gx@Rژ+TJC_%S"w?d`?ZEze瀞.*CМRZ꛾y`#%v-%NIWѣF"P 2԰I`lDY{N}e HCC.ose>2qvί"br5FWFky$.Zd/=yOڏ 翅"X!ky=@dkN+nk7poT/:{8ǕFVw5 >+ Jq͉"m 4!ЧmD"vaR?˿zۋ`Cof=μHmeZ,<5Vwn u!T3zҠ @G")% ئcrGST< 4 &)40R{1c#-Ҫe߾\YxkʹN|_g.ޕ˚ߞ;gӥ f:W[ޗ_vC2 y즞<@ְ} VVN;ֆvn6x`i=Z5|՛o(*IsQ[fA!(L/:,=7X:>$=]జxEP^ J>DM!hb- s`'asFbVH?7}9 9372  F8bX⭆SxTwLhԃmRe01tJv iVgcKy'd|t_Y~3l'7`€P|iK9Xe,q }Gl{z) I9A[q;$]QfN,/AF3x%CCC(k hdowvmjx͉3#_@8u<n4 Q񬾜.X(et 25@{23 7QQDw+.^S$̂7:i.oŐ^)f8MpPFVٵKQ(; +xP~lYN4J(5LÅb eDI@:-.MƔrRXٲOS8xùK\'{5E5Ļ*_񍠄 `ݓ{sgdJBag&)o!q4P(ҠRL$Kv\EQ# >4vPBsrY#Rϛ(PzAW0++I9&6 _œl]{VFsEU/%P}J'wȐBb!J|BR_MGA-+וގ[ZvD{mOԴ$^Ɉ <{|TvJomeBZ|x 8(ځx;Vׁd8ڑ{Sڞ'C[.u޸' <5=2H7g g3Ӏ&tWUod'" gΎcWoI쐦 bq[T!^Wq_D -ij@j\XPwR.~Up>jO^:@Oh\PǟvEQYᢸ0lV*2]qgJXLh]4΂&&ⰵ$ޣ+XRoV[/y cz ]_rgR)It7q&ŧm+8cj-Kͭ<èԢO!٣IS3~Q郁mn:{t LrH%wUNc2\䴞qcdՃl_N,/ȋ_fydY3 HS&ku-? B>]SfbdUY@Nll8rUTyL[S؆>X?M/4P:he?z]ZYJeې oFDj])5CG0%pҺcBMɶ:HnfJ*GVj،G3V_ļcd:,p PQQD4B `ʝҤ2M$AI"nP7&&}ulڇ 5tDB WܩIgRz$0DED}y~f:M@†27C; trzf`&UtL{oL}}VLt@r& 炕=!҃S.EljjRm ?~|[j]?t28cr% t(tQm? xk$uo؄Rw5a5}.U}rZlH$?C\Qv܌_wXMuR= )@-UvԱrrrAvN=FgɷK7i,u ٳ 3ϺW]I}k@I"%>b+@#X)/1=hYw <-}y].k(ʥ-7'7;F9Izbw[_.$H#P)% oҍ]rX빧c"!PM ȝLL],sؽ~KY?\0 #I0GYYFV4ARO>YiL-Roq>PA# T$ Т?Į6c|ZX4[`n\m.El0-zR.DA0bdT܊C"-W`U s%R|R3ɍOJJ²l(IC|6-Rm/+P ٻvF]nP #dۼ yzWWC{GmJsJ(E\kK1v_\jz)-]ΨRdFм|wٽ2|3u@z£Reŕ!QqZ揄[GȊ:c/<նW޻Gm_v bv78]"^jIءH39ɰ LsW <ɀek|:(o%5'iCbH#a"q ]> ޓN^g񨧠IR3uP$x̌ z͎wqᣁ2̓|JMg벝!ߋ쥡+#&K)lGJcTaYoy'<HUW`U >ʡ"+;C°4b@ؘ0,BI+J)s`?ɿm]$4T/ LabzBC$g;\|םYZLmIM\]MI0\U4o#Y@TD;O6c#ظ苫=JЏ=1.'B;^ q=n~Kxf9in>oF8qiPՄNhsO;=mwDsCMN |/M!0+R}.ǥ;zvܹFb\*͕tWT蟚*;[ihpuN1t6\A/z6TSUDz5>zlF|ysqNzT6B(807^i4qkl|QL Q!!g+;U{voY~bJ[߄fBPc@'\8Ek+(Z4O;tHHmn,~J?ޫr:⌢ ؞eHi-^oM x\9iG䌚L-(ˍ>1~|hLপ8}֜>$_IګrN`^|)sdMV׏~? |oQ(G0N-17hG]]E*M{{o~c$FF&$8MM)澾t@[Ww|[@#l?|ݐK}R\&zđ:qɉok>t]@󍞞Ҏ$__4//)N5*'F?^Z] ꢜL1yzΎ02"{-aAAg8:_8#$pGEF t| yZ1\0v؅=ɱ݆cYd1K"X4,]AELΜᩣ`I2_cocXkX̢B5 tʳis7/z?{wD$m&xT_Eڍv湧z \/S0H:Uu@Xqr쌤5s׮^66 l z.8B&ƫ@[Zw)np0 HD_]}%@~MM54D0mm MM?n킃̓-rr|0s>wQ?0a|}:@ǯԍTa}CG fc'-LئW*'4x;I`F8"4Pㅕ(Ur .l4clŀT#ۆS/DICd]JEX] H2OiGm;g PjsN] M_{&ji>fsm.{v qԅޥ։%9>ړRZ:jùYGyR7|@U1cC'$s*hqр$he9% 1=Fס~zఐ+t]j[c 1.0! C>VGm )!g"*Oь5MrboS dKgN:yp߆G@P|lOqUb 7rS0ur-Q}>îCDO (68TK6uOd55Y>n7o}K@) `-C\Y37v? 30$sƽWdV_GgF&/G^cru=y*{z֞&jrw'ugo;I%4wL` 5CUǬ63]oFCc%&wX,u8Ҷв6CluvNwr;+ѥT͟QQ)z7;=αD|'# +#,w\)蟒'hE]yLͧV:|Y쬡iy  %&v1xDžٳb˖]SW@ۑ!!&Хڔ@K<ͼ+/C-7~pذvܲzD%wq^޲\oeD&m`0 =<<\U5P\oL3 |HT7t(VvNe.=+`ZN'os$b]j)H!t}GeS4*faˣñ/`P&A9l.s ˄BB9t r&kpƐTB _ 4؂`"*h.D<8۽(ȡo(B5~vGcJ?(j)25(I"  ,ƕ ! ieK,~ӍBg>HK=himeECҼSJnU Hh$Ŝ`RaL K瑑WAjЙ+ eOd/1_`QX&vNjddAcDH8 &Տ Mev~ŧϴ l3ܲ<v/_ql{fv*>sV~YF`n-Yy@E(5M͈;ua@`<*ks'O[RٻF hi_'y| afˬ =E;qA.d5~ѱs8,fꎊ3:AZûCu3v|dU|Qv?㘪k,ݹY~14!6LU1 nȚ.ׄ~sUY'uɇ)vÙ6ˏ*&8OBK<즭vˬD \H81W&Cލ&a [6|"ظ86ϢӳnV-4ƪN%!B"7S%dks\iY֋WL嵵nPϾ?s:RtX  mۄ܆{r2Djy(o>&B5rptwssJLZ@߼ۇ:.yܳ W]܎n˙qq &|Ԉ8CڮB udd? 6?9kL6I( /i00]&fߎ+IK(^KpyPuol]YAi'> !W)>ZsnOww 8]ewOӗ-|;^,S=p 3W| E=l1)H^% 2W"b" 0X6FgIC ?$-M~:'jB,E,+$Wz8noC*2e[߾uu (+peD>g"5[V R$НzQTUV^iЉxVnZeL"'pUL%u IJ΂XV8RNU/'؅I5gWSPLZ.\oDUB}?G=X݌;됢;7l8ix#kx w`ΪŰ )\P<>ߜ:ϴ ʒpV$hбhqCi);?gR.[?rfb\6/o%Y12ǎʽ*0cI! N&N-G̢}]xا-[0AF::8fo`z}9}ǽ>u(D!H϶u]0К7w  8zB)MMs Mӣzk'W ~{H %™ ;؞%,u >c$曷/^}L`~اB#?O QG%?'…m0Fjهd5K\Թ L2 A2'U̢LFOֵC,}I >eTյ1fM*B`|tommwTgM?=_z̙&UM 09lʖh3])]T,cf;.e*tψ8ֹSJ[hQ󜸀}15\^Ȏ+׬rMҍ}Py goo IBVa3ǵ^J0QʠҘ&F4FboL|R$u@P1w^$@*ʬ|FIYLJM]4XR1q Y"ԐJd﫺MjDRLRG*I4mvX>+:Ϟ()M46sҠىj;I}HUvpEqPG j@*jF/>%ތM-‹/TcVN;O4MTd]kpiHD,b%Y[`68 3K$nX__zKb;8&p곙 g.*Za CRo*x.fF70T GgO KxǨbCf-3oz$M8DM4k bp峲|8xj448MsY57+߲δc'dFxHIo6/8HOҮN撵Ȅfw$R҄4 SfR#71 cQ̜3;Ě^c99"b.*l&shB&"fi4#۽cFtI[6 Ľ[_YkP&tqP[K>@է# f,)ju[ܾ343[;SJ @OPk̽;cb_g-\ # \ßt<`xp?2GL{N-ߺ7*cdE1Uq *.A{{"Q~Gb( Ax$^BB," 6onC' x7{Mt!NNv>TnR?3JY8&UعX{üH='N']K2Jyjx fv/ []KR0O#Gѥ:9usTn8Uz"%ޣfu#RpP! #{fZl&<cI44Q}z=b'=X}n+&O%.B-n =hf44Y1ie9*L]4gB|15S"_IlWB)4^8m@bNeΛ"IfM[x'A#\tIg 'gd13Hk-2\猠f p*GINm7MֻߧyqWA秶`[ ";lr2Ohǡx@yMSS[i'0we*̀SR3脀+WHИ||, :_pX@ f ͳ ˕z&vCV1ϯ[|RI YI%#~CCDRZ  C),-qQUR7jd~57$4@bb_t)YkvBc]wkt' 4^x@$Nލ龢r|"gM{/3Z5|Uc6CSf?3~fBיB768|'ƗBOnrdu_.]W!䓍l0@j, ]81Ln>@ӆv ٥O4`lCi7̾}[„.1X< XA[ v5:Ds?29S@a N+]澕,T{-RuȼVaRLI ga}k.cH-8W+L'w {ʳU[ qMR 3bIiO.Y34۸ -:tQGn P(&XHIWF5qF v<4J!Y[O]uo2KD EEEP& KQ8(FCd7fZ#OED@rZMg/!1aɱ:E"F c@x,rgY˕'olٳw~͝/@c.mlGru?\.~aSdTU ў#g:` 7 B/ ((n1h@-D^Gǘ0[[4'D!c. klRj|jSXƒ^QʶaFs Os2"%ROBoԦ[m;Y"Ue:*̉7Zt'𘘦wA:BEq_du6,je /gWk$s76t<'7+ri*g8Dy fm|/HNѸHQZqчpFO⃛Bo6iG Uαgϔ;wtgux>}9,N%u9$ؙ8yDk1d> MC Zg:0utj{2ݒJ\ˋ].u8<2R_VYQTyFUd7}_WX-H\-c#= T//O>E9Ԡa#1]S6g!ݿvL"F$J2~)/MO@be@` 0 PFyN|D>+,cHa<@ % PAs ^ b1HL3vO9ui12STSt9ى);ق&hxwJ-Jq /щD>#s>}ˎڐ0 !Tc!&,avΙ%hQl1} j <4٢OWo ͆!LBPjWؖPm8N}w 86j1ԛ ,Е{c`QwLﭻUNq-CoB}i.cAY!kջ -8&p:!|?d=냠lasC`S +kŠ QdtHLHIORX/ࢭusqX0&C:@ӌ#i[-Pί1ZRPJx@tKfB33=@텝DJ4)D6tÀ茲?pLYPE IX~"ĤH;e?"89=H.Cl#%rڢddZSV4p 9bϤBȡ"^>C;cȳ?PsfsEi !HnRf걕wO1]*vD|$I b8Tŋf'9)pq|kr0ꛅس^<"G1ce}G60~fxjFa u'6tj-7kɼźLۺ P5t >E Xwt&KsZ[&N.[E嗤WtmdqDߚJN-D&~&:Hk;Ўg-/X[™,,1e 24Z3" Ax (.@h'alÛ*3ݺR '/ ]8X"XYfGB0"m{*XWtۘ'6:t}R#}ݍY.9JG7/D05ц?Rtv-8 B q 10EnQʭ'u.LeD0)ekAc :,e rt?Љmʢ2ofZ@4C־Gn=|  .& |\GKcX9^fk$b[fJO6:R e̕Q~U5[9^x$bf ͪ꘶FrxZzyHft(wk=%o[]q톬}v2S|wdϒeL7o>01v٘И `!oCB1Hdyl 0x AEQ1 w}:ZZ{40hDR^ epZXБOLTp=! "Ƣ!sB\۷s._kpr#5f_I|sXR삔oz/?ŷg(` ʩ1fDJ߉qY86K}PP: 8&b_ ?fsJ䂾9ܚF^ Y)yN[M+P0v ICf%? %遥Dgt8V 5ftPDD՞Ĥ(+g\B/,CU fz@ wg[ Q.M*o? 'KyqAk[{"%[v k]vsvv`$$#>>C n@VI^aIfvI $3#K 1(՟.5KL or CuL&tח!WhsHǪ~x磭꾰9)7  3̰!pupg5(B<"C<W7}D:Kq#1? .ؓ8`/!^{T5|6bO2$5Ρ仜;J'w;'H7J WĂ`DXB"Ud뗬BA0A3Ey(cg鉻ZLgpȷW ~,i VGIAA?MLQb]`s<ωwԘOo!E|"Єn94xGvj8w8ՔҖ_3=f^MGb@y>{" c jo/pgWlr)gYrT.V\ $g~@a.m˽8̂,[ L3tGMg)*,иzW,v+ؒ3÷tղo[uDLM@i]}nBBdzFB~~jA`Ioy=ԇ0$ a _85r>x8|'O-ұdϒOH -ϝods'Oӌ7T7|ܒPߜ_y2n?tq D>J ] _D 3A5%:/#'ɚ,BEp# =DY CE#WlFbrDC$^h`rg$Hf6>UH gk}V~U=:jl1)5?\M.S SD>,ڐ{ mm3XbB@c7~eӞvdيasnɀggeǒ_^CGYӏV f..|CGg &ܠ~YKoW@*fNÙs*ßT7!!?$1ǯxdmho<#66 T㦎YDdoQy6FGzTd75vY[g?:&0"uxqإw?9wpaQj[w9M,0y>HIm}gokV7THC-QL[1bB0AlDg j0gebB5|:3C<&"IqQQ>5D]wħ%!Abe$D\Lttz+-HZ.^1u ѥ=HN0 QΌ%]ց.λX9%Ez4W-p8ٞu#ZbCۆoi՝wM=㡴xLPv,+Pt4 =F@裿Ww$W쐆aM;cuإ981GM~#EZbiVe[f~X}&h:w*񌏏ׯ=:7WޞSXp$ /9%߾i+WO],oxCGM]ꁝ+sGǪ xtŋ{_X9r.&!x/ⅷoGCdϜi=}}ln`8޵Q41x|{IE=yyT6_%zÍ7o#iyRG%* }4 M~('#$R@JƟx3xL U^?ETfM9<0HL\ Zp= )|Jo3Љ% Frhb~F2cD44\c$Ϭ*yݢ@J>RQ=bIB c\zo(/+NPu،sn n|C/N>X !8|?hhy|x:+"b"#ھ]kv· 9BĻ'N&}D|HzL7/pPe namkIzR#Ђ[Fa<Y4=~| *9闿T?pbu׷uuw"W~57zNl)+kk+,(L{}@vE meɊ?z1_Er5"?@8Aڵvꋊ<=W@B5hq Mj#J l]ļlpzo)WJ}x|8SmYQKnSU?}?K@=}Kg H0 @ܦDtyl|X,*#%a_X`/" Ze}L5I|g(8 læ6-4bXq5[1y 21 X)rjRs96[/:PR;9xL蚊*:3 9xJ,-4 ^ -sb0rQ VQ e\B>vc ןۨ {ua=8էXbD@v,uzJ ݻCLS4qlyrFZb14|&#(Կzkǧ{G?@j:Z^@<5ɹ1 4;G 8t“o έ~^6غ%Ĥo.Cڄ%8H0ei񋦖6X%]?}(1gpI+\U=`!4v}C_Yo^kK(r@ :@8"pbʓŦ.CIl2ކ WƩ0~8޹BW0 Aq$t~wŖOڐIfgYGP,ap7:pzb_@s% .nŢ$C%2 P膁|)ƩJut`X$ϪJ\[Z!r?u!oQAwYlKcmDdR#lxXj>CWW>>TlU møDg4[v}Pa7zelKO/ 0'DG}}Wǽj臃/B?uB7(lzN9&q o{!C\uH\zqmvf3_=."dC(z`7VA\hU((ȐCJC Ul? 15U_ _Fd$+'x&>zuHAx;n]Q!sj hPp1?1Eɟ6kT 管Mi. [WO}it MR09D؈³5/_L nc[挄*9. XOrt8I7mL`cc!am % `NtC;*fO4TKg. v'8$13Q^R5&l<-C::le+wxH_#  6uF& m7**' SPò \ ,h9÷Rbo\X%f0Puyn{4ͽ7oc(IS=g ƽtΣvP׶#$ݫmZQqz7q sB=5gS3mTn"U5_fD}-&i,Kt㷷'd^a$I˕;|1…b-_f 5]R{]X^ ;"(?CY64o@Wf1Ȫ E H5+ls!BbgDjZ2W©fU>dqb(aܛC͉B_ܘACկ v%m0 (DTw,C8 X!7)t$1k|tM[{Itt kU6fxO%{:){yT\ AZ8l7*R:׶ lG?$A^$Q P-}r;໣vGnা/I|v9ya QM3U,מ?1vH " 13S+88%59PXW^6K]]|VuP /;T;|Zݑ Fa hb&r2QA];}BE& ڬ.%'{'kyRUC?*L -İE:$ Ϲu* 9.~(Li؝D6Gߨ6hDXxƐaz=R *&0 Gi:rW,#ڀ4됌R^ܳpje05+3*vy-#rq"3=&a?J3/L+jl.36t0[?b-V|k|MT~ܙzW>P135fgsIzhWќKD\W/2J">_zǐxq7ۣqĕ#~>g^'̓H`rIi{{6rg'%ߤwVS| s60&o 4"BtYmM0Xh}Tj`S8:`Ac왨O1U921ب۹"k1@ƞP~!tI1óPlx_z&|fQI)zLjgƉgYhTbS3H >ALɫ$ E e<*C XQE Sߔ߾EeYG4?:}CzLr1#[LC_lJ';NTTfךۘ!p:eZ|ZQdHQm`[7]wBlp&cc/4mkO]PĘgE4)/3cq]³5 Q 6C4Mf>,B#y6D&!^{CEBDݚ5'NdoYN XJvYCO(d$~wAflsYL$Ir%t}mKi  KBL SK+k o*>&u_b-oB6K?f<")IrM(lTίڻ%̜7z.GnفJ3&jNJ/ܧr o0Q'NHjYFvLk:}R>::od:i`ӨnOj]SK%?R%'N4?6^sPE[g⡚.u#S[\8wlA Oz@~#mi{)L {%tˬ )XU4 D=ABz>+꤀j̶8[#%4y&叭]cĠ$ɞi9C>zB""ͻD:LǕ{Zgxmb gÙU;^$N5@W/ o&օ8DsղzDlO8U`.Xd<-u ޙЯ.m%rRWo݆2w`yhiG0HdT5C(V Pѐyx}/w ǻ'=ܣ+}` y@٣{[}H ɞzb *n{ᶂ?gjD1?{L1D8#A:pv )\XlӻCC֞-}pHȪTLN _6Ѥԩoޤq΀M\BC5<YS5"SwLmO`2 IoaGK*sVW,AkKMYj ҧD]bL \ [a^[p6(/-wH_*ju`P"^\tKpɝ}cxy'JeO%XCl]W=Zg1P1RjXEcd{:炟ř#eze(2lB2V@dЙcF"(%H8Hr Ï>ip7gk|@/!;#P"G҇ZpVA_:sf?Mz"["pSC-k/]Ode"W<~k?`NYY:m%))"} wB}f>lñ!++G%hKSzzKO{1 TϷ8ɧtw ܤZoIT\>qٹ?^ږ-tGU\q+>|wЃr/`9oШ]-c!7^DT5Yi񑏟U(Ŵ`l#+25;ǔ>"4h n>N Lz8˥*gPIn_]5?b|0yu)SfQ>݊]f; ++-iTUNkXt]7X|GcCyMg7>Ӻښ!?qiztֶ+W{qWz_ljhH.o.ޱ4"4uuhKK-UHo|zyyE5HLIt)Dә$ٸXbb1B3(xU`f'W@] \۞ESg (pa/"Y37-e]@u*E?+񡑷Tv9\I`Fv6@WM߽=R>:$?Au]4YQ0|K[fғ1m!X^f1b341 j)e Ŕ'ʶ Z<5GXl,C6YG>GF3FSjHsQSxfY>f2;o"L}VaFxa]]~Sjww>$p?Z_ҥ0Cl-PohI#G8g϶#SI3~Pl(yg0$?0&}Xw>{ed%`*'nDE 9:wvfY8^%TbhjE^&Ρ-F&.|fdB7ܹ6^֖;7`nёicgRJdYY3&>>"5-9k~Oj/srrH,T\U(=N0Z?ǀk bjMNBA$K\bFAΜ'$+uij[- L5F 8`H1=sR\soJXa#˗[_165|poR,tE(X]2^.gj$;l5*21AΝNiWlh' "eSH%9U-U /]G i) S:>|%Ђ~`^\YR:p؇X7e2#QJ#G5t"XdC?\.bOzXfN~?y O.,=nl w%`nOVS/.6Bfdͱ:4\xz;;q򖗉¬c]>{sGG7;{W1E%"essA\\4JBF/[(rE%{ rCtYߎN:`oo #7gfK6!Tw ҡ՞8Laq[l|jvXYi8jY0Njl.,W3c &b@ 8 bthYbοޞLl&}33gSS,,1)L<1XAf--0ǟ P]mm٧O 2nl%X+B!w C3<'Rt]fc. *fOiKIdd6.6v@}"JBNu̕=3cfv('GY>56xɶU'Ni=īC{47w*A6v)M;.?E<@&8(?K흓_t߫p0~Su3z;vrby)-}vIk BxFSr6 D!FF$>9Q!E䂘%Eb͌‚FPboc(TzmV~[DxiB1'MZNlk{ϘL3>B>Yȁ(YDe6.t ](Vk 9υ*Tt(1N ӍbMTZ+M.fK1V}~^^^^a,b0JJh^^Ҟ4apnƫD  VVPUnJ5YrFٰs|Opg+*ZO(3SOO>$ QͅX97[qI@#q5gyVGPD,ŁHh#i X1ʘ1u(w_NX|=ҫ嶘6a~m#Qd16aXEK 2٥ؔ=/QCu,FsW7G3q cs`B\͞g6gB^PwLl0L׹<²CnQk[gTӳZ8GaY#eںu3ݚ~(꽿$RMǍf>ڦl{?9owX/-eyƶ1#(i($tus8ztLL( gqt!<+$ l`` _(`G2EAp8A!G8f"FylJو 5ڂ'57'D'޾3"(("[`dSBMN,c$ FʢKA Z!;pӿLZΦ'XQ\FI<᳗mQϿ> WR/g?QyeWW夦7vM~|CX+wh΅e0k,@w|O|)t%`@57ws}=nⱥ%+(,ϗ縐B իm0Gedd#fhHB`j?(TYy vgH!NŸx-0CQ`+<-PB::$'8oD-J_p:t]ϒ3_`@HA]feְpmQ;qTcGe4ip(\x#{foήʽ*9#6QȊ6ml vhi Ѕ6C}dsD k-rytw/ TΨx~[c ,6Ó9C3!E𒪓 #}m)и$ 9_8[}e.11c𐏉 -(LόjH '房UTPJUp{Cnk=Z:qq@y.D4?wd֦RLVϿk񈖑 a"»ĊPlPNhMm(2$5taΐhf\$8r3}M4?\&=S *%]Ig9ŀvEaHj('wOkXt.LpS$\.^&RaG]*M%]|Ήeؙ)3̓D@8]rx|(Gv {{'[glz1K-YD\?,,4w˜PȨP0AF*uIхOuPi`@ew@#c n?uuoݿ͜S*Xb_{'v%26pf9dhih4k!YY|kwk&zubˀw2&~L%2b8d/\]%yx􎁻5۰|PĮf-ZNǤ\+x\9vr"NS]ӭJ%٘}ꯪ4zZC+S(vJ+`ߤz' =#rA~WR{4-I$CJ^5{RR0J)xaKͩ'ʞu.pס3j6>ffL/ʳjDɇaR~xTI;wŷsLꐲJ{ QB)K !NWѬ>F''v5Bj|ej`j:-/U<\Tė[~a¾ؐР޾RS+wL]/2 8Jd2Otg鯱-& ub[IO=~An\,5;h%cb#l/DruM "#ۤ!U6D-lO֑ qTiYJ K2`2$VQBKvQv%I=sP-54or[&yK]TO:}-O(CNG,F=N3Q7y[X1 *Cd5'WE]IⲬst ]# [S 㢔4)zoU8D%Xcڍhe< \20B F,9{,B*BoE0оqi)m|HeeRJJ0n^U՟7E"[ !RX+G@ 8DN[&꒳ގ>'nuۢ=O߸Gn1ܹ}ipQȫ#K1AXg)rC-%sH6lnu7-in= +gٽ`; #ܾ *0z-&fD6$]7ll~*z!lG_&kqOνO4i#I=DK(b0T?0*%j-NQynq(#.esͺa81';u;Bl-J,>E$Jkwvqi=rO:ch4v߄ @v-:oέr6*Zq#.Șe "Wĸ໾ ipT -`d8MbO엯Yz%σRRj}e2<{_٭/=58NB O;J˳&=77?7r{]ltd7I…V mC^2)#Jq D&q e+Q=(p#q hCӯR %[ sk}茝W&Њ!倸zvNǮv!@Kc\ ÈhfIlWɞSS6Lk_lRnp;hP%&b"4Pױ -|Юǎy!LORfL/o-\INW]CNVvd|jjH+66eJ;T=BDT%bda'|{L}s S$zͭ7:ܺE%qP)o,~zFnKH8{+WMK58sG,* dz8VYPT} 0N'~~vɶ#.Qпjl̞*Ҧ7z[߀K[Wc4C5/3kf]T0GZjk@ !pf8ECKgҋyua34yl SbdrxGNU4h8GC1i*ھ"IN=7n|b20]mDnL=Gvl}7"Qh91B ը.8ox%F~.sZiX3|y"rlCa&1vf)_\x!3**v9RBEgw]ğ(Sjdv~Fid+O ߨK!TADYt-6fBtܢspҙkцb19bBa (cƒÁ Co`^/1fYU>)T2 L;P`\W9 Vbc&Luւ)5A]q̾q#}nG;WVL&gU4Gб@ǥ o^0j] <ۧwU!~*v1{'o,Cs\9qNFwȨvOdp"c4ȶJzJ;LE`bV6"ЛTd+z))*o;G{u,[ N`SU.k A!R`Yɛ+ Fw!TD x*xl\|1*o_hUBc9@_@v| 3j:sZ&| 0+C5 anf!4* rcl^-2<. 3gTpz l9AfvEbLTMe6Ak6}a=2еKAC%0 o|i~?IΗ aPc34^}#)cy >`@=|ZY}}][ŤxfG;M*8 /^}dzANhy6}}nv\C )*pۯf=:)CDs V+5Hw-#YאkXu^NgaIT]C>'!̉]vHPnQS V"Kc&'9GnVb\bc">"DdeP~=~[&kHV€jAaXhxL}W4 ?ِ[w^%{y֐TcQ]|x4U)Y&Y[X&EI t+޾mb HymC}k1owAjcfCen$jS!=k}6ɛd`*lPL :ƴm_'>(4-:(Ko0T $'%fUg bhB`KҜz0H0XDm g+>٩1&x"K`Aȯ3|6˔06`ıHsh@aSE k2B&殭E%%GÂkSިErGP-/]Wdp\D{@dӁ3gZ8$ʞ525|IQclC/&СۉB& 5NY+ pAUŜV$Ԛ36U1H%3KpvU$s>kVekyuSď3A0 Z.^ATǀнy^QT rv[&B1y4 oX-NI?k%qz$;@g!s|f<3_)1}”?9s6ً\1ŏɲx FQ{8p1.X(?ϳ|G||IMF#?j]TIEq>M5R+H~s0#@_N%[@GRL/\\^4`} !>2-389@NS[/R:$zQkOw-B~=dmdSȼĆ%zU KҚAM3rAfJig%e[$;ln%Z)Ok $$#Ԭ)?Q̵dziPb8"'ky,At I;ev*_]ܡڳ7JB吒߾)'"rvbM<)_jY6Y:c"`<1. qvf1u`m6o_Svl/8]eJWjwBx gd&h:\#^?v,AJ 5}Nn8ͥ7ӌիLSeeF7gr !x/bLdizŘ@;w3!bwhwIZT/ǃG AZ5D `{tA)mLѕ-z _a`BQ4}/:1Hn=5*Gw4mxkFiG}NIMאmo{"ǝSpq@,$qd9B䈎>1YB/G1 \Ѧm#, ( 4"BuB6c` .lvRn/tJA^Aڵ>"K^h,³HLzɱ_ڱ﨨rLeJaa zl#jYXq 0qOGo*GdK-} H 8=uF0؇j=iU2.KgO+qK<@ %}~fO yY?5cz\nto@R_IBV/rkĊdKx1}xwVX3.ls7i 2.I-$af `[`1^g2#S!!Y!V9{MRJ"!.Oܹ;RʬR+J"f5'r--[~ie丟FjxEhu˧y?ГoN@j |Bj'4o8Dr$flãrcW3*T`頮 '.`s-VY6bJ F[iц`.eX>C9M Rt5%WgCnTWt2o~'ԐAt#-uMFcK?:iJ[ZфpQ`̱A? F L{a9((kf s y0!K}NT6>gBzYbJIW_z9ƕDXA#u@e'Ϙ0t̚ fL.BUK;p>i<8TLk-[.oXu{qfK~׵,*um,h4#skN)WJ[=9'NO,(+8w ˗o6yզl2GWTE+ھfd]MWM}~b$ĸ&W( '!77֝,bL8"bL-(Hͷ KI!r(em #%bmvuz̅ܲxci8"A0+m!~*y#3MRs`){ MIT%vMk,s܎ 19{:l-BVկ?S檡E8= FTߛZDm}Гd5 *jV$G0M6jĖՊcb{ 7Toq|uktˏhŴV5vŃA}U[VB 7~cZ9H$&u%ҍ|q m(apm8z"92R)0qG^K15[_V%; EV)Zc2Z^:<8!PEESRo76#x`*/6FP?Gy\C/vL}zYOtvuL?[8\C YK<뮩='#yo^.J@ jVLX#Y.n.pԟ>jdɅ޻K1Pri$0 Fx0 "뼣C'6%Fz#FYe?ސ3# |gULषt}w%  m[=qל/ C-dp>;wSXr.4=-l`P䏍&2-1Nrr"6bYүʘ&vN36ki7nB?yym  mB g;4tV kkd\ AW40XQ:~>Ss'OSY;<b(l*Șk֭\Cހ˗'%{ei׉#;߻X7 #8 }8J1:By1?P>=u0.`Lrr?iĕ!/\}D脗mp0}@/v`[B{َEg\c'L|S*蓸~ϴ-=T"Y& $:WAѥueME x@lhY ޮftWnܼ۶v/ < .kx{'.ŕƅ4QfN#g[v |rohh/"`"9e>_w>2Վ--9I \\J7rw44*eKR~DŠ܇ 1aD=1CH݉Jg QA`ǐI];|,Q.zJq@hHDISg10_k='k\b [tyƂymDSRylha;1GN4(844R~_=Lbҳ0''.228::s+K°Plli_YiŔ MZXD\>`PPQuM&v@M`Z[s}zt l㟫0W5F&67nQ@`F$cbF~bRG]egyۭʽvG0:8PspvQ r㊰Kr^} R;7{ c8|K?@ pZWofO X\ K"YvNzͮROt\0| GaA,qlWbR<Ȕ;^#fymkiwWWKtud4Vn^ف2Ƭ'C3eͦgy{fuxT])e;Fȳ Oƒ ];GRچ Po_4jz|Ӡ xe,Omw[qF ޽{;dvd݌.6V-&hAQ`f5ǘD6TQa=L縡JKQY 0ׯpRIWW~qY Aa`p䮮BfqD?߆tXj7{=;; L%!1_Ewo-4>KKupz4_IX鍉Q"5oo~[wBcc>G4%w`45usJ'~RhD_huuK-CĹĖ!*4# 7)(qs~7ЄM L|bbO^ tVU)s(#3+@h +ZKL/]ةaje_"DaR3r;:2I)qB`+=*Ի"%C|H+ZR5ґ}̣tif"m;- ׀ΊdAߒ{/7]ೝ 6ذ [lr1&j 1Z.y/U}|d2\K7 ʐ˩ )h>4YAeCeۼ̱sWGgenA'O#O8d[rYTe(j)StLI?VN"ۛ?6V<2Rw}};'ό(@7!]|wDt_bri`P30ljʾxt[C"OkpSb/]H: qΝﴶ@1Cc{;;Jt޹%by|VY::GVwimT |f#9ZϴF!RV?P/:S̙6blh@im]N^Ard|UlrO@p~ 3x#?9x~Ja,WUYQ +׷SҵfmcHHHsLO8GeDM;ahl*l~ϟoE*6c -L^xGB\\N>Fd;d#M,͵(K8¼9OLٽ3@ Dxi۪Pܡ30Ɩ^Sނ-#?ÇCf 7!L:K7xGO],(bbۍ\2O_AA o7/I 9dC1X@3S@x 1!'Ƭ /0q_ATSq|=|+hpEeOcLhXR?aZh@25\85P64QXBot{"TF +u3>c9s>)?]Ļg`m@.F`ܽ>h&fCiip>lmco@ܻRBjjP]R/`VɳccC, ]<ҫ N1Lߩ=Q)At3q "WWGoWK|_v mz'O_ ;"Z)v0s CQnn|uuFww `DEzz8xy$gi MˉadD+U_2ͬ@ܙw $[qaM#swnBw {G?0{_C˘ Iv-HENPLaF4]02d%}r]j3=n-Wk gӌFcOVm4է!i`i|Tі{:DsE1j* ̭EK+˹Fɉ $:=hh,n.կu'>2 k;Z_"9ܹ-z$^`LQ2=:_a?2 TZGh2 +oC1}eZ}]: 0O ]IqV`Fl%f%/xVZ/)$ObiG;{ 6u]Z;9^~>/tٚS9un|>~sHKefUۣK&4דӍ4[fa ֹNEn>!r78f՚8GɩwWR5EW?>٭~3v1(}s٩ %G dˑ{ '$$4lZ~,>8RJDPQQ,G'bp8#uF2 e3=$|X4J*2xe{é:l},2;;~y!--&&=qxaCk=}ں+W:vr"/Nwǚs|Iz)omMmfUUsϻC>j +i,kn,oتV*94ŗ8OޔYY((ּ춶|cԔ}66 MKcbŒ,܍0a՜<&?K 5v1nA'ZXۺ2 4)#ou̪L5u=5捬Nj%AN^H?+>Ca"QG-ʒ,<{'% և5'5ȝQ"C"-y4%1͙;`Tbp[bR ;72B=b_[xY+eH6.'x!t!^@H}rۮ5Moj]<o1;t,bЏ# 6 υ O߸9(ġz?)(ʕkk=+ :$IF58z&+.!0xqKjݝWՕƱc Qfl>37i]cESUg;^/4ͼٌ.Zۀ$C P*JJ@3(51XV\[bb,\SWsg W|vW>φ ,- 9F_D3=&Yv[%fN 7"AޜPaFtbӢ;; ų0h-w̅x:Z;C^l2C <+Xߧie뗘7 piy&HɶQ@\xxCso?~ɋCs0`0.]{:5cm]ypڎDȭ( n ,%JPRr 2$`d ~,3d yٲ^?s5AВF~xoo@7)oRS]} "'}RNUz"+hK|~hu4 tpt  NKH PGSeXmdtȖs=L\ALP+|k,Bm"Rzg]w80M[oTd~L"a!"䎵J;2 yᷴ /Sَ4mrQz=>xC LUZ=v`|xo߷Jcߥǁ%2Rs+a)݋#Ff^AxFrC~Dn{ TNk[M=*?nkW *fZN^_X|_{?5EQ;L3ɓs*:f3uOPCCc~l:Z^U."c2-'0>w>,~hzKL Eie փfI6 @o河 zDmwvwA`EfO* 0IwNz}}[FH[.r]wL} ]S~ܥò:߿4tgGdF xRφ}ItjuXn@vFF IFf\[bC (K-c-,KRp^ƽ [ӝƫ!'\Ymg_>@`ё PAAe`wYd -[]R!g|@vs.}Xol3˭Td04T5J1pˊnRԭE'&*m%d;<+EPxtDypeu(TUgHʎPсd EKsabQߎ-._#pR3k'@y21(*chB{n &2NG4wR97ۃQzy BaE>Iyɵq^.n?8E:Ě8w췬/?QSRQmj 9E"H?5*u4q̾Iu]ɖ XL991wNH͐g2so|M ^}C+ⓜ9<[Qnj 3K#l4~^ _Pi5Gҳ~k[_"d@2(Wg{$;:y![be<7WKJ!lu'oIiH,0pF3"fk<9U'X7wSgrȈ gg"ko*ffF0ae:4Kdޏgcc+n\,'fe|̇ƾNQ,#(YLpd.^선hH }C|]GcgC0:ZLc xdޕS` W9ׅQʠ܈\k:o67i,WPKqqճ'&th-W7?Ty ]Z\{ n65]5e\#qAVie P#ȇ^^s4p-vxx;m`{#ˤ@ۆ`!] x+l쐡85! `µ>,vnnbмj 3I `| <9r7XA,% epzMbĞۜh1Cl B  N{o* 6,/y*FO!qwjl?&xj%U J@0ɭ7HJzW/," uЊUzEk{ rFđ[r&~}-P6+(<ǂ=}'dzRdHR?yo)PZScT`rFQ.vЍ[/ćw߸#;-C瀗{tt`(VUgu"/]\"g0DD =!oPhE188_\"*j[etpFs\YH5 2vڨjzzĔA{룸N7oBv磿&$SQT`><|Cf])E*! =zDmݵ=1MR8$%&o`i\dH{it&t1̮V*{}"~yG?G }\.  p1,Ğ)]2x*M[vr熳oHZz|DDhXXU!L^Vn6@4ZBWFN.@):Y& v9Bk_pͳ RWw0<wV DV]\˲s䶯_ׁ$CR)bѣ~s:wlI銛xOU`5ڄ).&m|o>@>8lx:)>9dah%;qp>9$ b%c` t{#D89m;;b=&<輫A7 "`esZ8"1%FPh{)lb=(qgH# e-BR5Ը~_5]a9^5pd׍dzxG/4YZ9OMU8,2,=+׿"#b\ҩi-g7A^@3N83C*$?O!cE|lo,kAZo; ;-\ JwYYiLʟW9x?g1$踺τ'錖* 4X{AHW047[e8/'&v6ī %C"?|+n- FEE&xp\s99qh 4Aӻ{:n(!P `nꟚi&\u4Ӎ~q}NGcGRXg-=9ˎ.fC߫^D >54BJ=<3L%WN r#VHa'LzOm]6.Lo"K%aT7M]n|PQ;]gh[֙*"ܒg p#h 7B8Քn/cW!VX}<1@Q @oQq:ŵږ{Q~~Kt_Lfu mU-ݥb|q5l  6^1# .[f0izJb@0_]L5t/.6%Q1 >|[M@,!--lC7H,jqt?>o``lM:;w6ԙ.K༑ 56>9u8&^~A|BaTJq&,՝ PF+EKEgaރl.Ȍ):Edn/qڂ ׆Lב)wY( С!Hfyq U|SJUn,Ҁ{Q70n:{hģ5؍c-GkTuiDϷyu}˧Az?gp|!t.AJp$[NNKb|Q}1qxJ-yP$`uH-etó=دjh뉒ZKkchx` 9/?}='w܍3Ɓ>?*K~6#/Wn=*q٠ޛ\+y Lsvs7cu }P&J$`EHlW/b[]n2f9w_0hhdskp%{Rqlo &-T?Lp5~U;pAMc=5 _hz&&NR5eq<)$/\̶0 0HVTXƶoJ0x68!p:[ٺb0yq5+wHj}Zg* K]Griŵ'Si}(ԸW#Ĕz&m ǀ2Ja0y+C̸4`cA.U1 sUkJsˣ~[ *5 Z =^5nUjh ƠÅb 颛+I"6_0eFTlOd ,s4{Z$X#R}30.jLi1lJ BO-3kI/jA P Ēx\9l=QlMf#"g$wʈ2$;]ł"B+O?2;%'\H*"MMYGeSs[b d~%R:*89{*~ k발,G'780V +W//?''WVZ]] -ʊ)rsc{{RZCC,B- g.UΟDBiojkkSIO>}J8"СjԙvL7g4=exPq[ qPGHMMbd[p4ft:{֩'̘+ʰ sExͰ*šlzz- {sQ~J5:v"Ӏ3!"7T1ogC7OO=EqQPvbawUR4 pΠ WW3BjB$+{T$7<ݷ?&W,bǎjIrZ$*jvQɩKu,6;'ɥFHW/=;O-(EBrqD޽[۱<%>>~;Gxfz?lw6u.lts,GrhҧF'=?=lBL%7oL3L>d M\CZXrHvd`<f45T}F%tO}&]W_Q@`͘XA"Taa" 1t~ Sk vhah0vMrCV hP_QՆD䐫pz UKŚ٭~%\bU-LeH [ I%8a MQ__&6utSwHrK;Z^#4(MHq >|Q9l`^_ܧ^qOO#\1l20ddo >.<P 'ܹ-c?}X5񊈌0u EV)S8c$ # E扉1ll.DK(UGp%hi oC@'J8i௯[*X[rPɡd#o[ :bp^2"z ~s\!%u!D(ޫCsON`~ `еHqMPB=kgX ``cbgq2w{(t6G5/ Z4``V[l4φOq١ዅ/5>{MN9d"/#5h5 'EEF Km_*+ B'ik8!dN&EooAC-` .>A`pbӹUMT%.7VLjOnn4+JwyӺ!~eDo@k;ߪvRocbOn'7~gPY!` TC&5lUT=y /}T)833zlGEp$MCV t. <]`PVX`~󘗎.C)̛߷/5z;{DB5+Дd.9CỢ"Χ`cmleA&C>$ȑχ+M4rb ȡP@ ^F;#ٵIapћz 5w%8bsM!<*(;~r`fҌʞ`(=#A$s,U`Z|Ջ.\FR| iAMn\'ȕvc<O1NO6h` ;L,oϜm[^n Z3?_<'!55.u" W+z ZJ1{E.c K`^sfy)|䫡BL0ŸD\ZdȘ!v9\iJ=An~w7(Ͽ{qSK[Mݎݐ ø-GvH(c؎-Fx ) 1~,2:Ŋ+{YC z<%s笣Jv!BТF,bqs&bnEld_:v 4Xu))5%YuLKJ㖅[f}5l=`[R4(r"ϩ6;(+=#/? 'jO\WvE+>>;2ҟ@m޽AՔ##}%0""]"B?AwEdnpIX, ^{2@[t]~ jp|g~}tc&qR$|y?CI 9~ˌl+qnoSTT$gDQfcڰ2{dLzo R+bBCaͻuSlCez 0ƔZ V7d :1Y߼ُ⋑#eˋ&ǖ*=3s qpjzh}`Μ:|.-% UU<|}ss0í;1!jjM:B.9-V%lIO>3ҭ66nй`|3Vzdof8*N{݁ ^2nb@fy >T+z5L݀4ˁBsGBL|>+xkצw?8LqB"Tdhl+oƌQBCd@B3wɇ{F1eNR? ͅd׹.TؐJz,ov  h $XSsSUϡrcSct<'BfoOnuTy_';$tiq"Il ݣW1~+m 1dV4^ۣ+1ע%c_מI>ʱ,lD=GGŊiEՋj^%GҺ#ez(VpM|?RFTJ;E[W"z$x]{=Toz9!y U'JV%;QêFwn[~ 0UpEy@ZgQ=p]'l|6wNT,wyZ]`RWqVFv{ k Wuvq&2lURՙ3:/}idВčhW:c }qWW*jxO)Sjhۿ{W:"UXӫ>9o\W3+00ɈQt~wG_O"د"㍣.lhx;t T4QӔ>ֲ p>*HB84Ә/O:^F-ozŖq bMԘAÙbqz 09)ş_;NxoZiD٬2w"ed=ph{ii)@DoZ\z\ k :;џcvcSťƫW{Bg* H=0o]=LBa+.i]c\-ƈ]_c;`Λ{V%=߈bčDgK2ұ:mI%m #j{kQ֜';%8Az>sE+otU#8766;o Xy"J zF?}TИv!Sy bUQQyuMPldES]\=N4m0]iҔ.9$Ŭ l8fވx:P7Wճ&<3#gC'gh4~DB/,,G[6ɐ+;)uIY##ܳ `%1I\b23f+߇LNP#$a1GP b#hƀBa'1 ,<XOU6ּܹvYtB!؛j䟖չ9_: }ktb]`)=Po:YnELbx 3uf1ϊ`f#Y^h9]Ef"_ҍ>]GO(,$bEP_oiƒ2 ƀ 2뺊FYիmuݸLB녮Ej+z]Ə9w=A.rHg1` Ǩc#_P'6O 2|O"/ hrRW|]+' n#mMy51k#ʪ $4HˇGjUjj>K M|@+wtn#Q` ?q;[dʼnNQ qOd=@!GQmdr2YM" }="J-g]؃f_?m9FV1AZ;wvko/x?]F7Dhs̫oF[~sK غ4ƐDjwT29q>$&P) Ô/W̯@f4{4;35+?&w낎 U1ld[|z%I(xgR Jݙs ]=e{ 󭆑[+ZfVSԈ̌yZ 1+[_؝)=ԡ]RtmCgPNb0V1bQaw`-ܵ/m}~߱Bqh{9"Qz*V&&l"|8ix,fj#K61FC({59Z*5u6ZOGo'<VxFз=nv%E:cy4>te~\ )dA8k U>Jp= 尞+nM- ԩ`;#xx{l\ąWJx..IIQ&"UolF|z'b6Zyl+?y*s\Kϣ:dK^^mյ{[&7bMT7? + PpN$ ̱D]r~pikpUOu+%|1"|,!\KKvZCzsUrљKG-|J )KV]8UTe?6*F5[8Nk( % S?gfe^D# $TJSG:aB \Z|VwcuH-ZiF?Zip=2vq#SKtVԈ Tz!sYt< LSFؗӍ/_,t S:tU> % >ҳ0-[vl̥ZHbCKzyJ#|m g{x%@>H'JOnP$IPKF XlZc湘tjI/>DIs\o5+J1i6t I)@A!{eq',r*蘺+v$h.l46gx 2 rޕ9ͭEE8^$)44pfUGM0UUٻhLaC6<=T(ۙC#A&\B9w;; zkcʒI{*x|3-NJ3(8mh\.["UgU=vyQM0ZCuWFݑ#z.v9&Xk5읈Jlل '!UGU}(]v0b`󓬬2 44lw6c2}ff;c#g:Ue"H! ;ݽ+W*Ӯq_I}-阻{;: 0eYpm0|u}hmmF |uA/Gk8t+p"m&1zfeˆm[ZŞl#w<ĦN爷]sh{re21~BMMA+w3eC##䎏Il ԁ?{wHдM*,̘hR$ҜAyu0䘐`'~9FX\B0.R ]] |e9E^D+k-;ɉ1ptJM.\m΀'$_+{]/MP ozӛVo?.YgZaa{rp l!$I` .?[ۡl~)>w/XM1{ۈ0u6uqarsps D0r*G}'N߾"eg'&n5b;lPE⏊`>"`2qFsiiӾL@TP+(+֘%h>sboOO&/c%as^ۍut:Umيv2:jB:ۗd, -_"r2ֶ`mɩ֏i|QanMڜ{L8ՠRvrEq<(aazbU7ݙPW̱cdBˡ~ądzfVXIbatJu*OH}7~jmnap;w7ңWNl]T#/=wujY,/`]Fu`JS#AXjőP`J)^inegM}}bb k7WtLszN}`.c`VY)mCt}Uu pfT~`T@7DL#dmsftlv@9#kjL^[Zĺ4w" ٶMSP,'(}G0Xo:=\dyEF7a85՚af =}܍elB9љ"Q\AAj0~O0|>"z83sSKD](y3y$){W%;rj;Y*/]w `EꅝFGG&f"Ũ&w+$ӝH(%55 %@NbLS(rr 184f KKj8/dE89 16L ɵ.#YG'wcZLl$󵶡 #E4Gwexx9Bd=(jZD!R6֒yКl 2NG ; YqYh65ubCԾ}h?׮I84T"TW  51u)<ӭªŕS = F(J6~b{{;bytlCLa !lb pJQ@PuU722R)0sI#LkA괬` a8sޭ1OA0CH`"PwrB`8uZ#s &`0=2"5olʁb=|A0BaJAA:WT!$aDGK}]G]hOK>m-{l}-\ fd\}W Ns7'` %5-Gק<[>A TȆ6OL[xfDyBҍcsڡɄ&}+LLHR&4(oI!!|3 ;ZfaGjjZPm(BQQ|ܙbI23(UVNgF9iE`%ӳfDlߙ`){_Kh!9{ucϸ-[ ![9mGF Јcc4{zMU֒怓J#1r=1sٺG==E(@8d`^ CYCnk;۰::`}iF%GFQTK[O3 '$"* T wiWD*$57OZT9/xO,Fx݂Xd<%_.rz,hl5O+F(Sx#ֵuYx.HrhZc0FoL\dhx(.#}Q "Q g$8Y\֐Gq\6 ^x__/!dD+k8Melճ770q]#ƞ[IW!]&55d.inΕHRB%o.kb3sg/}\Ŷx}m홡\ cd c|klbL=+^:ܔiRWdEߞk_GZQq-F Z78gszFܣEZ0B2˫n*Cv~6Hũi;;99"&NĎ S{VkIBG/ݷV;̭ۭnL:'؅^Y@J:73$pd&[ƪ1wtwL(nz]ֺy0+Sbk{V4z\UqHi\݇K0a "WЋe0.޻׎;|xZ]\읛đhsC悔hC ݉SU8%aq£1!K##` /NgQSN߆'f谹9(lKi8/[+z %BrP2c c`A- {/wcЗGu6D8"fX%*'@yaݨ=RNCBKEÀSMۚYdwfO+''3d jP1ɫ"d^U |1.c+ wd5բ&C K\>)tg+ַݟ]cYR\Eyu_XY EӧKC#A٥NJI >77?P ;jDg8~={$3.9Q DǗW)h uw''[R96^܌R0(^5ˣ֞ť󒎮2c(ݿ"@!(i*t>DKbye4-an=r2eQ>zY݈!{uegT]ڈ^~AC75( p^qZFԊ +Pfz+2AY`@iS͛Og^~lQ$"i:la]됸D7mΊF66>}xGD riY㲥3;O/%p\' v)e 3?V-9RX-+<\P60͑|76#,.#:L"ߟy q݄ng%(4oK [)+( qR+fSRħ E Tߘ+:_yZ5%'ZNCͩ7pʒ'ݜ\oۧi) :"Qqqaazz>>~BNdGT1<8Xc *(LwvfiiY:lcȜ/DD # [v䝾NL|QYL()͍ g=juq F=4U1A8iǃouX!vd~򅸓t\ZdE&V@8fֺԱ2cs]bW/9|LdW!-'~=/:nv\$ Цf?a,{rkdG VUeaD JRM˻o~vJ_OF9Cm\15a_m7A7e5|݄'ˡ2c"BSH;K'Fgrtƭ ftqKVW23۵iL:F .74 e0yO6;FW E1o_634DHJJ9$4n!EK1kN|aqX":bH(X-\5w9*`lHJ-\}7C;doI$0V;ϟIG- XxIG֢󀡬8^BD͓+NM?Pv5Ҝ_-ޠ|N2]jV&St MC-͹Ʀ$SSɖTCیMH`]Vs`O?_9[*>{i1>=;y ^w֒FhdaT6N*hpd~ץ}D3|na!C^FF Dn h=c'K0&wN3̬\s#}'O=IM= };soD= Woʭ,Uubr9S5y3$N ?Ft|hpA&:-+PD`B#_(\f}n 0_ߕfR,B~n M!)Xʕen*dL&b 7f ]+[i>Wx~pEllPLrDFލ`%QL U(/"1.OBC0`clL13? ~]LgX6r>b[{&1rp8ʇ->7+PVĊ×v6Dޫg^:5b'F%=*3?(Y/ˇs0C71AS3~ArK?B l _$IfjZͮ}9dNq/#pw܂tţ;R ( _XW_1i2ko?P2uH֟@dw-`Լo *kz<5r%\|`5 S7w'5N-l2 䈺 !DZh1 PM>HذYɏ4ITT۵VDrL>ݿvz#+Nt<OƬb^|ۙtffD] : Vfhʊ6`|Xq `Ŋa+J)!Y8,":h[<ֿ$$Dg} "`nN{+BdB|Q\{ug_] @͗uMyz[:>@׻ -S`~E6uH׭4+Ym:wd/vƾ4 ˥/IAk8yyo۫3`8-l'T ٵ2ƨZՓ'4Ȼw+zC(klfrХb iHGk$ _,òYNNL\Q!Oog~eIqǛAg_)ƌs%'l~BEpg섢ƞ^b )A"d[ʗ]q椏y@Uo9l3:ZPV!$܈yD/9%"0𓚚$%FF J@aJbbU@ KneD(Ls(57{lVt37 IC$DJ {~kww/S]ti*xm. V.9ym5ڍ-N0{NZtkcl8v> h};iӶյ;킦5}b)\D0E)u2+pC0PNJ /mQ3 NHr࢈E1^ɒ:e [>-###uJj$8 m~p->=pLOSsj$ BO8QM12}[Axc"U600J<{p6OL`ps\"iRsww+€H1E P@p>s^njI0b ^GRs߶ݖݏ߂c?)@[(lq/TVS:![䯓`S$;Fx.YYY&Lzwѳ f?ZyL MCiiQ$lS-0Y``LzjZ[,HcDNnNUWn -P'C"}͵%GÍWͮzMV 0y=fNly(;Q'O'5Cef2+<{"-k :`Q| It?yU/?00Ar!K{{Q>3Wvp ~Arj+[MrD9 ֖ # 4bab}mu0T^tcj},s`)ewfr[0pٹ\MBFـqhSH&r_ ]ٳB8՗]R6ϏLvѪ-یl=c/WH=U}9Pl#x(Lotļl }9`"&BNdH{Ëܜ^ĝV]% J{N[[x2 ;)%4 Y> &f Ƀhf/֦&f$>|}4lE Dd7x3|fQM KVTtu7՝FkIBTazU]ݲrP{H`iDu^{!8…ڎB<]|B#-BA ( Kރ=d06AJh &!Ɖ9.LCo(榯?xZKLFXꦄfñ )ƹɡ?=\;$/}Q-d=|{v8^m XS1 ~WhGb}AmPO//;@ a* ?ns30RN:M"uo  9{P iC{.!  #Qdffisj50"uΘT[]`Zbq*gktxY}t!-Lwoaˋ̂1o{{7۝;o'آTCА×_jC5Q^؜wwt=ײ7uUggZF*tv8 9{IA`mwèfg%;ezΆX{҉)H#], j+"[?>!QqL;xxڗ$ǫGi62ΰ@'`. ^^S³ ㅧGF.J-9sX(<5?pƎ+Cr2o Ѹ4;+o3ꔅM~q0?zz0bʉ3XF~55ՊU=6XZeG0M]_ s+fs@<7/ NxϤfe uK^DT+7VA,1|Z8jfAb~Svu NEqiw2zl;~Rz_ݭ<`=ji ay i;+M@ oXge 4wdyP <]}!)ry =55^a 2ȶn(ZܹeGusɲSšMH  9i|]}(uO#N Gؘbq`:LBOO#݆'u˞?m y4ټb/viS.i~]c+xㅜsg$ҪkgDױiAZq:h}մϥWR#A+ W?"]ƓoNZ(컁ZO|ZdC2BͣԦ!UMQ^f|UPONe>役l~볡l$VW^>zE!_N$yұ:5nf"B'X[֐*Rtm| cYڴV]&jiZL\snuG"N'6w_'Q>Q$]}#K(x"/HƔNރ4%ԩu PABBL"Ĩ# _lD=7E NBAzRߠlfCrV|GqTWသ;qJg5myء`"a!{eqA )$++gPfu-2f%h ;r?OX$bjGu:F~p٨Ljz7Z_멾/_`F^9U$3ɟ&Vll討ވ"c4%1 _|vQ4f9a 2c})66SoT%+E%bj$qgd^^꫺^ SAځNT}&YN/%hS›=?@#mfǍQwE .S i]PR~~Jؗ-o|e* *45j[/%N/p<7$]h0%^Š%D}.8TWECmX^Tr:`[CFM- ;ky# skL;k>W}Gy#ĺ #"f. j/ljy FgH|1r.F e3I̟*ٹwy5ŒeivF!<36VuH;%PF.| ȏ{l>Q$5};^0t-.N"Ux}^O/`a}Υq:~Ea h}:ȹպrx?w ?۶鏏Kp Ȓoo&>>?hv5Go='?=T wɆ /7ޟS T] Μ?%Kd`__`+KGs,;F$=y>$$ɉAlogAwseb eyym]]EljjrmlW@sgvHrUjjS9~Sy丞)M OOgB^)88x3:QQY9+,LMd|*afкa U= $USOeU?6P6!>V?{M9xsO`3'&ZFF|C%e|)z8Wpz0sbÇttJ"d3?1T8{jTuaSٮA0]} QhOWˀ, AsںSsS_5k;fg;^oE- &#Co`ߎ*qffL|.?2R`BR(`L\PpC!T3AYX'Q]>ԨquM6~uiizDL06dF۫ұ,6C[ċܘޣ5s'HR8N?vɫU8Z̜,ՠ nߗza{dc X}G,ߟ鈍qCet O udyrr\7ݗLu+$*RRÿ jG'&tUZytvjJqvk ;'" ffۃCxo f9`čRM;T@g?rc ff&E- [͊0ܩ)@_h%8ih)ㇷFtSrj(_@tVm`j gkF(p{J#xm [-Wn1-xLg>QABIgCbI>%ggЍ0I CՏB0C 3| 1nOam!ɻi꾂`P`_MUUXNv\*!m B^f&ZZX~p1?J3bd=}||}β |}CɓeO0.Tqnn35KX"IwkפAGD0׍ϱx? Wp8^B_X70jacZZBzFRgx~e ˗ʫr*[k6F 2-=<}n3|%'..ŕY^tp'jũ#kjs%ũa!#xbz'LL4y6 |~ -$\zvJ[ǒϵߺ%0ST'י~T#j|AjGHEUJk=vSK-La ߟk>d*չ"̉IqY ؞'&$%E=|3+OCG.^70xT[WFki3ɮ LK0?)ٳ5îLo T *`_BWɓU S]55K fC˩LHCna?؈ /uu[Z/af!:wwggD0Agj Q(SќBX=bFqtt.C6& }6`[MM%;t-DXlRv {x' E!! ٿ޽7Z}0`o|˦4LgHAAaǽaa|,o~Sw6[&=|]pp`FZ6#ED}`RR32urb^T]0A9x@hvL;HXR LOXX102jsĨ0.Ɖ \`7n{RiKTP!C30P(ADKt bUU骂cc ^ǏFc ` uO/Z|\5~S\~ TP<3+sނ`¢sВ4:*Z^\{TzTP3dd>פ[bi9aaܕu_X~21[rUEߊ׹2}ه+!٧D`~gOKqW)7 z}Po *xr_ =Kk-q薀xou X獩F.YT&O XH衝U)F~03~,#TPAT(YqIENDB`PK\@4type/ddmarker/tests/fixtures/testquestion.moodle.xmlnu[ $course$/Default for C1 Milton Keynes landmarks Please place the markers on the map of Milton Keynes and be aware that there is more than one railway station. The Open University is at the junction of Brickhill Street and Groveway. There are three railway stations, Wolverton, Milton Keynes Central and Bletchley.

    ]]>
    1.0000000 0.3333333 0 Your answer is correct.

    ]]>
    Your answer is partially correct.

    ]]>
    Your answer is incorrect.

    ]]>
    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 1 OU 1 2 Railway station 3 1 circle 322,213;10 1 2 circle 144,84;10 2 3 circle 195,180;10 2 4 circle 267,302;10 2 You should be looking to place four markers on the map.

    ]]>
    You should be looking to place markers on three railway stations.

    ]]>
    PK\n:7:7%type/ddmarker/tests/question_test.phpnu[. namespace qtype_ddmarker; use question_attempt_step; use question_classified_response; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddmarker/tests/helper.php'); /** * Unit tests for the drag-and-drop markers question definition class. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \basic_testcase { public function test_get_question_summary(): void { $dd = \test_question_maker::make_question('ddmarker'); $this->assertEquals('The quick brown fox jumped over the lazy dog.; '. '[[Drop zone 1]] -> {quick / fox / lazy}; '. '[[Drop zone 2]] -> {quick / fox / lazy}; '. '[[Drop zone 3]] -> {quick / fox / lazy}', $dd->get_question_summary()); } public function test_get_question_summary_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $this->assertEquals('Fill in the operators to make this equation work:; '. '[[Drop zone 1]] -> {+ / - / * / /}; '. '[[Drop zone 2]] -> {+ / - / * / /}; '. '[[Drop zone 3]] -> {+ / - / * / /}', $dd->get_question_summary()); } public function test_summarise_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals('{Drop zone 1 -> quick}, '. '{Drop zone 2 -> fox}, '. '{Drop zone 3 -> lazy}', $dd->summarise_response(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '50,150'))); } public function test_summarise_response_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals('{Drop zone 1 -> +}, '. '{Drop zone 2 -> +}, '. '{Drop zone 3 -> +}', $dd->summarise_response(array('c1' => '50,50;150,50;50,150', 'c2' => '', 'c3' => ''))); } public function test_get_random_guess_score(): void { $dd = \test_question_maker::make_question('ddmarker'); $this->assertEquals(null, $dd->get_random_guess_score()); } public function test_get_random_guess_score_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $this->assertEquals(null, $dd->get_random_guess_score()); } public function test_get_right_choice_for(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(1, $dd->get_right_choice_for(1)); $this->assertEquals(2, $dd->get_right_choice_for(2)); $this->assertEquals(3, $dd->get_right_choice_for(3)); } public function test_get_right_choice_for_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(1, $dd->get_right_choice_for(1)); $this->assertEquals(1, $dd->get_right_choice_for(2)); $this->assertEquals(1, $dd->get_right_choice_for(3)); } public function test_clear_wrong_from_response(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $initialresponse = array('c1' => '50,50', 'c2' => '100,100', 'c3' => '100,100;200,200'); $this->assertEquals(array('c1' => '50,50', 'c2' => '', 'c3' => ''), $dd->clear_wrong_from_response($initialresponse)); } public function test_get_num_parts_right(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); // The second returned param in array is the max of correct choices or // the actual number of items dragged. $response1 = array('c1' => '50,50', 'c2' => '110,110', 'c3' => '90,90;210,210'); $this->assertEquals(array(1, 4), $dd->get_num_parts_right($response1)); $response2 = array('c1' => '50,50;150,50;50,150', 'c2' => '110,110', 'c3' => '90,90;210,210'); $this->assertEquals(array(1, 6), $dd->get_num_parts_right($response2)); $response3 = array('c1' => '50,50;150,50;50,150', 'c2' => '', 'c3' => ''); $this->assertEquals(array(1, 3), $dd->get_num_parts_right($response3)); } public function test_get_num_parts_right_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(3, 3), $dd->get_num_parts_right(array( 'c1' => '50,50;150,50;50,150', 'c2' => '', 'c3' => ''))); } public function test_get_expected_data(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals( array('c1' => PARAM_NOTAGS, 'c2' => PARAM_NOTAGS, 'c3' => PARAM_NOTAGS), $dd->get_expected_data() ); } public function test_get_correct_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150'), $dd->get_correct_response()); } public function test_get_correct_response_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array('c1' => '50,50;150,50;50,150'), $dd->get_correct_response()); } public function test_is_same_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertTrue($dd->is_same_response( array(), array('c1' => '', 'c2' => '', 'c3' => '', 'c4' => ''))); $this->assertFalse($dd->is_same_response( array(), array('c1' => '100,100', 'c2' => '', 'c3' => '', 'c4' => ''))); $this->assertFalse($dd->is_same_response( array('c1' => '', 'c2' => '', 'c3' => '', 'c4' => ''), array('c1' => '100,100', 'c2' => '', 'c3' => '', 'c4' => ''))); $this->assertTrue($dd->is_same_response( array('c1' => '100,100', 'c2' => '2', 'c3' => '3', 'c4' => '400,400'), array('c1' => '100,100', 'c2' => '2', 'c3' => '3', 'c4' => '400,400'))); $this->assertFalse($dd->is_same_response( array('c1' => '100,100', 'c2' => '200,200', 'c3' => '300,300', 'c4' => '400,400'), array('c1' => '100,100', 'c2' => '200,200', 'c3' => '200,200', 'c4' => '400,400'))); $this->assertTrue($dd->is_same_response( array('c1' => '100,100;200,200', 'c2' => '', 'c3' => '100,100;300,300', 'c4' => '400,400'), array('c1' => '200,200;100,100', 'c2' => '', 'c3' => '300,300;100,100', 'c4' => '400,400'))); $this->assertFalse($dd->is_same_response( array('c1' => '100,100;200,200', 'c2' => '', 'c3' => '100,100;400,300', 'c4' => '400,400'), array('c1' => '200,200;100,100', 'c2' => '', 'c3' => '300,300;100,100', 'c4' => '400,400'))); $this->assertTrue($dd->is_same_response( array('c1' => '100,100;100,100;200,200', 'c2' => '', 'c3' => '100,100;300,300', 'c4' => '400,400'), array('c1' => '200,200;100,100;100,100', 'c2' => '', 'c3' => '300,300;100,100', 'c4' => '400,400'))); $this->assertFalse($dd->is_same_response( array('c1' => '100,100;100,100;200,200', 'c2' => '', 'c3' => '100,100;300,300', 'c4' => '400,400'), array('c1' => '200,200;100,100', 'c2' => '', 'c3' => '300,300;100,100', 'c4' => '400,400'))); } public function test_is_complete_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertFalse($dd->is_complete_response(array())); $this->assertFalse($dd->is_complete_response( array('c1' => '', 'c2' => '', 'c3' => ''))); $this->assertFalse($dd->is_complete_response(array('c1' => ''))); $this->assertTrue($dd->is_complete_response( array('c1' => '300,300', 'c2' => '300,300', 'c3' => '300,300'))); } public function test_is_gradable_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->start_attempt(new question_attempt_step(), 1); $this->assertFalse($dd->is_gradable_response(array())); $this->assertFalse($dd->is_gradable_response( array('c1' => '', 'c2' => '', 'c3' => ''))); $this->assertTrue($dd->is_gradable_response( array('c1' => '300,300', 'c2' => '300,300', 'c3' => ''))); $this->assertTrue($dd->is_gradable_response(array('c1' => '300,300'))); $this->assertTrue($dd->is_gradable_response( array('c1' => '300,300', 'c2' => '300,300', 'c3' => '300,300'))); } public function test_grading(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(1, question_state::$gradedright), $dd->grade_response(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150'))); $this->assertEquals(array(2 / 3, question_state::$gradedpartial), $dd->grade_response(array('c1' => '50,50', 'c2' => '50,50', 'c3' => '100,150'))); $this->assertEquals(array(0, question_state::$gradedwrong), $dd->grade_response(array('c1' => '150,50', 'c2' => '50,50', 'c3' => '100,50'))); } public function test_grading_maths(): void { $dd = \test_question_maker::make_question('ddmarker', 'maths'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array(1, question_state::$gradedright), $dd->grade_response(array('c1' => '50,50;150,50;50,150', 'c2' => '', 'c3' => ''))); $this->assertEquals(array(0.75, question_state::$gradedpartial), $dd->grade_response(array('c1' => '50,50;150,50;50,150', 'c2' => '', 'c3' => '50,150'))); $this->assertEquals(array(0, question_state::$gradedwrong), $dd->grade_response(array('c1' => '', 'c2' => '50,50;150,50', 'c3' => '100,50'))); $this->assertEquals(array(0, question_state::$gradedwrong), $dd->grade_response(array('c1' => '300,300', 'c2' => '50,50;150,50', 'c3' => '100,50'))); } public function test_classify_response(): void { $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $dd->start_attempt(new question_attempt_step(), 1); $this->assertEquals(array( 1 => new question_classified_response(1, 'quick', 1 / 3), 2 => new question_classified_response(2, 'fox', 1 / 3), 3 => new question_classified_response(3, 'lazy', 1 / 3)), $dd->classify_response(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150'))); $this->assertEquals(array( 1 => new question_classified_response(1, 'quick', 1 / 3), 2 => question_classified_response::no_response(), 3 => question_classified_response::no_response()), $dd->classify_response(array('c1' => '50,50', 'c2' => '100,150', 'c3' => '150,50'))); } } PK\#?\+type/ddmarker/tests/form/edit_form_test.phpnu[. namespace qtype_ddmarker\form; use qtype_ddmarker_edit_form; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/ddmarker/edit_ddmarker_form.php'); /** * Unit tests for the drag-and-drop markers edit form. * * @package qtype_ddmarker * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class edit_form_test extends \advanced_testcase { /** * Helper method. * * @return array with two elements: * question_edit_form great a question form instance that can be tested. * stdClass the question category. */ protected function get_form() { $this->setAdminUser(); $this->resetAfterTest(); $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $bankcontext = \context_module::instance($qbank->cmid); $category = question_get_default_category($bankcontext->id, true); $fakequestion = new \stdClass(); $fakequestion->qtype = 'ddmarker'; $fakequestion->contextid = $bankcontext->id; $fakequestion->createdby = 2; $fakequestion->category = $category->id; $fakequestion->questiontext = 'Test question'; $fakequestion->options = new \stdClass(); $fakequestion->options->answers = array(); $fakequestion->formoptions = new \stdClass(); $fakequestion->formoptions->movecontext = null; $fakequestion->formoptions->repeatelements = true; $fakequestion->inputs = null; $form = new qtype_ddmarker_edit_form(new \moodle_url('/'), $fakequestion, $category, new \core_question\local\bank\question_edit_contexts($bankcontext)); return [$form, $category]; } /** * Test the form correctly validates the HTML allowed in items. */ public function test_item_validation(): void { list($form, $category) = $this->get_form(); $submitteddata = [ 'category' => $category->id, 'bgimage' => 0, 'nodropzone' => 0, 'noitems' => 4, 'drags' => [ ['label' => 'frog'], ['label' => 'toad'], ['label' => 'chien'], ['label' => ''], ], ]; $errors = $form->validation($submitteddata, []); $this->assertArrayNotHasKey('drags[0]', $errors); $this->assertArrayNotHasKey('drags[1]', $errors); $this->assertArrayNotHasKey('drags[2]', $errors); $this->assertEquals('Only "<br><i><em><b><strong>' . '<sup><sub><u><span>" tags are allowed in the label for a marker.', $errors['drags[3]']); } } PK\~V-type/ddmarker/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package qtype_ddmarker * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_ddmarker\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\user_preference_provider; use qtype_ddmarker\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ddmarker/classes/privacy/provider.php'); /** * Privacy provider tests class. * * @package qtype_ddmarker * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_ddmarker'); $actual = \qtype_ddmarker\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_ddmarker_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_ddmarker'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_ddmarker_{$key}", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_ddmarker'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the multiple choice question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'default mark 1.5' => ['defaultmark', 1.5, 1.5], 'penalty 33.33333%' => ['penalty', 0.3333333, '33.33333%'], 'shuffle yes' => ['shuffleanswers', 1, 'Yes'], 'shuffle no' => ['shuffleanswers', 0, 'No'] ]; } } PK\Mi  *type/ddmarker/tests/question_type_test.phpnu[. namespace qtype_ddmarker; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddmarker/tests/helper.php'); /** * Unit tests for the drag-and-drop markers question definition class. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \advanced_testcase { /** @var qtype_ddmarker instance of the question type class to test. */ protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = question_bank::get_qtype('ddmarker');; } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'ddmarker'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_save_question(): void { $this->resetAfterTest(); $this->setAdminUser(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(array()); $dd = $questiongenerator->create_question('ddmarker', 'zerodrag', array('category' => $cat->id)); $actual = question_bank::load_question_data($dd->id); $this->assertCount(2, $actual->options->drags); } } PK\_](type/ddmarker/tests/walkthrough_test.phpnu[. namespace qtype_ddmarker; use question_display_options; use question_hint_ddmarker; use question_pattern_expectation; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/ddmarker/tests/helper.php'); /** * Unit tests for the drag-and-drop markers question type. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_ddmarker_question * @covers \qtype_ddmarker_renderer * @covers \question_hint_ddmarker */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { /** * Get an expectation that the output contains a marker. * * @param int $choice which choice. * @param bool $infinite whether there are infinitely many of that choice. * @return \question_contains_tag_with_attributes the expectation. */ protected function get_contains_draggable_marker_home_expectation($choice, $infinite) { $class = 'marker choice'.$choice; if ($infinite) { $class .= ' infinite'; } $expectedattrs = array(); $expectedattrs['class'] = $class; return new \question_contains_tag_with_attributes('span', $expectedattrs); } /** * Get an expectation that the output contains a hidden input with certain name and optionally value. * * Like the parent method, but make it more specific to this question type. * * @param string $choiceno hidden field name. * @param string|null $value if passed, this value will also be asserted. * @return \question_contains_tag_with_attributes the expectation. */ protected function get_contains_hidden_expectation($choiceno, $value = null) { $name = $this->quba->get_field_prefix($this->slot) .'c'. $choiceno; $expectedattributes = array('type' => 'hidden', 'name' => s($name)); $expectedattributes['class'] = "choices choice{$choiceno}"; if (!is_null($value)) { $expectedattributes['value'] = s($value); } return new \question_contains_tag_with_attributes('input', $expectedattributes); } public function test_interactive_behaviour(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = array( new question_hint_ddmarker(13, 'This is the first hint.', FORMAT_HTML, false, false, false), new question_hint_ddmarker(14, 'This is the second hint.', FORMAT_HTML, true, true, false), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); $completelywrong = array('c1' => '0,250', 'c2' => '100,250', 'c3' => '150,250'); // Save the wrong answer. $this->process_submission($completelywrong); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '0,250'), $this->get_contains_hidden_expectation(2, '100,250'), $this->get_contains_hidden_expectation(3, '150,250'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Submit the wrong answer. $this->process_submission($completelywrong + array('-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '0,250'), $this->get_contains_hidden_expectation(2, '100,250'), $this->get_contains_hidden_expectation(3, '150,250'), $this->get_contains_try_again_button_expectation(true), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '0,250'), $this->get_contains_hidden_expectation(2, '100,250'), $this->get_contains_hidden_expectation(3, '150,250'), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission( array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(8); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, '100,150'), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(8); } public function test_deferred_feedback(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission(array('c1' => '150,50', 'c2' => '50,50')); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '150,50'), $this->get_contains_hidden_expectation(2, '50,50'), $this->get_contains_hidden_expectation(3, ''), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save the right answer. $this->process_submission( array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150')); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, '100,150'), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(12); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, '100,150'), $this->get_contains_correct_expectation()); // Change the right answer a bit. $dd->rightchoices[2] = 1; // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(8); } public function test_deferred_feedback_unanswered(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save a blank response. $this->process_submission(array('c1' => '', 'c2' => '', 'c3' => '')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, ''), $this->get_contains_hidden_expectation(2, ''), $this->get_contains_hidden_expectation(3, ''), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false)); } public function test_deferred_feedback_partial_answer(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->process_submission(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '')); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, ''), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_partcorrect_expectation()); } public function test_interactive_grading(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = array( new question_hint_ddmarker(1, 'This is the first hint.', FORMAT_MOODLE, true, true, false), new question_hint_ddmarker(2, 'This is the second hint.', FORMAT_MOODLE, true, true, false), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 12); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->assertEquals('interactivecountback', $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation()); // Submit an response with the first two parts right. $this->process_submission( array('c1' => '50,50', 'c2' => '150,50', 'c3' => '150,50', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the first hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, '150,50')); // Check that extract responses will return the reset data. $prefix = $this->quba->get_field_prefix($this->slot); $this->assertEquals(array('c1' => '50,50', 'c2' => '150,50'), $this->quba->extract_responses($this->slot, array($prefix . 'c1' => '50,50', $prefix . 'c2' => '150,50', '-tryagain' => 1))); // Do try again. // keys c3 is an extra hidden fields to clear data. $this->process_submission( array('c1' => '50,50', 'c2' => '150,50', 'c3' => '', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, ''), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit an response with the first and last parts right. $this->process_submission( array('c1' => '50,50', 'c2' => '150,150', 'c3' => '100,150', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_hint_expectation('This is the second hint'), $this->get_contains_num_parts_correct(2), $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,150'), $this->get_contains_hidden_expectation(3, '100,150')); // Do try again. $this->process_submission( array('c1' => '50,50', 'c2' => '', 'c3' => '', '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, ''), $this->get_contains_hidden_expectation(3, ''), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_try_again_button_expectation(), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(1), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission( array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(8); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, '50,50'), $this->get_contains_hidden_expectation(2, '150,50'), $this->get_contains_hidden_expectation(3, '100,150'), $this->get_does_not_contain_submit_button_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_contains_standard_correct_combined_feedback_expectation()); } public function test_interactive_correct_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = array( new question_hint_ddmarker(23, 'This is the first hint.', FORMAT_MOODLE, false, false, false), new question_hint_ddmarker(24, 'This is the second hint.', FORMAT_MOODLE, true, true, false), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the right answer. $this->process_submission(array('c1' => '50,50', 'c2' => '150,50', 'c3' => '100,150')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); } public function test_interactive_partial_no_submit(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = array( new question_hint_ddmarker(23, 'This is the first hint.', FORMAT_MOODLE, false, false, false), new question_hint_ddmarker(24, 'This is the second hint.', FORMAT_MOODLE, true, true, false), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a partially right answer. $this->process_submission(array('c1' => '50,50', 'c2' => '50,50', 'c3' => '100,150')); // Finish the attempt without clicking check. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_partcorrect_expectation(), $this->get_no_hint_visible_expectation()); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedpartial); $this->check_current_mark(2); } public function test_interactive_no_right_clears(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = array( new question_hint_ddmarker(23, 'This is the first hint.', FORMAT_MOODLE, false, true, false), new question_hint_ddmarker(24, 'This is the second hint.', FORMAT_MOODLE, true, true, false), ); $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the a completely wrong answer. $this->process_submission( array('c1' => '100,150', 'c2' => '100,150', 'c3' => '50,50', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint')); // Do try again. $this->process_submission( array('c1' => '', 'c2' => '', 'c3' => '', '-tryagain' => 1)); // Check that all the wrong answers have been cleared. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1, ''), $this->get_contains_hidden_expectation(2, ''), $this->get_contains_hidden_expectation(3, ''), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); } public function test_display_of_right_answer_when_shuffled(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $this->start_attempt_at_question($dd, 'deferredfeedback', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_does_not_contain_feedback_expectation()); // Save a partial answer. $this->process_submission($dd->get_correct_response()); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $rightanswer = array($dd->get_right_choice_for(1) => '50,50', $dd->get_right_choice_for(2) => '150,50', $dd->get_right_choice_for(3) => '100,150'); $this->check_current_output( $this->get_contains_hidden_expectation(1, $rightanswer[1]), $this->get_contains_hidden_expectation(2, $rightanswer[2]), $this->get_contains_hidden_expectation(3, $rightanswer[3]), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->displayoptions->rightanswer = question_display_options::VISIBLE; $this->assertEquals('{Drop zone 1 -> quick}, '. '{Drop zone 2 -> fox}, '. '{Drop zone 3 -> lazy}', $dd->get_right_answer_summary()); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); } public function test_interactive_state_which_incorrect(): void { // Create a drag-and-drop question. $dd = \test_question_maker::make_question('ddmarker'); $dd->hints = [ new question_hint_ddmarker(23, 'This is the first hint.', FORMAT_MOODLE, false, true, true), ]; $dd->shufflechoices = false; $this->start_attempt_at_question($dd, 'interactive', 2); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_contains_hidden_expectation(1), $this->get_contains_hidden_expectation(2), $this->get_contains_hidden_expectation(3), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Save the a completely wrong answer. $this->process_submission( ['c1' => '100,150', 'c2' => '100,150', 'c3' => '50,50', '-submit' => 1]); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_draggable_marker_home_expectation(1, false), $this->get_contains_draggable_marker_home_expectation(2, false), $this->get_contains_draggable_marker_home_expectation(3, false), $this->get_does_not_contain_submit_button_expectation(), new question_pattern_expectation('~' . preg_quote( '
    Markers placed in the wrong area: ' . 'quick, fox, ' . 'lazy', '~') . '~'), $this->get_contains_hint_expectation('This is the first hint')); } } PK\~#} #type/ddmarker/tests/shapes_test.phpnu[. namespace qtype_ddmarker; use qtype_ddmarker_shape_circle; use qtype_ddmarker_shape_polygon; use qtype_ddmarker_shape_rectangle; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/ddmarker/shapes.php'); /** * Unit tests for the drag-and-drop words shape code. * * @package qtype_ddmarker * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class shapes_test extends \basic_testcase { public function test_polygon_valdiation_test_ok(): void { $shape = new qtype_ddmarker_shape_polygon('10, 10; 20, 10; 20, 20; 10, 20'); $this->assertFalse($shape->get_coords_interpreter_error()); // No errors. } public function test_polygon_valdiation_test_only_two_points(): void { $shape = new qtype_ddmarker_shape_polygon('10, 10; 20, 10'); $this->assertEquals(get_string('formerror_polygonmusthaveatleastthreepoints', 'qtype_ddmarker', array('shape' => 'polygon', 'coordsstring' => get_string('shape_polygon_coords', 'qtype_ddmarker'))), $shape->get_coords_interpreter_error()); } public function test_polygon_valdiation_test_invalid_point(): void { $shape = new qtype_ddmarker_shape_polygon('10, 10; 20, ; 20, 20; 10, 20'); $this->assertEquals(get_string('formerror_onlyusewholepositivenumbers', 'qtype_ddmarker', array('shape' => 'polygon', 'coordsstring' => get_string('shape_polygon_coords', 'qtype_ddmarker'))), $shape->get_coords_interpreter_error()); } public function test_polygon_valdiation_test_repeated_point(): void { $shape = new qtype_ddmarker_shape_polygon('70,220;90,200;95,150;120,150;140,200;150,230;'. '150,230;150,240;120,240;110,240;90,240'); $this->assertEquals(get_string('formerror_repeatedpoint', 'qtype_ddmarker', array('shape' => 'polygon', 'coordsstring' => get_string('shape_polygon_coords', 'qtype_ddmarker'))), $shape->get_coords_interpreter_error()); } public function test_polygon_hit_test(): void { $shape = new qtype_ddmarker_shape_polygon('10, 10; 20, 10; 20, 20; 10, 20'); $this->assertTrue($shape->is_point_in_shape(array(15, 15))); $this->assertFalse($shape->is_point_in_shape(array(5, 5))); $this->assertFalse($shape->is_point_in_shape(array(5, 15))); $this->assertFalse($shape->is_point_in_shape(array(15, 25))); $this->assertFalse($shape->is_point_in_shape(array(25, 15))); $this->assertTrue($shape->is_point_in_shape(array(11, 11))); $this->assertTrue($shape->is_point_in_shape(array(19, 19))); // Test points right on the edge are in. $this->assertTrue($shape->is_point_in_shape(array(10, 10))); $this->assertTrue($shape->is_point_in_shape(array(10, 20))); $this->assertTrue($shape->is_point_in_shape(array(20, 20))); $this->assertTrue($shape->is_point_in_shape(array(20, 10))); $this->assertTrue($shape->is_point_in_shape(array(10, 15))); $this->assertTrue($shape->is_point_in_shape(array(15, 10))); $this->assertTrue($shape->is_point_in_shape(array(20, 15))); $this->assertTrue($shape->is_point_in_shape(array(15, 20))); // Should accept closed polygon coords or unclosed and it will model a closed polygon. $shape = new qtype_ddmarker_shape_polygon('10, 10; 20, 10; 20, 20; 10, 20; 10, 10'); $this->assertTrue($shape->is_point_in_shape(array(15, 15))); $this->assertFalse($shape->is_point_in_shape(array(5, 5))); $this->assertFalse($shape->is_point_in_shape(array(5, 15))); $this->assertFalse($shape->is_point_in_shape(array(15, 25))); $this->assertFalse($shape->is_point_in_shape(array(25, 15))); $this->assertTrue($shape->is_point_in_shape(array(11, 11))); $this->assertTrue($shape->is_point_in_shape(array(19, 19))); $shape = new qtype_ddmarker_shape_polygon('10, 10; 15, 5; 20, 10; 20, 20; 10, 20'); $this->assertTrue($shape->is_point_in_shape(array(15, 15))); $this->assertFalse($shape->is_point_in_shape(array(5, 5))); $this->assertFalse($shape->is_point_in_shape(array(5, 15))); $this->assertFalse($shape->is_point_in_shape(array(15, 25))); $this->assertFalse($shape->is_point_in_shape(array(25, 15))); $this->assertTrue($shape->is_point_in_shape(array(11, 11))); $this->assertTrue($shape->is_point_in_shape(array(19, 19))); $this->assertTrue($shape->is_point_in_shape(array(15, 9))); $this->assertTrue($shape->is_point_in_shape(array(15, 10))); $shape = new qtype_ddmarker_shape_polygon('15, 5; 20, 10; 20, 20; 10, 20; 10, 10'); $this->assertTrue($shape->is_point_in_shape(array(15, 10))); $shape = new qtype_ddmarker_shape_polygon('15, 5; 20, 10; 20, 20; 10, 20; 10, 10'); $this->assertFalse($shape->is_point_in_shape(array(25, 10))); $shape = new qtype_ddmarker_shape_polygon('0, 0; 500, 0; 600, 1000; 0, 1200; 10, 10'); $this->assertTrue($shape->is_point_in_shape(array(25, 10))); } public function test_circle_valdiation_test(): void { $shape = new qtype_ddmarker_shape_circle('10, 10; 10'); $this->assertFalse($shape->get_coords_interpreter_error()); // No errors. } public function test_circle_hit_test(): void { $shape = new qtype_ddmarker_shape_circle('10, 10; 10'); $this->assertTrue($shape->is_point_in_shape(array(19, 10))); $this->assertTrue($shape->is_point_in_shape(array(20, 10))); $this->assertFalse($shape->is_point_in_shape(array(21, 10))); $this->assertTrue($shape->is_point_in_shape(array(10, 1))); $this->assertTrue($shape->is_point_in_shape(array(10, 0))); $this->assertFalse($shape->is_point_in_shape(array(10, -1))); $this->assertFalse($shape->is_point_in_shape(array(15, 25))); $this->assertFalse($shape->is_point_in_shape(array(25, 15))); $this->assertTrue($shape->is_point_in_shape(array(11, 11))); $this->assertTrue($shape->is_point_in_shape(array(1, 10))); $this->assertTrue($shape->is_point_in_shape(array(17, 17))); $this->assertTrue($shape->is_point_in_shape(array(3, 3))); $this->assertFalse($shape->is_point_in_shape(array(2, 2))); // Should be exactly on the boundary - 3, 4, 5 right-angled triangle. $this->assertTrue($shape->is_point_in_shape(array(16, 18))); } public function test_rectangle_valdiation_test(): void { $shape = new qtype_ddmarker_shape_rectangle('1000, 4000; 500, 400'); $this->assertFalse($shape->get_coords_interpreter_error()); // No errors. } public function test_rectangle_hit_test(): void { $shape = new qtype_ddmarker_shape_rectangle('1000, 4000; 500, 400'); $this->assertFalse($shape->is_point_in_shape(array(999, 4200))); $this->assertTrue($shape->is_point_in_shape(array(1000, 4200))); $this->assertTrue($shape->is_point_in_shape(array(1001, 4200))); $this->assertTrue($shape->is_point_in_shape(array(1499, 4200))); $this->assertTrue($shape->is_point_in_shape(array(1500, 4200))); $this->assertFalse($shape->is_point_in_shape(array(1501, 4200))); $this->assertFalse($shape->is_point_in_shape(array(1250, 3999))); $this->assertTrue($shape->is_point_in_shape(array(1250, 4000))); $this->assertTrue($shape->is_point_in_shape(array(1250, 4400))); $this->assertFalse($shape->is_point_in_shape(array(1250, 4401))); } } PK\b@..type/ddmarker/tests/helper.phpnu[. /** * Test helpers for the drag-and-drop markers question type. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the drag-and-drop markers question type. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_test_helper extends question_test_helper { public function get_test_questions() { return ['fox', 'maths', 'mkmap', 'zerodrag', 'mathjax']; } /** * @return qtype_ddmarker_question */ public function make_ddmarker_question_fox() { question_bank::load_question_definition_classes('ddmarker'); $dd = new qtype_ddmarker_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop markers question'; $dd->questiontext = 'The quick brown fox jumped over the lazy dog.'; $dd->generalfeedback = 'This sentence uses each letter of the alphabet.'; $dd->qtype = question_bank::get_qtype('ddmarker'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = $this->make_choice_structure(array( new qtype_ddmarker_drag_item('quick', 1, 0, 1), new qtype_ddmarker_drag_item('fox', 2, 0, 1), new qtype_ddmarker_drag_item('lazy', 3, 0, 1) )); $dd->places = $this->make_place_structure(array( new qtype_ddmarker_drop_zone(1, 'circle', '50,50;50'), new qtype_ddmarker_drop_zone(2, 'rectangle', '100,0;100,100'), new qtype_ddmarker_drop_zone(3, 'polygon', '0,100;200,100;200,200;0,200') )); $dd->rightchoices = array(1 => 1, 2 => 2, 3 => 3); return $dd; } protected function make_choice_structure($choices) { $choicestructure = array(); foreach ($choices as $choice) { $group = $choice->choice_group(); if (!isset($choicestructure[$group])) { $choicestructure[$group] = array(); } $choicestructure[$group][$choice->no] = $choice; } return $choicestructure; } protected function make_place_structure($places) { $placestructure = array(); foreach ($places as $place) { $placestructure[$place->no] = $place; } return $placestructure; } /** * @return qtype_ddmarker_question */ public function make_ddmarker_question_maths() { question_bank::load_question_definition_classes('ddmarker'); $dd = new qtype_ddmarker_question(); test_question_maker::initialise_a_question($dd); $dd->name = 'Drag-and-drop markers question'; $dd->questiontext = 'Fill in the operators to make this equation work: '; $dd->generalfeedback = 'Hmmmm...'; $dd->qtype = question_bank::get_qtype('ddmarker'); $dd->shufflechoices = true; test_question_maker::set_standard_combined_feedback_fields($dd); $dd->choices = $this->make_choice_structure(array( new qtype_ddmarker_drag_item('+', 1, 1, 0), new qtype_ddmarker_drag_item('-', 2, 1, 0), new qtype_ddmarker_drag_item('*', 3, 1, 0), new qtype_ddmarker_drag_item('/', 4, 1, 0) )); $dd->places = $this->make_place_structure(array( new qtype_ddmarker_drop_zone(1, 'circle', '50,50;50'), new qtype_ddmarker_drop_zone(2, 'rectangle', '100,0;100,100'), new qtype_ddmarker_drop_zone(3, 'polygon', '0,100;100,100;100,200;0,200') )); $dd->rightchoices = array(1 => 1, 2 => 1, 3 => 1); return $dd; } /** * @return stdClass date to create a ddmarkers question. */ public function get_ddmarker_question_form_data_mkmap() { global $CFG, $USER; $fromform = new stdClass(); $bgdraftitemid = 0; file_prepare_draft_area($bgdraftitemid, null, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = context_user::instance($USER->id)->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $bgdraftitemid; $filerecord->filepath = '/'; $filerecord->filename = 'mkmap.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/ddmarker/tests/fixtures/mkmap.png'); $fromform->name = 'Milton Keynes landmarks'; $fromform->questiontext = array( 'text' => 'Please place the markers on the map of Milton Keynes and be aware that '. 'there is more than one railway station.', 'format' => FORMAT_HTML, ); $fromform->defaultmark = 1; $fromform->generalfeedback = array( 'text' => 'The Open University is at the junction of Brickhill Street and Groveway. '. 'There are three railway stations, Wolverton, Milton Keynes Central and Bletchley.', 'format' => FORMAT_HTML, ); $fromform->bgimage = $bgdraftitemid; $fromform->shuffleanswers = 0; $fromform->drags = array( array('label' => 'OU', 'noofdrags' => 1), array('label' => 'Railway station', 'noofdrags' => 3), ); $fromform->drops = array( array('shape' => 'circle', 'coords' => '322,213;10', 'choice' => 1), array('shape' => 'circle', 'coords' => '144,84;10', 'choice' => 2), array('shape' => 'circle', 'coords' => '195,180;10', 'choice' => 2), array('shape' => 'circle', 'coords' => '267,302;10', 'choice' => 2), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->penalty = '0.3333333'; $fromform->hint = array( array( 'text' => 'You are trying to place four markers on the map.', 'format' => FORMAT_HTML, ), array( 'text' => 'You are trying to mark three railway stations.', 'format' => FORMAT_HTML, ), ); $fromform->hintshownumcorrect = array(1, 1); $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Return the test data needed by the question generator to create question tempplate. * * @return stdClass date to create a ddmarkers question. */ public function get_ddmarker_question_form_data_mathjax() { global $CFG, $USER; $fromform = new stdClass(); $bgdraftitemid = 0; file_prepare_draft_area($bgdraftitemid, null, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = context_user::instance($USER->id)->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $bgdraftitemid; $filerecord->filepath = '/'; $filerecord->filename = 'mkmap.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/ddmarker/tests/fixtures/mkmap.png'); $fromform->name = 'Drag-and-drop into maker question with equation'; $fromform->questiontext = ['text' => 'Fill in the correct mathjax equation: y = 2, x =4', 'format' => FORMAT_HTML]; $fromform->defaultmark = 1; $fromform->generalfeedback = ['text' => 'The right answer is: "y = x^2"', 'format' => FORMAT_HTML]; $fromform->showmisplaced = 1; $fromform->bgimage = $bgdraftitemid; $fromform->shuffleanswers = 0; $fromform->drags = [ ['label' => '$$ y = x^2 $$', 'noofdrags' => '1'], ['label' => '$$ y = x^5 $$', 'noofdrags' => '1'], ]; $fromform->drops = [ ['shape' => 'circle', 'coords' => '322,213;10', 'choice' => 1], ['shape' => 'circle', 'coords' => '144,84;10', 'choice' => 2], ]; test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->penalty = '0.3333333'; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Return the test data needed by the question generator (the data that * would come from saving the editing form). * @return stdClass date to create a ddmarkers question where one of the drag items has text '0'. */ public function get_ddmarker_question_form_data_zerodrag() { global $CFG, $USER; $fromform = new stdClass(); $bgdraftitemid = 0; file_prepare_draft_area($bgdraftitemid, null, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = context_user::instance($USER->id)->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $bgdraftitemid; $filerecord->filepath = '/'; $filerecord->filename = 'mkmap.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/ddmarker/tests/fixtures/mkmap.png'); $fromform->name = 'Drag digits'; $fromform->questiontext = array( 'text' => 'Put 0 in the left of the image, and 1 in the right.', 'format' => FORMAT_HTML, ); $fromform->defaultmark = 2; $fromform->generalfeedback = array( 'text' => '', 'format' => FORMAT_HTML, ); $fromform->bgimage = $bgdraftitemid; $fromform->shuffleanswers = 0; $fromform->drags = array( array('label' => '0', 'noofdrags' => 1), array('label' => '1', 'noofdrags' => 1), ); $fromform->drops = array( array('shape' => 'Rectangle', 'coords' => '0,0;272,389', 'choice' => 1), array('shape' => 'Rectangle', 'coords' => '272,0;272,389', 'choice' => 2), ); test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->penalty = '0.3333333'; $fromform->hint = array( array( 'text' => 'Hint 1.', 'format' => FORMAT_HTML, ), array( 'text' => 'Hint 2.', 'format' => FORMAT_HTML, ), ); $fromform->hintshownumcorrect = array(1, 1); $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } } PK\ CC2type/ddmarker/tests/behat/behat_qtype_ddmarker.phpnu[. /** * Behat steps definitions for drag and drop markers. * * @package qtype_ddmarker * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); /** * Steps definitions related with the drag and drop markers question type. * * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_qtype_ddmarker extends behat_base { /** * Get the xpath for a given drag item. * * @param string $marker the text of the item to drag. * @param bool $iskeyboard is using keyboard or not. * @return string the xpath expression. */ protected function marker_xpath($marker, $iskeyboard = false) { if ($iskeyboard) { return '//span[contains(@class, "marker") and not(contains(@class, "dragplaceholder")) ' . 'and span[@class = "markertext" and contains(normalize-space(.), "' . $this->escape($marker) . '")]]'; } return '//span[contains(@class, "marker") and contains(@class, "unneeded") ' . 'and not(contains(@class, "dragplaceholder")) and span[@class = "markertext" and contains(normalize-space(.), "' . $this->escape($marker) . '")]]'; } /** * Drag the drag item with the given text to the given space. * * @param string $marker the marker to drag. The label, optionally followed by , (int) if relevant. * @param string $coordinates the position to drag the marker to, 'x,y'. * * @Given /^I drag "(?P[^"]*)" to "(?P\d+,\d+)" in the drag and drop markers question$/ */ public function i_drag_to_in_the_drag_and_drop_markers_question($marker, $coordinates) { list($x, $y) = explode(',', $coordinates); // This is a bit nasty, but Behat (indeed Selenium) will only drag on // DOM node so that its centre is over the centre of anothe DOM node. // Therefore to make it drag to the specified place, we have to add // a target div. // We also need to scroll the marker into view so we can calculate the correct offsetHeight and offsetWidth. $markerxpath = $this->marker_xpath($marker); $this->execute_script(" (function() { require(['core/pending'], function(Pending) { if (document.getElementById('target-{$x}-{$y}')) { return; } const pendingPromise = new Pending('qtype_ddmarker:drag-drop'); const image = document.querySelector('.dropbackground'); const target = document.createElement('div'); target.setAttribute('id', 'target-{$x}-{$y}'); const container = document.querySelector('.droparea'); container.insertBefore(target, image); const widthRatio = image.offsetWidth / image.naturalWidth; const heightRatio = image.offsetHeight / image.naturalHeight; const marker = document.evaluate('{$markerxpath}', document, null, XPathResult.ANY_TYPE, null).iterateNext(); marker.scrollIntoView(); const xadjusted = {$x} * widthRatio + (container.offsetWidth - image.offsetWidth) / 2 + marker.offsetWidth / 2; const yadjusted = {$y} * heightRatio + (container.offsetHeight - image.offsetHeight) / 2 + marker.offsetHeight / 2; target.style.setProperty('position', 'absolute'); target.style.setProperty('left', xadjusted + 'px'); target.style.setProperty('top', yadjusted + 'px'); target.style.setProperty('width', '1px'); target.style.setProperty('height', '1px'); pendingPromise.resolve(); }); }())" ); $generalcontext = behat_context_helper::get('behat_general'); $generalcontext->i_drag_and_i_drop_it_in($markerxpath, 'xpath_element', "#target-{$x}-{$y}", 'css_element'); } /** * Type some characters while focussed on a given drop box. * * @param string $direction the direction key to press. * @param int $ * @param string $marker the marker to drag. The label, optionally followed by , (int) if relevant. * * @Given /^I type "(?Pup|down|left|right)" "(?P\d+)" times on marker "(?P[^"]*)" in the drag and drop markers question$/ */ public function i_type_on_marker_in_the_drag_and_drop_markers_question($direction, $repeats, $marker) { $node = $this->get_selected_node('xpath_element', $this->marker_xpath($marker, true)); $this->ensure_node_is_visible($node); $node->focus(); for ($i = 0; $i < $repeats; $i++) { $this->execute('behat_general::i_press_named_key', ['', $direction]); } } } PK\U-%type/ddmarker/tests/behat/add.featurenu[@qtype @qtype_ddmarker Feature: Test creating a drag and drop markers question As a teacher In order to test my students I need to be able to create drag and drop markers questions Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: Create a drag and drop markers question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Drag and drop markers" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I set the field "Question name" to "Drag and drop markers" And I set the field "Question text" to "Please place the markers on the map of Milton Keynes and be aware that there is more than one railway station." And I set the field "General feedback" to "The Open University is at the junction of Brickhill Street and Groveway. There are three railway stations, Wolverton, Milton Keynes Central and Bletchley." And I set the field "id_showmisplaced" to "1" And I upload "question/type/ddmarker/tests/fixtures/mkmap.png" file to "Background image" filemanager And I expand all fieldsets # Markers. And I set the field "id_shuffleanswers" to "1" And I set the field "id_drags_0_label" to "OU" And I set the field "id_drags_0_noofdrags" to "1" And I set the field "id_drags_1_label" to "Railway station" And I set the field "id_drags_1_noofdrags" to "3" # Drop zones. And I set the field "id_drops_0_shape" to "Circle" And I set the field "id_drops_0_coords" to "322,213;10" And I set the field "id_drops_0_choice" to "1" And I set the field "id_drops_1_shape" to "Circle" And I set the field "id_drops_1_coords" to "144,84;10" And I set the field "id_drops_1_choice" to "2" And I set the field "id_drops_2_shape" to "Circle" And I set the field "id_drops_2_coords" to "195,180;10" And I set the field "id_drops_2_choice" to "2" And I set the field "id_drops_3_shape" to "Circle" And I set the field "id_drops_3_coords" to "267,302;10" # Try to submit without setting the last marker. And I press "id_submitbutton" Then I should see "You have specified a drop zone but not chosen a marker that must be dragged to the zone." # Set the last marker and submit again. And I set the field "id_drops_3_choice" to "2" And I press "id_submitbutton" And I should see "Drag and drop markers" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "item_qtype_ddmarker" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | id_showmisplaced | 1 | | id_shuffleanswers | 1 | @javascript @_file_upload Scenario: Question must have at least one marker and one drop zone When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I press "Create a new question ..." And I set the field "Drag and drop markers" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I set the field "Question name" to "Drag and drop markers" And I set the field "Question text" to "Markers, who need markers?" And I upload "question/type/ddmarker/tests/fixtures/mkmap.png" file to "Background image" filemanager And I press "Save changes" Then I should see "You must add at least one marker to this question." And I should see "You must define at least one drop zone for this question." PK\w&type/ddmarker/tests/behat/edit.featurenu[@qtype @qtype_ddmarker Feature: Test editing a drag and drop markers questions As a teacher In order to be able to update my drag and drop markers questions I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddmarker | Drag markers | mkmap | @javascript Scenario: Edit a drag and drop markers question When I am on the "Drag markers" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | Edited question name | And I press "id_submitbutton" Then I should see "Edited question name" PK\5w  -type/ddmarker/tests/behat/previewquiz.featurenu[@qtype @qtype_ddmarker Feature: Preview a quiz with multiple maker question. As a teacher In order to check my drag-drop marker questions will work for students I need to preview them in quiz with multiple questions. Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddmarker | Drag markers | mkmap | | Test questions | ddmarker | Drag markers 2 | mkmap | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | Drag markers | 1 | | Drag markers 2 | 2 | @javascript Scenario: Preview a quiz with multiple markers question Given I am on the "Test quiz" "mod_quiz > View" page logged in as "admin" And I press "Preview quiz" # Add change window size so we can drag-drop OU marker on 322,213 coordinates on firefox. And I change viewport size to "large" # Drag items and go back and forth between the question. And I drag "OU" to "322,213" in the drag and drop markers question And I drag "Railway station" to "144,84" in the drag and drop markers question And I drag "Railway station" to "195,180" in the drag and drop markers question And I press "Next page" And I drag "OU" to "322,213" in the drag and drop markers question And I drag "Railway station" to "144,84" in the drag and drop markers question And I drag "Railway station" to "195,180" in the drag and drop markers question And I press "Previous page" And I drag "Railway station" to "267,302" in the drag and drop markers question And I press "Next page" And I drag "Railway station" to "267,302" in the drag and drop markers question And I press "Previous page" And I press "Next page" And I press "Finish attempt ..." And I press "Submit all and finish" When I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then I should see "2.00/2.00" And the state of "Please place the markers on the map of Milton Keynes and be aware that" question is shown as "Correct" And I should see "Well done!" PK\}QQ(type/ddmarker/tests/behat/export.featurenu[@qtype @qtype_ddmarker Feature: Test exporting drag and drop markers questions As a teacher In order to be able to reuse my drag and drop markers questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddmarker | Drag markers | mkmap | @javascript Scenario: Export a drag and drop markers question When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | Drag markers | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\$ꝿ(type/ddmarker/tests/behat/import.featurenu[@qtype @qtype_ddmarker Feature: Test importing drag and drop markers questions As a teacher In order to reuse drag and drop markers questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import drag and drop markers question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/ddmarker/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "Please place the markers on the map of Milton Keynes and be aware that there is more than one railway station." And I press "Continue" And I should see "Milton Keynes landmarks" PK\`!`!4type/ddmarker/tests/behat/backup_and_restore.featurenu[@qtype @qtype_ddmarker Feature: Test duplicating a quiz containing a drag and drop markers question As a teacher In order re-use my courses containing drag and drop markers questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddmarker | Drag markers | mkmap | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | Drag markers | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | And I log in as "admin" And I am on "Course 1" course homepage @javascript Scenario: Backup and restore a course containing a drag and drop markers question When I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page And I choose "Edit question" action for "Drag markers" in the question bank Then the following fields match these values: | Question name | Drag markers | | Question text | Please place the markers on the map of Milton Keynes and be aware that there is more than one railway station. | | General feedback | The Open University is at the junction of Brickhill Street and Groveway. There are three railway stations, Wolverton, Milton Keynes Central and Bletchley. | | Default mark | 1 | | id_shuffleanswers | 0 | | id_drags_0_label | OU | | id_drags_0_noofdrags | 1 | | id_drags_1_label | Railway station | | id_drags_1_noofdrags | 3 | | id_drops_0_shape | Circle | | id_drops_0_coords | 322,213;10 | | id_drops_0_choice | OU | | id_drops_1_shape | Circle | | id_drops_1_coords | 144,84;10 | | id_drops_1_choice | Railway station | | id_drops_2_shape | Circle | | id_drops_2_coords | 195,180;10 | | id_drops_2_choice | Railway station | | id_drops_3_shape | Circle | | id_drops_3_coords | 267,302;10 | | id_drops_3_choice | Railway station | | For any correct response | Well done! | | For any partially correct response | Parts, but only parts, of your response are correct. | | id_shownumcorrect | 1 | | For any incorrect response | That is not right at all. | | Penalty for each incorrect try | 33.33333% | | Hint 1 | You are trying to place four markers on the map. | | id_hintshownumcorrect_0 | 1 | | id_hintclearwrong_0 | 0 | | id_hintoptions_0 | 0 | | Hint 2 | You are trying to mark three railway stations. | | id_hintshownumcorrect_1 | 1 | | id_hintclearwrong_1 | 1 | | id_hintoptions_1 | 1 | PK\FBe)type/ddmarker/tests/behat/preview.featurenu[@qtype @qtype_ddmarker @_switch_window Feature: Preview a drag-drop marker question As a teacher In order to check my drag-drop marker questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | ddmarker | Drag markers | mkmap | | Test questions | ddmarker | Drag to mathjax equation | mathjax | @javascript @_bug_phantomjs Scenario: Preview a question using the mouse When I am on the "Drag markers" "core_question > preview" page logged in as teacher And I drag "OU" to "322,213" in the drag and drop markers question And I drag "Railway station" to "144,84" in the drag and drop markers question And I drag "Railway station" to "195,180" in the drag and drop markers question And I drag "Railway station" to "267,302" in the drag and drop markers question And I press "Submit and finish" Then the state of "Please place the markers on the map of Milton Keynes" question is shown as "Correct" And I should see "Mark 1.00 out of 1.00" @javascript Scenario: Preview a question using the keyboard When I am on the "Drag markers" "core_question > preview" page logged in as teacher And I type "up" "88" times on marker "Railway station" in the drag and drop markers question And I type "right" "26" times on marker "Railway station" in the drag and drop markers question And I press "Submit and finish" Then the state of "Please place the markers on the map of Milton Keynes" question is shown as "Partially correct" And I should see "Mark 0.25 out of 1.00" @javascript Scenario: Preview a question in multiple viewports When I am on the "Drag markers" "core_question > preview" page logged in as teacher And I change viewport size to "large" And I drag "OU" to "322,213" in the drag and drop markers question And I drag "Railway station" to "144,84" in the drag and drop markers question And I drag "Railway station" to "195,180" in the drag and drop markers question And I press "Save" And I change viewport size to "640x768" And I press "Save" And I drag "Railway station" to "267,302" in the drag and drop markers question And I press "Save" And I press "Submit and finish" Then the state of "Please place the markers on the map of Milton Keynes" question is shown as "Correct" And I should see "Mark 1.00 out of 1.00" @javascript Scenario: Preview a drag-drop marker question with mathjax question. Given the "mathjaxloader" filter is "on" And the "mathjaxloader" filter applies to "content and headings" And I am on the "Drag to mathjax equation" "core_question > preview" page logged in as teacher When I press "Submit and finish" Then ".markertexts .markertext .MathJax" "css_element" should exist in the ".droparea" "css_element" And I press "Start again" And I press "Fill in correct responses" And I press "Submit and finish" And ".filter_mathjaxloader_equation" "css_element" should exist in the ".droparea" "css_element" And ".markertexts .markertext .MathJax" "css_element" should not exist in the ".droparea" "css_element" PK\(b;;type/ddmarker/questiontype.phpnu[. /** * Question type class for the drag-and-drop images onto images question type. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/ddimageortext/questiontypebase.php'); /** * Question hint for ddmarker. * * An extension of {@link question_hint} for questions like match and multiple * choice with multile answers, where there are options for whether to show the * number of parts right at each stage, and to reset the wrong parts. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_hint_ddmarker extends question_hint_with_parts { public $statewhichincorrect; /** * Constructor. * @param int the hint id from the database. * @param string $hint The hint text * @param int the corresponding text FORMAT_... type. * @param bool $shownumcorrect whether the number of right parts should be shown * @param bool $clearwrong whether the wrong parts should be reset. */ public function __construct($id, $hint, $hintformat, $shownumcorrect, $clearwrong, $statewhichincorrect) { parent::__construct($id, $hint, $hintformat, $shownumcorrect, $clearwrong); $this->statewhichincorrect = $statewhichincorrect; } /** * Create a basic hint from a row loaded from the question_hints table in the database. * @param object $row with property options as well as hint, shownumcorrect and clearwrong set. * @return question_hint_ddmarker */ public static function load_from_record($row) { return new question_hint_ddmarker($row->id, $row->hint, $row->hintformat, $row->shownumcorrect, $row->clearwrong, $row->options); } } /** * The drag-and-drop markers question type class. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker extends qtype_ddtoimage_base { public function save_defaults_for_new_questions(stdClass $fromform): void { parent::save_defaults_for_new_questions($fromform); $this->set_default_value('showmisplaced', $fromform->showmisplaced); $this->set_default_value('shuffleanswers', $fromform->shuffleanswers); } public function save_question_options($formdata) { global $DB, $USER; $context = $formdata->context; $options = $DB->get_record('qtype_ddmarker', array('questionid' => $formdata->id)); if (!$options) { $options = new stdClass(); $options->questionid = $formdata->id; $options->correctfeedback = ''; $options->partiallycorrectfeedback = ''; $options->incorrectfeedback = ''; $options->id = $DB->insert_record('qtype_ddmarker', $options); } $options->shuffleanswers = !empty($formdata->shuffleanswers); $options->showmisplaced = !empty($formdata->showmisplaced); $options = $this->save_combined_feedback_helper($options, $formdata, $context, true); $this->save_hints($formdata, true); $DB->update_record('qtype_ddmarker', $options); $DB->delete_records('qtype_ddmarker_drops', array('questionid' => $formdata->id)); foreach (array_keys($formdata->drops) as $dropno) { if ($formdata->drops[$dropno]['choice'] == 0) { continue; } $drop = new stdClass(); $drop->questionid = $formdata->id; $drop->no = $dropno + 1; $drop->shape = $formdata->drops[$dropno]['shape']; $drop->coords = $formdata->drops[$dropno]['coords']; $drop->choice = $formdata->drops[$dropno]['choice']; $DB->insert_record('qtype_ddmarker_drops', $drop); } // An array of drag no -> drag id. $olddragids = $DB->get_records_menu('qtype_ddmarker_drags', array('questionid' => $formdata->id), '', 'no, id'); foreach (array_keys($formdata->drags) as $dragno) { if ($formdata->drags[$dragno]['label'] !== '') { $drag = new stdClass(); $drag->questionid = $formdata->id; $drag->no = $dragno + 1; if ($formdata->drags[$dragno]['noofdrags'] == 0) { $drag->infinite = 1; $drag->noofdrags = 1; } else { $drag->infinite = 0; $drag->noofdrags = $formdata->drags[$dragno]['noofdrags']; } $drag->label = $formdata->drags[$dragno]['label']; if (isset($olddragids[$dragno + 1])) { $drag->id = $olddragids[$dragno + 1]; unset($olddragids[$dragno + 1]); $DB->update_record('qtype_ddmarker_drags', $drag); } else { $drag->id = $DB->insert_record('qtype_ddmarker_drags', $drag); } } } if (!empty($olddragids)) { list($sql, $params) = $DB->get_in_or_equal(array_values($olddragids)); $DB->delete_records_select('qtype_ddmarker_drags', "id $sql", $params); } file_save_draft_area_files($formdata->bgimage, $formdata->context->id, 'qtype_ddmarker', 'bgimage', $formdata->id, array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 1)); } public function save_hints($formdata, $withparts = false) { global $DB; $context = $formdata->context; $oldhints = $DB->get_records('question_hints', array('questionid' => $formdata->id), 'id ASC'); if (!empty($formdata->hint)) { $numhints = max(array_keys($formdata->hint)) + 1; } else { $numhints = 0; } if ($withparts) { if (!empty($formdata->hintclearwrong)) { $numclears = max(array_keys($formdata->hintclearwrong)) + 1; } else { $numclears = 0; } if (!empty($formdata->hintshownumcorrect)) { $numshows = max(array_keys($formdata->hintshownumcorrect)) + 1; } else { $numshows = 0; } $numhints = max($numhints, $numclears, $numshows); } for ($i = 0; $i < $numhints; $i += 1) { if (html_is_blank($formdata->hint[$i]['text'])) { $formdata->hint[$i]['text'] = ''; } if ($withparts) { $clearwrong = !empty($formdata->hintclearwrong[$i]); $shownumcorrect = !empty($formdata->hintshownumcorrect[$i]); $statewhichincorrect = !empty($formdata->hintoptions[$i]); } if (empty($formdata->hint[$i]['text']) && empty($clearwrong) && empty($shownumcorrect) && empty($statewhichincorrect)) { continue; } // Update an existing hint if possible. $hint = array_shift($oldhints); if (!$hint) { $hint = new stdClass(); $hint->questionid = $formdata->id; $hint->hint = ''; $hint->id = $DB->insert_record('question_hints', $hint); } $hint->hint = $this->import_or_save_files($formdata->hint[$i], $context, 'question', 'hint', $hint->id); $hint->hintformat = $formdata->hint[$i]['format']; if ($withparts) { $hint->clearwrong = $clearwrong; $hint->shownumcorrect = $shownumcorrect; $hint->options = $statewhichincorrect; } $DB->update_record('question_hints', $hint); } // Delete any remaining old hints. $fs = get_file_storage(); foreach ($oldhints as $oldhint) { $fs->delete_area_files($context->id, 'question', 'hint', $oldhint->id); $DB->delete_records('question_hints', array('id' => $oldhint->id)); } } protected function make_hint($hint) { return question_hint_ddmarker::load_from_record($hint); } protected function make_choice($dragdata) { return new qtype_ddmarker_drag_item($dragdata->label, $dragdata->no, $dragdata->infinite, $dragdata->noofdrags); } protected function make_place($dropdata) { return new qtype_ddmarker_drop_zone($dropdata->no, $dropdata->shape, $dropdata->coords); } protected function initialise_combined_feedback(question_definition $question, $questiondata, $withparts = false) { parent::initialise_combined_feedback($question, $questiondata, $withparts); $question->showmisplaced = $questiondata->options->showmisplaced; } public function move_files($questionid, $oldcontextid, $newcontextid) { global $DB; $fs = get_file_storage(); parent::move_files($questionid, $oldcontextid, $newcontextid); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_ddmarker', 'bgimage', $questionid); $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } /** * Delete all the files belonging to this question. * @param int $questionid the question being deleted. * @param int $contextid the context the question is in. */ protected function delete_files($questionid, $contextid) { global $DB; $fs = get_file_storage(); parent::delete_files($questionid, $contextid); $this->delete_files_in_combined_feedback($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } public function export_to_xml($question, qformat_xml $format, $extra = null) { $fs = get_file_storage(); $contextid = $question->contextid; $output = ''; if ($question->options->shuffleanswers) { $output .= " \n"; } if ($question->options->showmisplaced) { $output .= " \n"; } $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid); $files = $fs->get_area_files($contextid, 'qtype_ddmarker', 'bgimage', $question->id); $output .= " ".$this->write_files($files, 2)."\n";; foreach ($question->options->drags as $drag) { $files = $fs->get_area_files($contextid, 'qtype_ddmarker', 'dragimage', $drag->id); $output .= " \n"; $output .= " {$drag->no}\n"; $output .= $format->writetext($drag->label, 3); if ($drag->infinite) { $output .= " \n"; } $output .= " {$drag->noofdrags}\n"; $output .= " \n"; } foreach ($question->options->drops as $drop) { $output .= " \n"; $output .= " {$drop->no}\n"; $output .= " {$drop->shape}\n"; $output .= " {$drop->coords}\n"; $output .= " {$drop->choice}\n"; $output .= " \n"; } return $output; } public function import_from_xml($data, $question, qformat_xml $format, $extra=null) { if (!isset($data['@']['type']) || $data['@']['type'] != 'ddmarker') { return false; } $question = $format->import_headers($data); $question->qtype = 'ddmarker'; $question->shuffleanswers = array_key_exists('shuffleanswers', $format->getpath($data, array('#'), array())); $question->showmisplaced = array_key_exists('showmisplaced', $format->getpath($data, array('#'), array())); $filexml = $format->getpath($data, array('#', 'file'), array()); $question->bgimage = $format->import_files_as_draft($filexml); $drags = $data['#']['drag']; $question->drags = array(); foreach ($drags as $dragxml) { $dragno = $format->getpath($dragxml, array('#', 'no', 0, '#'), 0); $dragindex = $dragno - 1; $question->drags[$dragindex] = array(); $question->drags[$dragindex]['label'] = $format->getpath($dragxml, array('#', 'text', 0, '#'), '', true); if (array_key_exists('infinite', $dragxml['#'])) { $question->drags[$dragindex]['noofdrags'] = 0; // Means infinite in the form. } else { // Defaults to 1 if 'noofdrags' not set. $question->drags[$dragindex]['noofdrags'] = $format->getpath($dragxml, array('#', 'noofdrags', 0, '#'), 1); } } $drops = $data['#']['drop']; $question->drops = array(); foreach ($drops as $dropxml) { $dropno = $format->getpath($dropxml, array('#', 'no', 0, '#'), 0); $dropindex = $dropno - 1; $question->drops[$dropindex] = array(); $question->drops[$dropindex]['choice'] = $format->getpath($dropxml, array('#', 'choice', 0, '#'), 0); $question->drops[$dropindex]['shape'] = $format->getpath($dropxml, array('#', 'shape', 0, '#'), ''); $question->drops[$dropindex]['coords'] = $format->getpath($dropxml, array('#', 'coords', 0, '#'), ''); } $format->import_combined_feedback($question, $data, true); $format->import_hints($question, $data, true, true, $format->get_format($question->questiontextformat)); return $question; } public function get_random_guess_score($questiondata) { return null; } } PK\>a00$type/ddmarker/edit_ddmarker_form.phpnu[. /** * Defines the editing form for the drag-and-drop images onto images question type. * * @package qtype_ddmarker * @copyright 2012 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot.'/question/type/ddimageortext/edit_ddtoimage_form_base.php'); require_once($CFG->dirroot.'/question/type/ddmarker/shapes.php'); define('QTYPE_DDMARKER_ALLOWED_TAGS_IN_MARKER', '
    '); /** * Drag-and-drop images onto images editing form definition. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_ddmarker_edit_form extends qtype_ddtoimage_edit_form_base { public function qtype() { return 'ddmarker'; } protected function definition_inner($mform) { $mform->addElement('advcheckbox', 'showmisplaced', get_string('showmisplaced', 'qtype_ddmarker')); $mform->setDefault('showmisplaced', $this->get_default_value('showmisplaced', 0)); parent::definition_inner($mform); $mform->addHelpButton('drops[0]', 'dropzones', 'qtype_ddmarker'); } public function js_call() { global $PAGE; $PAGE->requires->js_call_amd('qtype_ddmarker/form', 'init'); } protected function definition_draggable_items($mform, $itemrepeatsatstart) { $mform->addElement('header', 'draggableitemheader', get_string('markers', 'qtype_ddmarker')); $mform->addElement('advcheckbox', 'shuffleanswers', get_string('shuffleimages', 'qtype_'.$this->qtype())); $mform->setDefault('shuffleanswers', $this->get_default_value('shuffleanswers', 0)); $this->repeat_elements($this->draggable_item($mform), $itemrepeatsatstart, $this->draggable_items_repeated_options(), 'noitems', 'additems', self::ADD_NUM_ITEMS, get_string('addmoreitems', 'qtype_ddmarker'), true); } protected function draggable_item($mform) { $draggableimageitem = array(); $grouparray = array(); $grouparray[] = $mform->createElement('text', 'label', '', array('size' => 30, 'class' => 'tweakcss')); $mform->setType('text', PARAM_RAW_TRIMMED); $noofdragoptions = array(0 => get_string('infinite', 'qtype_ddmarker')); foreach (range(1, 6) as $option) { $noofdragoptions[$option] = $option; } $grouparray[] = $mform->createElement('select', 'noofdrags', get_string('noofdrags', 'qtype_ddmarker'), $noofdragoptions); $draggableimageitem[] = $mform->createElement('group', 'drags', get_string('marker_n', 'qtype_ddmarker'), $grouparray); return $draggableimageitem; } protected function draggable_items_repeated_options() { $repeatedoptions = array(); $repeatedoptions['drags[label]']['type'] = PARAM_RAW; return $repeatedoptions; } protected function drop_zone($mform, $imagerepeats) { $grouparray = array(); $shapearray = qtype_ddmarker_shape::shape_options(); $grouparray[] = $mform->createElement('select', 'shape', get_string('shape', 'qtype_ddmarker'), $shapearray); $markernos = array(); $markernos[0] = ''; for ($i = 1; $i <= $imagerepeats; $i += 1) { $markernos[$i] = $i; } $grouparray[] = $mform->createElement('select', 'choice', get_string('marker', 'qtype_ddmarker'), $markernos); $grouparray[] = $mform->createElement('text', 'coords', get_string('coords', 'qtype_ddmarker'), array('size' => 30, 'class' => 'tweakcss')); $mform->setType('coords', PARAM_RAW); // These are validated manually. $dropzone = $mform->createElement('group', 'drops', get_string('dropzone', 'qtype_ddmarker', '{no}'), $grouparray); return array($dropzone); } protected function drop_zones_repeated_options() { $repeatedoptions = array(); $repeatedoptions['drops[coords]']['type'] = PARAM_RAW; return $repeatedoptions; } protected function get_hint_fields($withclearwrong = false, $withshownumpartscorrect = false) { $mform = $this->_form; $repeated = array(); $repeated[] = $mform->createElement('editor', 'hint', get_string('hintn', 'question'), array('rows' => 5), $this->editoroptions); $repeatedoptions['hint']['type'] = PARAM_RAW; $repeated[] = $mform->createElement('checkbox', 'hintshownumcorrect', '', get_string('shownumpartscorrect', 'question')); $repeated[] = $mform->createElement('checkbox', 'hintoptions', '', get_string('stateincorrectlyplaced', 'qtype_ddmarker')); $repeated[] = $mform->createElement('checkbox', 'hintclearwrong', '', get_string('clearwrongparts', 'qtype_ddmarker')); return array($repeated, $repeatedoptions); } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_combined_feedback($question, true); $question = $this->data_preprocessing_hints($question, true, true); $dragids = array(); // Drag no -> dragid. if (!empty($question->options)) { $question->shuffleanswers = $question->options->shuffleanswers; $question->showmisplaced = $question->options->showmisplaced; $question->drags = array(); foreach ($question->options->drags as $drag) { $dragindex = $drag->no - 1; $question->drags[$dragindex] = array(); $question->drags[$dragindex]['label'] = $drag->label; if ($drag->infinite == 1) { $question->drags[$dragindex]['noofdrags'] = 0; } else { $question->drags[$dragindex]['noofdrags'] = $drag->noofdrags; } $dragids[$dragindex] = $drag->id; } $question->drops = array(); foreach ($question->options->drops as $drop) { $droparray = (array)$drop; unset($droparray['id']); unset($droparray['no']); unset($droparray['questionid']); $question->drops[$drop->no - 1] = $droparray; } } // Initialise file picker for bgimage. $draftitemid = file_get_submitted_draft_itemid('bgimage'); file_prepare_draft_area($draftitemid, $this->context->id, 'qtype_ddmarker', 'bgimage', !empty($question->id) ? (int) $question->id : null, self::file_picker_options()); $question->bgimage = $draftitemid; $this->js_call(); return $question; } /** * Perform the necessary preprocessing for the hint fields. * * @param object $question The data being passed to the form. * @param bool $withclearwrong Clear wrong hints. * @param bool $withshownumpartscorrect Show number correct. * @return object The modified data. */ protected function data_preprocessing_hints($question, $withclearwrong = false, $withshownumpartscorrect = false) { if (empty($question->hints)) { return $question; } parent::data_preprocessing_hints($question, $withclearwrong, $withshownumpartscorrect); $question->hintoptions = array(); foreach ($question->hints as $hint) { $question->hintoptions[] = $hint->options; } return $question; } public function validation($data, $files) { $errors = parent::validation($data, $files); $bgimagesize = $this->get_image_size_in_draft_area($data['bgimage']); if ($bgimagesize === null) { $errors["bgimage"] = get_string('formerror_nobgimage', 'qtype_ddmarker'); } $dropfound = false; for ($i = 0; $i < $data['nodropzone']; $i++) { $choice = $data['drops'][$i]['choice']; $choicepresent = ($choice !== '0'); if ($choicepresent) { $dropfound = true; // Test coords here. if ($bgimagesize !== null) { $shape = $data['drops'][$i]['shape']; $coordsstring = $data['drops'][$i]['coords']; $shapeobj = qtype_ddmarker_shape::create($shape, $coordsstring); $interpretererror = $shapeobj->get_coords_interpreter_error(); if ($interpretererror !== false) { $errors["drops[{$i}]"] = $interpretererror; } else if (!$shapeobj->inside_width_height($bgimagesize)) { $errorcode = 'shapeoutsideboundsofbgimage'; $errors["drops[{$i}]"] = get_string('formerror_'.$errorcode, 'qtype_ddmarker'); } } } else { if (trim($data['drops'][$i]['coords']) !== '') { $dropfound = true; $errorcode = 'noitemselected'; $errors["drops[{$i}]"] = get_string('formerror_'.$errorcode, 'qtype_ddmarker'); } } } if (!$dropfound) { $errors['drops[0]'] = get_string('formerror_droprequired', 'qtype_ddmarker'); } $markerfound = false; for ($dragindex = 0; $dragindex < $data['noitems']; $dragindex++) { $label = $data['drags'][$dragindex]['label']; if ($label !== '') { $markerfound = true; } if ($label != strip_tags($label, QTYPE_DDMARKER_ALLOWED_TAGS_IN_MARKER)) { $errors["drags[{$dragindex}]"] = get_string('formerror_onlysometagsallowed', 'qtype_ddmarker', s(QTYPE_DDMARKER_ALLOWED_TAGS_IN_MARKER)); } } if (!$markerfound) { $errors['drags[0]'] = get_string('formerror_dragrequired', 'qtype_ddmarker'); } return $errors; } /** * Gets the width and height of a draft image. * * @param int $draftitemid ID of the draft image * @return array Return array of the width and height of the draft image. */ public function get_image_size_in_draft_area($draftitemid) { global $USER; $usercontext = context_user::instance($USER->id); $fs = get_file_storage(); $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'id'); if ($draftfiles) { foreach ($draftfiles as $file) { if ($file->is_directory()) { continue; } // Just return the data for the first good file, there should only be one. $imageinfo = $file->get_imageinfo(); $width = $imageinfo['width']; $height = $imageinfo['height']; return array($width, $height); } } return null; } } PK\qrmlltype/essay/styles.cssnu[.que.essay textarea.qtype_essay_response { width: 100%; } .que.essay textarea.qtype_essay_response.qtype_essay_plain { white-space: pre-wrap; font: inherit; } .que.essay textarea.qtype_essay_response.qtype_essay_monospaced { white-space: pre; font-family: Andale Mono, Monaco, Courier New, DejaVu Sans Mono, monospace; } .que.essay .qtype_essay_response { min-height: 3em; } .que.essay .qtype_essay_response.readonly { background-color: white; } .que.essay div.qtype_essay_response textarea { width: 100%; } .que.essay .ablock .filemanager .fp-restrictions { margin-top: 1em; } PK\9S\\type/essay/renderer.phpnu[. /** * Essay question renderer class. * * @package qtype * @subpackage essay * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Generates the output for essay questions. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_renderer extends qtype_renderer { public function formulation_and_controls(question_attempt $qa, question_display_options $options) { global $CFG; $question = $qa->get_question(); /** @var qtype_essay_format_renderer_base $responseoutput */ $responseoutput = $question->get_format_renderer($this->page); $responseoutput->set_displayoptions($options); // Answer field. $step = $qa->get_last_step_with_qt_var('answer'); if (!$step->has_qt_var('answer') && empty($options->readonly)) { // Question has never been answered, fill it with response template. $step = new question_attempt_step(array('answer' => $question->responsetemplate)); } if (empty($options->readonly)) { $answer = $responseoutput->response_area_input('answer', $qa, $step, $question->responsefieldlines, $options->context); } else { $answer = $responseoutput->response_area_read_only('answer', $qa, $step, $question->responsefieldlines, $options->context); $answer .= html_writer::nonempty_tag('p', $question->get_word_count_message_for_review($step->get_qt_data())); if (!empty($CFG->enableplagiarism)) { require_once($CFG->libdir . '/plagiarismlib.php'); $answer .= plagiarism_get_links([ 'context' => $options->context->id, 'component' => $qa->get_question()->qtype->plugin_name(), 'area' => $qa->get_usage_id(), 'itemid' => $qa->get_slot(), 'userid' => $step->get_user_id(), 'content' => $qa->get_response_summary()]); } } $files = ''; if ($question->attachments) { if (empty($options->readonly)) { $files = $this->files_input($qa, $question->attachments, $options); } else { $files = $this->files_read_only($qa, $options); } } $result = ''; $result .= html_writer::tag('div', $question->format_questiontext($qa), array('class' => 'qtext')); $result .= html_writer::start_tag('div', array('class' => 'ablock')); $result .= html_writer::tag('div', $answer, array('class' => 'answer')); // If there is a response and min/max word limit is set in the form then check the response word count. if ($qa->get_state() == question_state::$invalid) { $result .= html_writer::nonempty_tag('div', $question->get_validation_error($step->get_qt_data()), ['class' => 'validationerror']); } $result .= html_writer::tag('div', $files, array('class' => 'attachments')); $result .= html_writer::end_tag('div'); return $result; } /** * Displays any attached files when the question is in read-only mode. * @param question_attempt $qa the question attempt to display. * @param question_display_options $options controls what should and should * not be displayed. Used to get the context. */ public function files_read_only(question_attempt $qa, question_display_options $options) { global $CFG; $files = $qa->get_last_qt_files('attachments', $options->context->id); $filelist = []; $step = $qa->get_last_step_with_qt_var('attachments'); foreach ($files as $file) { $out = html_writer::link($qa->get_response_file_url($file), $this->output->pix_icon(file_file_icon($file), get_mimetype_description($file), 'moodle', array('class' => 'icon')) . ' ' . s($file->get_filename())); if (!empty($CFG->enableplagiarism)) { require_once($CFG->libdir . '/plagiarismlib.php'); $out .= plagiarism_get_links([ 'context' => $options->context->id, 'component' => $qa->get_question()->qtype->plugin_name(), 'area' => $qa->get_usage_id(), 'itemid' => $qa->get_slot(), 'userid' => $step->get_user_id(), 'file' => $file]); } $filelist[] = html_writer::tag('li', $out, ['class' => 'mb-2']); } $labelbyid = $qa->get_qt_field_name('attachments') . '_label'; $fileslabel = $options->add_question_identifier_to_label(get_string('answerfiles', 'qtype_essay')); $output = html_writer::tag('h4', $fileslabel, ['id' => $labelbyid, 'class' => 'visually-hidden']); $output .= html_writer::tag('ul', implode($filelist), [ 'aria-labelledby' => $labelbyid, 'class' => 'list-unstyled m-0', ]); return $output; } /** * Displays the input control for when the student should upload a single file. * @param question_attempt $qa the question attempt to display. * @param int $numallowed the maximum number of attachments allowed. -1 = unlimited. * @param question_display_options $options controls what should and should * not be displayed. Used to get the context. */ public function files_input(question_attempt $qa, $numallowed, question_display_options $options) { global $CFG, $COURSE; require_once($CFG->dirroot . '/lib/form/filemanager.php'); $pickeroptions = new stdClass(); $pickeroptions->mainfile = null; $pickeroptions->maxfiles = $numallowed; $pickeroptions->itemid = $qa->prepare_response_files_draft_itemid( 'attachments', $options->context->id); $pickeroptions->context = $options->context; $pickeroptions->return_types = FILE_INTERNAL | FILE_CONTROLLED_LINK; $pickeroptions->accepted_types = $qa->get_question()->filetypeslist; $fm = new form_filemanager($pickeroptions); $fm->options->maxbytes = get_user_max_upload_file_size( $this->page->context, $CFG->maxbytes, $COURSE->maxbytes, $qa->get_question()->maxbytes ); $filesrenderer = $this->page->get_renderer('core', 'files'); $text = ''; if (!empty($qa->get_question()->filetypeslist)) { $text = html_writer::tag('p', get_string('acceptedfiletypes', 'qtype_essay')); $filetypesutil = new \core_form\filetypes_util(); $filetypes = $qa->get_question()->filetypeslist; $filetypedescriptions = $filetypesutil->describe_file_types($filetypes); $text .= $this->render_from_template('core_form/filetypes-descriptions', $filetypedescriptions); } $output = html_writer::start_tag('fieldset'); $fileslabel = $options->add_question_identifier_to_label(get_string('answerfiles', 'qtype_essay')); $output .= html_writer::tag('legend', $fileslabel, ['class' => 'visually-hidden']); $output .= $filesrenderer->render($fm); $output .= html_writer::empty_tag('input', [ 'type' => 'hidden', 'name' => $qa->get_qt_field_name('attachments'), 'value' => $pickeroptions->itemid, ]); $output .= $text; $output .= html_writer::end_tag('fieldset'); return $output; } public function manual_comment(question_attempt $qa, question_display_options $options) { if ($options->manualcomment != question_display_options::EDITABLE) { return ''; } $question = $qa->get_question(); return html_writer::nonempty_tag('div', $question->format_text( $question->graderinfo, $question->graderinfoformat, $qa, 'qtype_essay', 'graderinfo', $question->id), array('class' => 'graderinfo')); } } /** * A base class to abstract out the differences between different type of * response format. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_essay_format_renderer_base extends plugin_renderer_base { /** @var question_display_options Question display options instance for any necessary information for rendering the question. */ protected $displayoptions; /** * Question number setter. * * @param question_display_options $displayoptions */ public function set_displayoptions(question_display_options $displayoptions): void { $this->displayoptions = $displayoptions; } /** * Render the students respone when the question is in read-only mode. * @param string $name the variable name this input edits. * @param question_attempt $qa the question attempt being display. * @param question_attempt_step $step the current step. * @param int $lines approximate size of input box to display. * @param object $context the context teh output belongs to. * @return string html to display the response. */ abstract public function response_area_read_only($name, question_attempt $qa, question_attempt_step $step, $lines, $context); /** * Render the students respone when the question is in read-only mode. * @param string $name the variable name this input edits. * @param question_attempt $qa the question attempt being display. * @param question_attempt_step $step the current step. * @param int $lines approximate size of input box to display. * @param object $context the context teh output belongs to. * @return string html to display the response for editing. */ abstract public function response_area_input($name, question_attempt $qa, question_attempt_step $step, $lines, $context); /** * @return string specific class name to add to the input element. */ abstract protected function class_name(); } /** * An essay format renderer for essays where the student should not enter * any inline response. * * @copyright 2013 Binghamton University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_noinline_renderer extends qtype_essay_format_renderer_base { protected function class_name() { return 'qtype_essay_noinline'; } public function response_area_read_only($name, $qa, $step, $lines, $context) { return ''; } public function response_area_input($name, $qa, $step, $lines, $context) { return ''; } } /** * An essay format renderer for essays where the student should use the HTML * editor without the file picker. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_editor_renderer extends qtype_essay_format_renderer_base { protected function class_name() { return 'qtype_essay_editor'; } public function response_area_read_only($name, $qa, $step, $lines, $context) { $labelbyid = $qa->get_qt_field_name($name) . '_label'; $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_essay')); $output = html_writer::tag('h4', $responselabel, ['id' => $labelbyid, 'class' => 'visually-hidden']); $output .= html_writer::tag('div', $this->prepare_response($name, $qa, $step, $context), [ 'role' => 'textbox', 'aria-readonly' => 'true', 'aria-labelledby' => $labelbyid, 'class' => $this->class_name() . ' qtype_essay_response readonly', 'style' => 'min-height: ' . ($lines * 1.5) . 'em;', ]); // Height $lines * 1.5 because that is a typical line-height on web pages. // That seems to give results that look OK. return $output; } public function response_area_input($name, $qa, $step, $lines, $context) { global $CFG; require_once($CFG->dirroot . '/repository/lib.php'); $inputname = $qa->get_qt_field_name($name); $responseformat = $step->get_qt_var($name . 'format'); $id = $inputname . '_id'; $editor = editors_get_preferred_editor($responseformat); $strformats = format_text_menu(); $formats = $editor->get_supported_formats(); foreach ($formats as $fid) { $formats[$fid] = $strformats[$fid]; } list($draftitemid, $response) = $this->prepare_response_for_editing( $name, $step, $context); $editor->set_text($response); $editor->use_editor($id, $this->get_editor_options($context), $this->get_filepicker_options($context, $draftitemid)); $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_essay')); $output = html_writer::tag('label', $responselabel, [ 'class' => 'visually-hidden', 'for' => $id, ]); $output .= html_writer::start_tag('div', array('class' => $this->class_name() . ' qtype_essay_response')); $output .= html_writer::tag('div', html_writer::tag('textarea', s($response), array('id' => $id, 'name' => $inputname, 'rows' => $lines, 'cols' => 60, 'class' => 'form-control'))); $output .= html_writer::start_tag('div'); if (count($formats) == 1) { reset($formats); $output .= html_writer::empty_tag('input', array('type' => 'hidden', 'name' => $inputname . 'format', 'value' => key($formats))); } else { $output .= html_writer::label(get_string('format'), 'menu' . $inputname . 'format', false); $output .= ' '; $output .= html_writer::select($formats, $inputname . 'format', $responseformat, ''); } $output .= html_writer::end_tag('div'); $output .= $this->filepicker_html($inputname, $draftitemid); $output .= html_writer::end_tag('div'); return $output; } /** * Prepare the response for read-only display. * @param string $name the variable name this input edits. * @param question_attempt $qa the question attempt being display. * @param question_attempt_step $step the current step. * @param object $context the context the attempt belongs to. * @return string the response prepared for display. */ protected function prepare_response($name, question_attempt $qa, question_attempt_step $step, $context) { if (!$step->has_qt_var($name)) { return ''; } $formatoptions = new stdClass(); $formatoptions->para = false; return format_text($step->get_qt_var($name), $step->get_qt_var($name . 'format'), $formatoptions); } /** * Prepare the response for editing. * @param string $name the variable name this input edits. * @param question_attempt_step $step the current step. * @param object $context the context the attempt belongs to. * @return string the response prepared for display. */ protected function prepare_response_for_editing($name, question_attempt_step $step, $context) { return array(0, $step->get_qt_var($name)); } /** * @param object $context the context the attempt belongs to. * @return array options for the editor. */ protected function get_editor_options($context) { // Disable the text-editor autosave because quiz has it's own auto save function. return array('context' => $context, 'autosave' => false); } /** * @param object $context the context the attempt belongs to. * @param int $draftitemid draft item id. * @return array filepicker options for the editor. */ protected function get_filepicker_options($context, $draftitemid) { return array('return_types' => FILE_INTERNAL | FILE_EXTERNAL); } /** * @param string $inputname input field name. * @param int $draftitemid draft file area itemid. * @return string HTML for the filepicker, if used. */ protected function filepicker_html($inputname, $draftitemid) { return ''; } } /** * An essay format renderer for essays where the student should use the HTML * editor with the file picker. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_editorfilepicker_renderer extends qtype_essay_format_editor_renderer { protected function class_name() { return 'qtype_essay_editorfilepicker'; } protected function prepare_response($name, question_attempt $qa, question_attempt_step $step, $context) { if (!$step->has_qt_var($name)) { return ''; } $formatoptions = new stdClass(); $formatoptions->para = false; $text = $qa->rewrite_response_pluginfile_urls($step->get_qt_var($name), $context->id, 'answer', $step); return format_text($text, $step->get_qt_var($name . 'format'), $formatoptions); } protected function prepare_response_for_editing($name, question_attempt_step $step, $context) { return $step->prepare_response_files_draft_itemid_with_text( $name, $context->id, $step->get_qt_var($name)); } /** * Get editor options for question response text area. * @param object $context the context the attempt belongs to. * @return array options for the editor. */ protected function get_editor_options($context) { return question_utils::get_editor_options($context); } /** * Get the options required to configure the filepicker for one of the editor * toolbar buttons. * @deprecated since 3.5 * @param mixed $acceptedtypes array of types of '*'. * @param int $draftitemid the draft area item id. * @param object $context the context. * @return object the required options. */ protected function specific_filepicker_options($acceptedtypes, $draftitemid, $context) { debugging('qtype_essay_format_editorfilepicker_renderer::specific_filepicker_options() is deprecated, ' . 'use question_utils::specific_filepicker_options() instead.', DEBUG_DEVELOPER); $filepickeroptions = new stdClass(); $filepickeroptions->accepted_types = $acceptedtypes; $filepickeroptions->return_types = FILE_INTERNAL | FILE_EXTERNAL; $filepickeroptions->context = $context; $filepickeroptions->env = 'filepicker'; $options = initialise_filepicker($filepickeroptions); $options->context = $context; $options->client_id = uniqid(); $options->env = 'editor'; $options->itemid = $draftitemid; return $options; } /** * @param object $context the context the attempt belongs to. * @param int $draftitemid draft item id. * @return array filepicker options for the editor. */ protected function get_filepicker_options($context, $draftitemid) { return question_utils::get_filepicker_options($context, $draftitemid); } protected function filepicker_html($inputname, $draftitemid) { $nonjspickerurl = new moodle_url('/repository/draftfiles_manager.php', array( 'action' => 'browse', 'env' => 'editor', 'itemid' => $draftitemid, 'subdirs' => false, 'maxfiles' => -1, 'sesskey' => sesskey(), )); return html_writer::empty_tag('input', array('type' => 'hidden', 'name' => $inputname . ':itemid', 'value' => $draftitemid)) . html_writer::tag('noscript', html_writer::tag('div', html_writer::tag('object', '', array('type' => 'text/html', 'data' => $nonjspickerurl, 'height' => 160, 'width' => 600, 'style' => 'border: 1px solid #000;')))); } } /** * An essay format renderer for essays where the student should use a plain * input box, but with a normal, proportional font. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_plain_renderer extends qtype_essay_format_renderer_base { /** * @return string the HTML for the textarea. */ protected function textarea($response, $lines, $attributes) { $attributes['class'] = $this->class_name() . ' qtype_essay_response form-control'; $attributes['rows'] = $lines; $attributes['cols'] = 60; return html_writer::tag('textarea', s($response), $attributes); } protected function class_name() { return 'qtype_essay_plain'; } public function response_area_read_only($name, $qa, $step, $lines, $context) { $id = $qa->get_qt_field_name($name) . '_id'; $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_essay')); $output = html_writer::tag('label', $responselabel, ['class' => 'visually-hidden', 'for' => $id]); $output .= $this->textarea($step->get_qt_var($name), $lines, ['id' => $id, 'readonly' => 'readonly']); return $output; } public function response_area_input($name, $qa, $step, $lines, $context) { $inputname = $qa->get_qt_field_name($name); $id = $inputname . '_id'; $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_essay')); $output = html_writer::tag('label', $responselabel, ['class' => 'visually-hidden', 'for' => $id]); $output .= $this->textarea($step->get_qt_var($name), $lines, ['name' => $inputname, 'id' => $id]); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => $inputname . 'format', 'value' => FORMAT_PLAIN]); return $output; } } /** * An essay format renderer for essays where the student should use a plain * input box with a monospaced font. You might use this, for example, for a * question where the students should type computer code. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_monospaced_renderer extends qtype_essay_format_plain_renderer { protected function class_name() { return 'qtype_essay_monospaced'; } } PK\_ddtype/essay/db/upgrade.phpnu[. /** * Essay question type upgrade code. * * @package qtype * @subpackage essay * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the essay question type. * @param int $oldversion the version we are upgrading from. */ function xmldb_qtype_essay_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\̜ type/essay/db/install.xmlnu[
    PK\u'type/essay/db/upgradelib.phpnu[. /** * Upgrade library code for the essay question type. * * @package qtype * @subpackage essay * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Class for converting attempt data for essay questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_qe2_attempt_updater extends question_qtype_attempt_updater { public function right_answer() { return ''; } public function response_summary($state) { if (!empty($state->answer)) { return $this->to_text($state->answer); } else { return null; } } public function was_answered($state) { return !empty($state->answer); } public function set_first_step_data_elements($state, &$data) { } public function supply_missing_first_step_data(&$data) { } public function set_data_elements_for_step($state, &$data) { if (!empty($state->answer)) { $data['answer'] = $state->answer; $data['answerformat'] = FORMAT_HTML; } } } PK\^  "type/essay/lang/en/qtype_essay.phpnu[. /** * Strings for component 'qtype_essay', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage essay * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['acceptedfiletypes'] = 'Accepted file types'; $string['acceptedfiletypes_help'] = 'Accepted file types can be restricted by entering a list of file extensions. If the field is left empty, then all file types are allowed.'; $string['allowattachments'] = 'Allow attachments'; $string['answerfiles'] = 'Answer files'; $string['answertext'] = 'Answer text'; $string['attachedfiles'] = 'Attachments: {$a}'; $string['attachmentsoptional'] = 'Attachments are optional'; $string['attachmentsrequired'] = 'Require attachments'; $string['attachmentsrequired_help'] = 'This option specifies the minimum number of attachments required for a response to be considered gradable.'; $string['err_maxminmismatch'] = 'Maximum word limit must be greater than minimum word limit'; $string['err_maxwordlimit'] = 'Maximum word limit is enabled but is not set'; $string['err_maxwordlimitnegative'] = 'Maximum word limit cannot be a negative number'; $string['err_minwordlimit'] = 'Minimum word limit is enabled but is not set'; $string['err_minwordlimitnegative'] = 'Minimum word limit cannot be a negative number'; $string['formateditor'] = 'HTML editor'; $string['formateditorfilepicker'] = 'HTML editor with file picker'; $string['formatmonospaced'] = 'Plain text, monospaced font'; $string['formatnoinline'] = 'No online text'; $string['formatplain'] = 'Plain text'; $string['graderinfo'] = 'Information for graders'; $string['graderinfoheader'] = 'Grader information'; $string['maxbytes'] = 'Maximum file size'; $string['maxwordlimit'] = 'Maximum word limit'; $string['maxwordlimit_help'] = 'If the response requires that students enter text, this is the maximum number of words that each student will be allowed to submit.'; $string['maxwordlimitboundary'] = 'The word limit for this question is {$a->limit} words and you are attempting to submit {$a->count} words. Please shorten your response and try again.'; $string['minwordlimit'] = 'Minimum word limit'; $string['minwordlimit_help'] = 'If the response requires that students enter text, this is the minimum number of words that each student will be allowed to submit.'; $string['minwordlimitboundary'] = 'This question requires a response of at least {$a->limit} words and you are attempting to submit {$a->count} words. Please expand your response and try again.'; $string['mustattach'] = 'When "No online text" is selected, or responses are optional, you must allow at least one attachment.'; $string['mustrequire'] = 'When "No online text" is selected, or responses are optional, you must require at least one attachment.'; $string['mustrequirefewer'] = 'You cannot require more attachments than you allow.'; $string['nlines'] = '{$a} lines'; $string['nonexistentfiletypes'] = 'The following file types were not recognised: {$a}'; $string['pluginname'] = 'Essay'; $string['pluginname_help'] = 'In response to a question, the respondent may upload one or more files and/or enter text online. A response template may be provided. Responses must be graded manually.'; $string['pluginname_link'] = 'question/type/essay'; $string['pluginnameadding'] = 'Adding an Essay question'; $string['pluginnameediting'] = 'Editing an Essay question'; $string['pluginnamesummary'] = 'Allows a response of a file upload and/or online text. This must then be graded manually.'; $string['privacy:metadata'] = 'Essay question type plugin allows question authors to set default options as user preferences.'; $string['privacy:preference:defaultmark'] = 'The default mark set for a given question.'; $string['privacy:preference:responseformat'] = 'What is the response format (HTML editor, plain text, etc.)?'; $string['privacy:preference:responserequired'] = 'Whether the student is required to enter text or the text input is optional.'; $string['privacy:preference:responsefieldlines'] = 'Number of lines indicating the size of the input box (textarea).'; $string['privacy:preference:attachments'] = 'Number of allowed attachments.'; $string['privacy:preference:attachmentsrequired'] = 'Number of required attachments.'; $string['privacy:preference:maxbytes'] = 'Maximum file size.'; $string['responsefieldlines'] = 'Input box size'; $string['responseformat'] = 'Response format'; $string['responseoptions'] = 'Response options'; $string['responserequired'] = 'Require text'; $string['responsenotrequired'] = 'Text input is optional'; $string['responseisrequired'] = 'Require the student to enter text'; $string['responsetemplate'] = 'Response template'; $string['responsetemplateheader'] = 'Response template'; $string['responsetemplate_help'] = 'Any text entered here will be displayed in the response input box when a new attempt at the question starts.'; $string['wordcount'] = 'Word count: {$a}'; $string['wordcounttoofew'] = 'Word count: {$a->count}, less than the required {$a->limit} words.'; $string['wordcounttoomuch'] = 'Word count: {$a->count}, more than the limit of {$a->limit} words.'; PK\ Moo>type/essay/backup/moodle2/restore_qtype_essay_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * restore plugin class that provides the necessary information * needed to restore one essay qtype plugin * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_essay_plugin extends restore_qtype_plugin { /** * Returns the paths to be handled by the plugin at question level */ protected function define_question_plugin_structure() { return array( new restore_path_element('essay', $this->get_pathfor('/essay')) ); } /** * Process the qtype/essay element */ public function process_essay($data) { global $DB; $data = (object)$data; $oldid = $data->id; if (!isset($data->responsetemplate)) { $data->responsetemplate = ''; } if (!isset($data->responsetemplateformat)) { $data->responsetemplateformat = FORMAT_HTML; } if (!isset($data->responserequired)) { $data->responserequired = 1; } if (!isset($data->minwordlimit)) { $data->minwordlimit = null; } if (!isset($data->maxwordlimit)) { $data->maxwordlimit = null; } if (!isset($data->attachmentsrequired)) { $data->attachmentsrequired = 0; } // Detect if the question is created or mapped. $questioncreated = $this->get_mappingid('question_created', $this->get_old_parentid('question')) ? true : false; // If the question has been created by restore, we need to create its // qtype_essay too. if ($questioncreated) { $data->questionid = $this->get_new_parentid('question'); $newitemid = $DB->insert_record('qtype_essay_options', $data); $this->set_mapping('qtype_essay', $oldid, $newitemid); } } /** * Return the contents of this qtype to be processed by the links decoder */ public static function define_decode_contents() { return array( new restore_decode_content('qtype_essay_options', 'graderinfo', 'qtype_essay'), ); } /** * When restoring old data, that does not have the essay options information * in the XML, supply defaults. */ protected function after_execute_question() { global $DB; $essayswithoutoptions = $DB->get_records_sql(" SELECT q.* FROM {question} q JOIN {backup_ids_temp} bi ON bi.newitemid = q.id LEFT JOIN {qtype_essay_options} qeo ON qeo.questionid = q.id WHERE q.qtype = ? AND qeo.id IS NULL AND bi.backupid = ? AND bi.itemname = ? ", array('essay', $this->get_restoreid(), 'question_created')); foreach ($essayswithoutoptions as $q) { $defaultoptions = new stdClass(); $defaultoptions->questionid = $q->id; $defaultoptions->responseformat = 'editor'; $defaultoptions->responserequired = 1; $defaultoptions->responsefieldlines = 15; $defaultoptions->minwordlimit = null; $defaultoptions->maxwordlimit = null; $defaultoptions->attachments = 0; $defaultoptions->attachmentsrequired = 0; $defaultoptions->graderinfo = ''; $defaultoptions->graderinfoformat = FORMAT_HTML; $defaultoptions->responsetemplate = ''; $defaultoptions->responsetemplateformat = FORMAT_HTML; $DB->insert_record('qtype_essay_options', $defaultoptions); } } } PK\#D D =type/essay/backup/moodle2/backup_qtype_essay_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup essay questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_essay_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. $plugin = $this->get_plugin_element(null, '../../qtype', 'essay'); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // Now create the qtype own structures. $essay = new backup_nested_element('essay', array('id'), array( 'responseformat', 'responserequired', 'responsefieldlines', 'minwordlimit', 'maxwordlimit', 'attachments', 'attachmentsrequired', 'graderinfo', 'graderinfoformat', 'responsetemplate', 'responsetemplateformat', 'filetypeslist', 'maxbytes')); // Now the own qtype tree. $pluginwrapper->add_child($essay); // Set source to populate the data. $essay->set_source_table('qtype_essay_options', array('questionid' => backup::VAR_PARENTID)); // Don't need to annotate ids nor files. return $plugin; } /** * Returns one array with filearea => mappingname elements for the qtype * * Used by {@link get_components_and_fileareas} to know about all the qtype * files to be processed both in backup and restore. */ public static function get_qtype_fileareas() { return array( 'graderinfo' => 'question_created', ); } } PK\oC=rr!type/essay/backup/moodle1/lib.phpnu[. /** * @package qtype * @subpackage essay * @copyright 2011 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Short answer question type conversion handler */ class moodle1_qtype_essay_handler extends moodle1_qtype_handler { /** * @return array */ public function get_question_subpaths() { return array(); } /** * Appends the essay specific information to the question */ public function process_question(array $data, array $raw) { // Data added on the upgrade step 2011031000. $this->write_xml('essay', array( 'id' => $this->converter->get_nextid(), 'responseformat' => 'editor', 'responserequired' => 1, 'responsefieldlines' => 15, 'attachments' => 0, 'attachmentsrequired' => 0, 'graderinfo' => '', 'graderinfoformat' => FORMAT_HTML, 'responsetemplate' => '', 'responsetemplateformat' => FORMAT_HTML ), array('/essay/id')); } } PK\hhtype/essay/pix/icon.gifnu[GIF89a! ,9HJAqGX4Yd(e6 V궰:+ܿڎ"@a T;PK\type/essay/pix/icon.svgnu[PK\vtype/essay/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_essay * @copyright Dongsheng Cai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for essay questions. * * @package qtype_essay * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_essay_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_essay', $filearea, $args, $forcedownload, $options); } PK\0,99type/essay/version.phpnu[. /** * Version information for the essay question type. * * @package qtype * @subpackage essay * @copyright 2005 Mark Nielsen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_essay'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\11type/essay/question.phpnu[. /** * Essay question definition class. * * @package qtype * @subpackage essay * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); /** * Represents an essay question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_question extends question_with_responses { public $responseformat; /** @var int Indicates whether an inline response is required ('0') or optional ('1') */ public $responserequired; public $responsefieldlines; /** @var int indicates whether the minimum number of words required */ public $minwordlimit; /** @var int indicates whether the maximum number of words required */ public $maxwordlimit; public $attachments; /** @var int maximum file size in bytes */ public $maxbytes; /** @var int The number of attachments required for a response to be complete. */ public $attachmentsrequired; public $graderinfo; public $graderinfoformat; public $responsetemplate; public $responsetemplateformat; /** @var array The string array of file types accepted upon file submission. */ public $filetypeslist; public function make_behaviour(question_attempt $qa, $preferredbehaviour) { return question_engine::make_behaviour('manualgraded', $qa, $preferredbehaviour); } /** * @param moodle_page the page we are outputting to. * @return qtype_essay_format_renderer_base the response-format-specific renderer. */ public function get_format_renderer(moodle_page $page) { return $page->get_renderer('qtype_essay', 'format_' . $this->responseformat); } public function get_expected_data() { if ($this->responseformat == 'editorfilepicker') { $expecteddata = array('answer' => question_attempt::PARAM_RAW_FILES); } else { $expecteddata = array('answer' => PARAM_RAW); } $expecteddata['answerformat'] = PARAM_ALPHANUMEXT; if ($this->attachments != 0) { $expecteddata['attachments'] = question_attempt::PARAM_FILES; } return $expecteddata; } public function summarise_response(array $response) { $output = null; if (isset($response['answer'])) { $output .= question_utils::to_plain_text($response['answer'], $response['answerformat'], array('para' => false)); } if (isset($response['attachments']) && $response['attachments']) { $attachedfiles = []; foreach ($response['attachments']->get_files() as $file) { $attachedfiles[] = $file->get_filename() . ' (' . display_size($file->get_filesize()) . ')'; } if ($attachedfiles) { $output .= get_string('attachedfiles', 'qtype_essay', implode(', ', $attachedfiles)); } } return $output; } public function un_summarise_response(string $summary) { if (empty($summary)) { return []; } if (str_contains($this->responseformat, 'editor')) { return ['answer' => text_to_html($summary), 'answerformat' => FORMAT_HTML]; } else { return ['answer' => $summary, 'answerformat' => FORMAT_PLAIN]; } } public function get_correct_response() { return null; } public function is_complete_response(array $response) { // Determine if the given response has online text and attachments. $hasinlinetext = array_key_exists('answer', $response) && ($response['answer'] !== ''); // If there is a response and min/max word limit is set in the form then validate the number of words in response. if ($hasinlinetext) { if ($this->check_input_word_count($response['answer'])) { return false; } } $hasattachments = array_key_exists('attachments', $response) && $response['attachments'] instanceof question_response_files; // Determine the number of attachments present. if ($hasattachments) { // Check the filetypes. $filetypesutil = new \core_form\filetypes_util(); $allowlist = $filetypesutil->normalize_file_types($this->filetypeslist); $wrongfiles = array(); foreach ($response['attachments']->get_files() as $file) { if (!$filetypesutil->is_allowed_file_type($file->get_filename(), $allowlist)) { $wrongfiles[] = $file->get_filename(); } } if ($wrongfiles) { // At least one filetype is wrong. return false; } $attachcount = count($response['attachments']->get_files()); } else { $attachcount = 0; } // Determine if we have /some/ content to be graded. $hascontent = $hasinlinetext || ($attachcount > 0); // Determine if we meet the optional requirements. $meetsinlinereq = $hasinlinetext || (!$this->responserequired) || ($this->responseformat == 'noinline'); $meetsattachmentreq = ($attachcount >= $this->attachmentsrequired); // The response is complete iff all of our requirements are met. return $hascontent && $meetsinlinereq && $meetsattachmentreq; } /** * Return null if is_complete_response() returns true * otherwise, return the minmax-limit error message * * @param array $response * @return string */ public function get_validation_error(array $response) { if ($this->is_complete_response($response)) { return ''; } return $this->check_input_word_count($response['answer']); } public function is_gradable_response(array $response) { // Determine if the given response has online text and attachments. if (array_key_exists('answer', $response) && ($response['answer'] !== '')) { return true; } else if (array_key_exists('attachments', $response) && $response['attachments'] instanceof question_response_files) { return true; } else { return false; } } public function is_same_response(array $prevresponse, array $newresponse) { if (array_key_exists('answer', $prevresponse) && $prevresponse['answer'] !== $this->responsetemplate) { $value1 = (string) $prevresponse['answer']; } else { $value1 = ''; } if (array_key_exists('answer', $newresponse) && $newresponse['answer'] !== $this->responsetemplate) { $value2 = (string) $newresponse['answer']; } else { $value2 = ''; } return $value1 === $value2 && ($this->attachments == 0 || question_utils::arrays_same_at_key_missing_is_blank( $prevresponse, $newresponse, 'attachments')); } public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { if ($component == 'question' && $filearea == 'response_attachments') { // Response attachments visible if the question has them. return $this->attachments != 0; } else if ($component == 'question' && $filearea == 'response_answer') { // Response attachments visible if the question has them. return $this->responseformat === 'editorfilepicker'; } else if ($component == 'qtype_essay' && $filearea == 'graderinfo') { return $options->manualcomment && $args[0] == $this->id; } else { return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload); } } /** * Return the question settings that define this question as structured data. * * @param question_attempt $qa the current attempt for which we are exporting the settings. * @param question_display_options $options the question display options which say which aspects of the question * should be visible. * @return mixed structure representing the question settings. In web services, this will be JSON-encoded. */ public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) { // This is a partial implementation, returning only the most relevant question settings for now, // ideally, we should return as much as settings as possible (depending on the state and display options). $settings = [ 'responseformat' => $this->responseformat, 'responserequired' => $this->responserequired, 'responsefieldlines' => $this->responsefieldlines, 'attachments' => $this->attachments, 'attachmentsrequired' => $this->attachmentsrequired, 'maxbytes' => $this->maxbytes, 'filetypeslist' => $this->filetypeslist, 'responsetemplate' => $this->responsetemplate, 'responsetemplateformat' => $this->responsetemplateformat, 'minwordlimit' => $this->minwordlimit, 'maxwordlimit' => $this->maxwordlimit, ]; return $settings; } /** * Check the input word count and return a message to user * when the number of words are outside the boundary settings. * * @param string $responsestring * @return string|null .*/ private function check_input_word_count($responsestring) { if (!$this->responserequired) { return null; } if (!$this->minwordlimit && !$this->maxwordlimit) { // This question does not care about the word count. return null; } // Count the number of words in the response string. $count = count_words($responsestring); if ($this->maxwordlimit && $count > $this->maxwordlimit) { return get_string('maxwordlimitboundary', 'qtype_essay', ['limit' => $this->maxwordlimit, 'count' => $count]); } else if ($count < $this->minwordlimit) { return get_string('minwordlimitboundary', 'qtype_essay', ['limit' => $this->minwordlimit, 'count' => $count]); } else { return null; } } /** * If this question uses word counts, then return a display of the current * count, and whether it is within limit, for when the question is being reviewed. * * @param array $response responses, as returned by * {@see question_attempt_step::get_qt_data()}. * @return string If relevant to this question, a display of the word count. */ public function get_word_count_message_for_review(array $response): string { if (!$this->minwordlimit && !$this->maxwordlimit) { // This question does not care about the word count. return ''; } if (!array_key_exists('answer', $response) || ($response['answer'] === '')) { // No response. return ''; } $count = count_words($response['answer']); if ($this->maxwordlimit && $count > $this->maxwordlimit) { return get_string('wordcounttoomuch', 'qtype_essay', ['limit' => $this->maxwordlimit, 'count' => $count]); } else if ($count < $this->minwordlimit) { return get_string('wordcounttoofew', 'qtype_essay', ['limit' => $this->minwordlimit, 'count' => $count]); } else { return get_string('wordcount', 'qtype_essay', $count); } } } PK\r'type/essay/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_essay. * * @package qtype_essay * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_essay\privacy; use \core_privacy\local\metadata\collection; use \core_privacy\local\request\user_preference_provider; use \core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_essay implementing user_preference_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This component has data. // We need to return default options that have been set a user preferences. \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_user_preference('qtype_essay_defaultmark', 'privacy:preference:defaultmark'); $collection->add_user_preference('qtype_essay_responseformat', 'privacy:preference:responseformat'); $collection->add_user_preference('qtype_essay_responserequired', 'privacy:preference:responserequired'); $collection->add_user_preference('qtype_essay_responsefieldlines', 'privacy:preference:responsefieldlines'); $collection->add_user_preference('qtype_essay_attachments', 'privacy:preference:attachments'); $collection->add_user_preference('qtype_essay_attachmentsrequired', 'privacy:preference:attachmentsrequired'); $collection->add_user_preference('qtype_essay_maxbytes', 'privacy:preference:maxbytes'); return $collection; } /** * Export all user preferences for the plugin. * * @param int $userid The userid of the user whose data is to be exported. */ public static function export_user_preferences(int $userid) { $preference = get_user_preferences('qtype_essay_defaultmark', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:defaultmark', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'defaultmark', $preference, $desc); } $preference = get_user_preferences('qtype_essay_responseformat', null, $userid); if (null !== $preference) { switch($preference) { case 'editor': $stringvalue = get_string('formateditor', 'qtype_essay'); break; case 'editorfilepicker': $stringvalue = get_string('formateditorfilepicker', 'qtype_essay'); break; case 'plain': $stringvalue = get_string('formatplain', 'qtype_essay'); break; case 'monospaced': $stringvalue = get_string('formatmonospaced', 'qtype_essay'); break; case 'noinline': $stringvalue = get_string('formatnoinline', 'qtype_essay'); break; default: $stringvalue = get_string('formateditor', 'qtype_essay'); break; } $desc = get_string('privacy:preference:responseformat', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'responseformat', $stringvalue, $desc); } $preference = get_user_preferences('qtype_essay_responserequired', null, $userid); if (null !== $preference) { if ($preference) { $stringvalue = get_string('responseisrequired', 'qtype_essay'); } else { $stringvalue = get_string('responsenotrequired', 'qtype_essay'); } $desc = get_string('privacy:preference:responserequired', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'responserequired', $stringvalue, $desc); } $preference = get_user_preferences('qtype_essay_responsefieldlines', null, $userid); if (null !== $preference) { $desc = get_string('privacy:preference:responsefieldlines', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'responsefieldlines', get_string('nlines', 'qtype_essay', $preference), $desc); } $preference = get_user_preferences('qtype_essay_attachments', null, $userid); if (null !== $preference) { if ($preference == 0) { $stringvalue = get_string('no'); } else if ($preference == -1) { $stringvalue = get_string('unlimited'); } else { $stringvalue = $preference; } $desc = get_string('privacy:preference:attachments', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'attachments', $stringvalue, $desc); } $preference = get_user_preferences('qtype_essay_attachmentsrequired', null, $userid); if (null !== $preference) { if ($preference == 0) { $stringvalue = get_string('attachmentsoptional', 'qtype_essay'); } else { $stringvalue = $preference; } $desc = get_string('privacy:preference:attachmentsrequired', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'attachmentsrequired', $stringvalue, $desc); } $preference = get_user_preferences('qtype_essay_maxbytes', null, $userid); if (null !== $preference) { switch ($preference) { case 52428800: $stringvalue = '50MB'; break; case 20971520: $stringvalue = '20MB'; break; case 10485760: $stringvalue = '10MB'; break; case 5242880: $stringvalue = '5MB'; break; case 2097152: $stringvalue = '2MB'; break; case 1048576: $stringvalue = '1MB'; break; case 512000: $stringvalue = '500KB'; break; case 102400: $stringvalue = '100KB'; break; case 51200: $stringvalue = '50KB'; break; case 10240: $stringvalue = '10KB'; break; default: $stringvalue = '50MB'; break; } $desc = get_string('privacy:preference:maxbytes', 'qtype_essay'); writer::export_user_preference('qtype_essay', 'maxbytes', $stringvalue, $desc); } } } PK\ammtype/essay/tests/fixtures/3.pngnu[PNG  IHDRmmV pHYs)y)yU 2tEXtSoftwarewww.inkscape.org<IDATxϋuVɒRuHCIEaD:xD"P')2âZ ];kADDYV3ێ]?~|g; # HV.+=@\̗$j;.6w'FpRF3 e4CPF3 e4CPF3 e4CPF3 e4CPF3 e4CPF3 e4CPF3 e4CPF3 e4CPo]|H \ LS9+TDRp&i,ߒN/E ֧GIQEoC$=t=$56f9'rF`7P1IDM<^Q/sPP}5L|siCdD\cDt7bAI#綇7iq!"[-XzZgmˏEQע]%Uע=X6"dV6{PYFI*m[kWH <4 젺 |䬙hs+p8Nnvcm2n(=(3O;?S`[JkۀۀGL#h>sDG½R߁"Lй'F#zUrS ?56)5(:6XFb XR v4?M2NEOO46ͮ6]`pEo} FkI3t;I(i/)Ow'4X_{IQ]klY`EchS]nl@>p|0"ZU"I;q^?˗ ǻP-Kg8鬌f(h2f(h2f(h2f(h2f(h2f(h2f(h2f(h2f(h2f(h^ ΒN%yz4/IENDB`PK\5N type/essay/tests/fixtures/1.pngnu[PNG  IHDRmmV pHYs)y)yU 2tEXtSoftwarewww.inkscape.org<+IDATxٱAFsuT|} 132w001c3@ L4|Ad 8,4)z&š) swq} $vZGp6qw_3ORUkQ)фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM(фM`O pa?Fγ/S>i3q $course$/Default for C1 essay-001 Write an essay with 500 words. This is general feedback 1.0000000 0.0000000 0 editor 1 15 20 0 0 0 PK\^  type/essay/tests/fixtures/2.pngnu[PNG  IHDRmmV pHYs)y)yU 2tEXtSoftwarewww.inkscape.org<IDATxMUu)A/ZbN\uYDtmZh[CB;qai1nYFAw /;>p?s9w^wHLʢSYK`z\gMDLo/M r4A&9 GhM r4A&9 GhM r4A&9 GhM r4A&9 Ghf?DGDxX < *י^D'N` /3n/1_ׁϘ9r`/p-"ZEL0UqoD(TCrIENDB`PK\. namespace qtype_essay; use question_attempt_step; use question_display_options; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the matching question definition class. * * @package qtype_essay * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qtype_essay_question */ final class question_test extends \advanced_testcase { public function test_get_question_summary(): void { $essay = \test_question_maker::make_an_essay_question(); $essay->questiontext = 'Hello world'; $this->assertEquals('Hello [world]', $essay->get_question_summary()); } /** * Test summarise_response() when teachers view quiz attempts and then * review them to see what has been saved in the response history table. * * @dataProvider summarise_response_provider * @param int $responserequired * @param int $attachmentsrequired * @param string $answertext * @param int $attachmentuploaded * @param string $expected */ public function test_summarise_response(int $responserequired, int $attachmentsrequired, string $answertext, int $attachmentuploaded, string $expected): void { $this->resetAfterTest(); // If number of allowed attachments is set to 'Unlimited', generate 10 attachments for testing purpose. $numberofattachments = ($attachmentsrequired === -1) ? 10 : $attachmentsrequired; // Create sample attachments. $attachments = $this->create_user_and_sample_attachments($numberofattachments); // Create the essay question under test. $essay = \test_question_maker::make_an_essay_question(); $essay->start_attempt(new question_attempt_step(), 1); $essay->responseformat = 'editor'; $essay->responserequired = $responserequired; $essay->attachmentsrequired = $attachmentsrequired; // The space before the number of bytes from display_size is actually a non-breaking space. $expected = str_replace(' bytes', "\xc2\xa0bytes", $expected); $this->assertEquals($expected, $essay->summarise_response( ['answer' => $answertext, 'answerformat' => FORMAT_HTML, 'attachments' => $attachments[$attachmentuploaded]])); } /** * Data provider for summarise_response() test cases. * * @return array List of data sets (test cases) */ public static function summarise_response_provider(): array { return [ 'text input required, not attachments required' => [1, 0, 'This is the text input for this essay.', 0, 'This is the text input for this essay.'], 'Text input required, one attachments required, one uploaded' => [1, 1, 'This is the text input for this essay.', 1, 'This is the text input for this essay.Attachments: 0 (1 bytes)'], 'Text input is optional, four attachments required, one uploaded' => [0, 4, '', 1, 'Attachments: 0 (1 bytes)'], 'Text input is optional, four attachments required, two uploaded' => [0, 4, '', 2, 'Attachments: 0 (1 bytes), 1 (1 bytes)'], 'Text input is optional, four attachments required, three uploaded' => [0, 4, '', 3, 'Attachments: 0 (1 bytes), 1 (1 bytes), 2 (1 bytes)'], 'Text input is optional, four attachments required, four uploaded' => [0, 4, 'I have attached 4 files.', 4, 'I have attached 4 files.Attachments: 0 (1 bytes), 1 (1 bytes), 2 (1 bytes), 3 (1 bytes)'], 'Text input is optional, unlimited attachments required, one uploaded' => [0, -1, '', 1, 'Attachments: 0 (1 bytes)'], 'Text input is optional, unlimited attachments required, five uploaded' => [0, -1, 'I have attached 5 files.', 5, 'I have attached 5 files.Attachments: 0 (1 bytes), 1 (1 bytes), 2 (1 bytes), 3 (1 bytes), 4 (1 bytes)'], 'Text input is optional, unlimited attachments required, ten uploaded' => [0, -1, '', 10, 'Attachments: 0 (1 bytes), 1 (1 bytes), 2 (1 bytes), 3 (1 bytes), 4 (1 bytes), ' . '5 (1 bytes), 6 (1 bytes), 7 (1 bytes), 8 (1 bytes), 9 (1 bytes)'] ]; } /** * Test un_summarise_response(). * * @dataProvider un_summarise_response_provider * @param string $responseformat * @param string $summary * @param array $expected */ public function test_un_summarise_response(string $responseformat, string $summary, array $expected): void { $this->resetAfterTest(); $essay = \test_question_maker::make_an_essay_question(); $essay->start_attempt(new question_attempt_step(), 1); $essay->responseformat = $responseformat; $this->assertEquals($expected, $essay->un_summarise_response($summary)); } /** * Data provider for test_un_summarise_response(). * * Covers multiple response formats, empty responses, * case sensitivity, unicode text, and HTML content. * * @return array */ public static function un_summarise_response_provider(): array { return [ 'empty summary returns empty array' => [ 'plain', '', [], ], 'plain format returns plain text' => [ 'plain', 'Hello Moodle', ['answer' => 'Hello Moodle', 'answerformat' => FORMAT_PLAIN], ], 'editor format converts to HTML' => [ 'editor', 'Hello Moodle', ['answer' => text_to_html('Hello Moodle'), 'answerformat' => FORMAT_HTML], ], 'plain format preserves HTML tags literally' => [ 'plain', 'bold', ['answer' => 'bold', 'answerformat' => FORMAT_PLAIN], ], ]; } public function test_is_same_response(): void { $essay = \test_question_maker::make_an_essay_question(); $essay->responsetemplate = ''; $essay->start_attempt(new question_attempt_step(), 1); $this->assertTrue($essay->is_same_response( array(), array('answer' => ''))); $this->assertTrue($essay->is_same_response( array('answer' => ''), array('answer' => ''))); $this->assertTrue($essay->is_same_response( array('answer' => ''), array())); $this->assertFalse($essay->is_same_response( array('answer' => 'Hello'), array())); $this->assertFalse($essay->is_same_response( array('answer' => 'Hello'), array('answer' => ''))); $this->assertFalse($essay->is_same_response( array('answer' => 0), array('answer' => ''))); $this->assertFalse($essay->is_same_response( array('answer' => ''), array('answer' => 0))); $this->assertFalse($essay->is_same_response( array('answer' => '0'), array('answer' => ''))); $this->assertFalse($essay->is_same_response( array('answer' => ''), array('answer' => '0'))); } public function test_is_same_response_with_template(): void { $essay = \test_question_maker::make_an_essay_question(); $essay->responsetemplate = 'Once upon a time'; $essay->start_attempt(new question_attempt_step(), 1); $this->assertTrue($essay->is_same_response( array(), array('answer' => 'Once upon a time'))); $this->assertTrue($essay->is_same_response( array('answer' => ''), array('answer' => 'Once upon a time'))); $this->assertTrue($essay->is_same_response( array('answer' => 'Once upon a time'), array('answer' => ''))); $this->assertTrue($essay->is_same_response( array('answer' => ''), array())); $this->assertTrue($essay->is_same_response( array('answer' => 'Once upon a time'), array())); $this->assertFalse($essay->is_same_response( array('answer' => 0), array('answer' => ''))); $this->assertFalse($essay->is_same_response( array('answer' => ''), array('answer' => 0))); $this->assertFalse($essay->is_same_response( array('answer' => '0'), array('answer' => ''))); $this->assertFalse($essay->is_same_response( array('answer' => ''), array('answer' => '0'))); } public function test_is_complete_response(): void { $this->resetAfterTest(true); // Create sample attachments. $attachments = $this->create_user_and_sample_attachments(); // Create the essay question under test. $essay = \test_question_maker::make_an_essay_question(); $essay->start_attempt(new question_attempt_step(), 1); // Test the "traditional" case, where we must receive a response from the user. $essay->responserequired = 1; $essay->attachmentsrequired = 0; $essay->responseformat = 'editor'; // The empty string should be considered an incomplete response, as should a lack of a response. $this->assertFalse($essay->is_complete_response(array('answer' => ''))); $this->assertFalse($essay->is_complete_response(array())); // Any nonempty string should be considered a complete response. $this->assertTrue($essay->is_complete_response(array('answer' => 'A student response.'))); $this->assertTrue($essay->is_complete_response(array('answer' => '0 times.'))); $this->assertTrue($essay->is_complete_response(array('answer' => '0'))); // Test case for minimum and/or maximum word limit. $response = []; $response['answer'] = 'In this essay, I will be testing a function called check_input_word_count().'; $essay->minwordlimit = 50; // The answer is shorter than the required minimum word limit. $this->assertFalse($essay->is_complete_response($response)); $essay->minwordlimit = 10; // The word count meets the required minimum word limit. $this->assertTrue($essay->is_complete_response($response)); // The word count meets the required minimum and maximum word limit. $essay->minwordlimit = 10; $essay->maxwordlimit = 15; $this->assertTrue($essay->is_complete_response($response)); // Unset the minwordlimit/maxwordlimit variables to avoid the extra check in is_complete_response() for further tests. $essay->minwordlimit = null; $essay->maxwordlimit = null; // Test the case where two files are required. $essay->attachmentsrequired = 2; // Attaching less than two files should result in an incomplete response. $this->assertFalse($essay->is_complete_response(array('answer' => 'A'))); $this->assertFalse($essay->is_complete_response( array('answer' => 'A', 'attachments' => $attachments[0]))); $this->assertFalse($essay->is_complete_response( array('answer' => 'A', 'attachments' => $attachments[1]))); // Anything without response text should result in an incomplete response. $this->assertFalse($essay->is_complete_response( array('answer' => '', 'attachments' => $attachments[2]))); // Attaching two or more files should result in a complete response. $this->assertTrue($essay->is_complete_response( array('answer' => 'A', 'attachments' => $attachments[2]))); $this->assertTrue($essay->is_complete_response( array('answer' => 'A', 'attachments' => $attachments[3]))); // Test the case in which two files are required, but the inline // response is optional. $essay->responserequired = 0; $this->assertFalse($essay->is_complete_response( array('answer' => '', 'attachments' => $attachments[1]))); $this->assertTrue($essay->is_complete_response( array('answer' => '', 'attachments' => $attachments[2]))); // Test the case in which both the response and online text are optional. $essay->attachmentsrequired = 0; // Providing no answer and no attachment should result in an incomplete // response. $this->assertFalse($essay->is_complete_response( array('answer' => ''))); $this->assertFalse($essay->is_complete_response( array('answer' => '', 'attachments' => $attachments[0]))); // Providing an answer _or_ an attachment should result in a complete // response. $this->assertTrue($essay->is_complete_response( array('answer' => '', 'attachments' => $attachments[1]))); $this->assertTrue($essay->is_complete_response( array('answer' => 'Answer text.', 'attachments' => $attachments[0]))); // Test the case in which we're in "no inline response" mode, // in which the response is not required (as it's not provided). $essay->responserequired = 0; $essay->responseformat = 'noinline'; $essay->attachmentsrequired = 1; $this->assertFalse($essay->is_complete_response( array())); $this->assertFalse($essay->is_complete_response( array('attachments' => $attachments[0]))); // Providing an attachment should result in a complete response. $this->assertTrue($essay->is_complete_response( array('attachments' => $attachments[1]))); // Ensure that responserequired is ignored when we're in inline response mode. $essay->responserequired = 1; $this->assertTrue($essay->is_complete_response( array('attachments' => $attachments[1]))); } /** * test_get_question_definition_for_external_rendering */ public function test_get_question_definition_for_external_rendering(): void { $this->resetAfterTest(); $essay = \test_question_maker::make_an_essay_question(); $essay->minwordlimit = 15; $essay->start_attempt(new question_attempt_step(), 1); $qa = \test_question_maker::get_a_qa($essay); $displayoptions = new question_display_options(); $options = $essay->get_question_definition_for_external_rendering($qa, $displayoptions); $this->assertNotEmpty($options); $this->assertEquals('editor', $options['responseformat']); $this->assertEquals(1, $options['responserequired']); $this->assertEquals(15, $options['responsefieldlines']); $this->assertEquals(0, $options['attachments']); $this->assertEquals(0, $options['attachmentsrequired']); $this->assertNull($options['maxbytes']); $this->assertNull($options['filetypeslist']); $this->assertEquals('', $options['responsetemplate']); $this->assertEquals(FORMAT_MOODLE, $options['responsetemplateformat']); $this->assertEquals($essay->minwordlimit, $options['minwordlimit']); $this->assertNull($options['maxwordlimit']); } /** * Test get_validation_error when users submit their input text. * * (The tests are done with a fixed 14-word response.) * * @dataProvider get_min_max_wordlimit_test_cases * @param int $responserequired whether response required (yes = 1, no = 0) * @param int $minwordlimit minimum word limit * @param int $maxwordlimit maximum word limit * @param string $expected error message | null */ public function test_get_validation_error(int $responserequired, int $minwordlimit, int $maxwordlimit, string $expected): void { $question = \test_question_maker::make_an_essay_question(); $response = ['answer' => 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen.']; $question->responserequired = $responserequired; $question->minwordlimit = $minwordlimit; $question->maxwordlimit = $maxwordlimit; $actual = $question->get_validation_error($response); $this->assertEquals($expected, $actual); } /** * Data provider for get_validation_error test. * * @return array the test cases. */ public static function get_min_max_wordlimit_test_cases(): array { return [ 'text input required, min/max word limit not set' => [1, 0, 0, ''], 'text input required, min/max word limit valid (within the boundaries)' => [1, 10, 25, ''], 'text input required, min word limit not reached' => [1, 15, 25, get_string('minwordlimitboundary', 'qtype_essay', ['count' => 14, 'limit' => 15])], 'text input required, max word limit is exceeded' => [1, 5, 12, get_string('maxwordlimitboundary', 'qtype_essay', ['count' => 14, 'limit' => 12])], 'text input not required, min/max word limit not set' => [0, 5, 12, ''], ]; } /** * Test get_word_count_message_for_review when users submit their input text. * * (The tests are done with a fixed 14-word response.) * * @dataProvider get_word_count_message_for_review_test_cases * @param int|null $minwordlimit minimum word limit * @param int|null $maxwordlimit maximum word limit * @param string $expected error message | null */ public function test_get_word_count_message_for_review(?int $minwordlimit, ?int $maxwordlimit, string $expected): void { $question = \test_question_maker::make_an_essay_question(); $question->minwordlimit = $minwordlimit; $question->maxwordlimit = $maxwordlimit; $response = ['answer' => 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen.']; $this->assertEquals($expected, $question->get_word_count_message_for_review($response)); } /** * Data provider for test_get_word_count_message_for_review. * * @return array the test cases. */ public static function get_word_count_message_for_review_test_cases(): array { return [ 'No limit' => [null, null, ''], 'min and max, answer within range' => [10, 25, get_string('wordcount', 'qtype_essay', 14)], 'min and max, answer too short' => [15, 25, get_string('wordcounttoofew', 'qtype_essay', ['count' => 14, 'limit' => 15])], 'min and max, answer too long' => [5, 12, get_string('wordcounttoomuch', 'qtype_essay', ['count' => 14, 'limit' => 12])], 'min only, answer within range' => [14, null, get_string('wordcount', 'qtype_essay', 14)], 'min only, answer too short' => [15, null, get_string('wordcounttoofew', 'qtype_essay', ['count' => 14, 'limit' => 15])], 'max only, answer within range' => [null, 14, get_string('wordcount', 'qtype_essay', 14)], 'max only, answer too short' => [null, 13, get_string('wordcounttoomuch', 'qtype_essay', ['count' => 14, 'limit' => 13])], ]; } /** * Create sample attachemnts and retun generated attachments. * @param int $numberofattachments * @return array */ private function create_user_and_sample_attachments($numberofattachments = 4) { // Create a new logged-in user, so we can test responses with attachments. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Create sample attachments to use in testing. $helper = \test_question_maker::get_test_helper('essay'); $attachments = []; for ($i = 0; $i < ($numberofattachments + 1); ++$i) { $attachments[$i] = $helper->make_attachments_saver($i); } return $attachments; } } PK\s(type/essay/tests/form/edit_form_test.phpnu[. namespace qtype_essay\form; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/essay/edit_essay_form.php'); /** * Unit tests for the essay edit form. * * @package qtype_essay * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class edit_form_test extends \advanced_testcase { /** * Helper method. * * @param string $classname the question form class to instantiate. * * @return array with two elements: * question_edit_form great a question form instance that can be tested. * stdClass the question category. */ protected function get_form($classname) { global $USER; $this->setAdminUser(); $this->resetAfterTest(); $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $bankcontext = \context_module::instance($qbank->cmid); $category = question_get_default_category($bankcontext->id, true); $fakequestion = new \stdClass(); $fakequestion->qtype = 'essay'; $fakequestion->contextid = $bankcontext->id; $fakequestion->createdby = $USER->id; $fakequestion->category = $category->id; $fakequestion->questiontext = 'please writer an assay about ...'; $fakequestion->responseformat = 'editorfilepicker'; $fakequestion->responserequired = 1; $fakequestion->responsefieldlines = 10; $fakequestion->attachments = -1; $fakequestion->attachmentsrequired = 3; $fakequestion->filetypeslist = ''; $form = new $classname( new \moodle_url('/'), $fakequestion, $category, new \core_question\local\bank\question_edit_contexts($bankcontext) ); return [$form, $category]; } /** * Test the form for correct validation of attachments options. * * @dataProvider user_preference_provider * @param int $allowed * @param int $required * @param array $expected */ public function test_attachments_validation(int $allowed, int $required, array $expected): void { list($form, $category) = $this->get_form('qtype_essay_edit_form'); $submitteddata = [ 'category' => $category->id, 'questiontext' => [ 'text' => 'please writer an assay about ...', 'format' => FORMAT_HTML, ], 'responseformat' => 'editorfilepicker', 'responserequired' => '1', 'attachments' => $allowed, 'attachmentsrequired' => $required, ]; $errors = $form->validation($submitteddata, []); $this->assertArrayNotHasKey('attachments', $errors); $this->assertEquals($expected, $errors); } /** * Return an array of all possible allowed and required attachments, * and the expected results from the form validation method. * * @return array, an array of all possible options. */ public static function user_preference_provider(): array { $valid = []; $invalid = ['attachmentsrequired' => get_string('mustrequirefewer', 'qtype_essay')]; return [ 'Attachments allowed=0, required=0, valid' => [0, 0, $valid], 'Attachments allowed=0, required=1, invalid, so required is set to 0 when saving' => [0, 1, $valid], 'Attachments allowed=0, required=2, invalid, so required is set to 0 when saving' => [0, 2, $valid], 'Attachments allowed=0, required=3, invalid, so required is set to 0 when saving' => [0, 3, $valid], 'Attachments allowed=1, required=0, valid' => [1, 0, $valid], 'Attachments allowed=1, required=1, valid' => [1, 1, $valid], 'Attachments allowed=1, required=2, invalid' => [1, 2, $invalid], 'Attachments allowed=2, required=3, invalid' => [2, 3, $invalid], 'Attachments allowed=3, required=4, invalid' => [3, 4, $invalid], 'Attachments allowed=-1, required=4, valid' => [-1, 4, $valid], ]; } } PK\NN*type/essay/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package qtype_essay * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_essay\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\user_preference_provider; use qtype_essay\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/essay/classes/privacy/provider.php'); /** * Privacy provider tests class. * * @package qtype_essay * @copyright 2021 The Open university * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('qtype_essay'); $actual = \qtype_essay\privacy\provider::get_metadata($collection); $this->assertEquals($collection, $actual); } public function test_export_user_preferences_no_pref(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data()); } /** * Test the export_user_preferences given different inputs * @dataProvider user_preference_provider * @param string $name The name of the user preference to get/set * @param string $value The value stored in the database * @param string $expected The expected transformed value */ public function test_export_user_preferences($name, $value, $expected): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); set_user_preference("qtype_essay_$name", $value, $user); provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('qtype_essay'); foreach ($preferences as $key => $pref) { $preference = get_user_preferences("qtype_essay_{$key}", null, $user->id); if ($preference === null) { continue; } $desc = get_string("privacy:preference:{$key}", 'qtype_essay'); $this->assertEquals($expected, $pref->value); $this->assertEquals($desc, $pref->description); } } /** * Create an array of valid user preferences for the multiple choice question type. * * @return array Array of valid user preferences. */ public static function user_preference_provider(): array { return [ 'default mark 2' => ['defaultmark', 2, 2], 'responseformat editror ' => ['responseformat', 'editor', get_string('formateditor', 'qtype_essay')], 'responseformat editor and filepicker ' => ['responseformat', 'editorfilepicker', get_string('formateditorfilepicker', 'qtype_essay')], 'responseformat plain ' => ['responseformat', 'plain', get_string('formatplain', 'qtype_essay')], 'responseformat monospaced ' => ['responseformat', 'monospaced', get_string('formatmonospaced', 'qtype_essay')], 'responseformat noinline ' => ['responseformat', 'noinline', get_string('formatnoinline', 'qtype_essay')], 'responserequired yes' => ['responserequired', 1, get_string('responseisrequired', 'qtype_essay')], 'responserequired no ' => ['responserequired', 0, get_string('responsenotrequired', 'qtype_essay')], 'responsefieldlines 10' => ['responsefieldlines', 10, '10 lines'], 'attachments none' => ['attachments', 0, get_string('no')], 'attachments 3' => ['attachments', 3, '3'], 'attachments unlimited' => ['attachments', -1, get_string('unlimited')], 'attachmentsrequired optional' => ['attachmentsrequired', 0, get_string('attachmentsoptional', 'qtype_essay')], 'attachmentsrequired 1' => ['attachmentsrequired', 1, '1'], 'maxbytes 50KB' => ['maxbytes', 51200, '50KB'] ]; } } PK\ӆ44'type/essay/tests/question_type_test.phpnu[. namespace qtype_essay; use qtype_essay; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/essay/questiontype.php'); /** * Unit tests for the essay question type class. * * @package qtype_essay * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_type_test extends \advanced_testcase { protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_essay(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } protected function get_test_question_data() { $q = new \stdClass(); $q->id = 1; return $q; } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'essay'); } public function test_can_analyse_responses(): void { $this->assertFalse($this->qtype->can_analyse_responses()); } public function test_get_random_guess_score(): void { $q = $this->get_test_question_data(); $this->assertEquals(0, $this->qtype->get_random_guess_score($q)); } public function test_get_possible_responses(): void { $q = $this->get_test_question_data(); $this->assertEquals(array(), $this->qtype->get_possible_responses($q)); } } PK\lGwzwz%type/essay/tests/walkthrough_test.phpnu[. namespace qtype_essay; use question_bank; use question_engine; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the essay question type. * * @package qtype_essay * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { protected function check_contains_textarea($name, $content = '', $height = 10) { $fieldname = $this->quba->get_field_prefix($this->slot) . $name; $this->assertTag(array('tag' => 'textarea', 'attributes' => array('cols' => '60', 'rows' => $height, 'name' => $fieldname)), $this->currentoutput); if ($content) { $this->assertMatchesRegularExpression('/' . preg_quote(s($content), '/') . '/', $this->currentoutput); } } /** * Helper method: Store a test file with a given name and contents in a * draft file area. * * @param int $usercontextid user context id. * @param int $draftitemid draft item id. * @param string $filename filename. * @param string $contents file contents. */ protected function save_file_to_draft_area($usercontextid, $draftitemid, $filename, $contents) { $fs = get_file_storage(); $filerecord = new \stdClass(); $filerecord->contextid = $usercontextid; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $draftitemid; $filerecord->filepath = '/'; $filerecord->filename = $filename; $fs->create_file_from_string($filerecord, $contents); } public function test_deferred_feedback_html_editor(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $q = \test_question_maker::make_question('essay', 'editor'); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $prefix = $this->quba->get_field_prefix($this->slot); $fieldname = $prefix . 'answer'; $response = '

    The cat sat on the mat. Then it ate a frog.

    '; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', ''); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save a response. $this->quba->process_all_actions(null, array( 'slots' => $this->slot, $fieldname => $response, $fieldname . 'format' => FORMAT_HTML, $prefix . ':sequencecheck' => '1', )); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->render(); $this->check_contains_textarea('answer', $response); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->render(); $this->assertMatchesRegularExpression('/' . preg_quote($response, '/') . '/', $this->currentoutput); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_general_feedback_expectation($q)); } public function test_deferred_feedback_plain_text(): void { // Create an essay question. $q = \test_question_maker::make_question('essay', 'plain'); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $prefix = $this->quba->get_field_prefix($this->slot); $fieldname = $prefix . 'answer'; $response = "x < 1\nx > 0\nFrog & Toad were friends."; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', ''); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save a response. $this->quba->process_all_actions(null, array( 'slots' => $this->slot, $fieldname => $response, $fieldname . 'format' => FORMAT_HTML, $prefix . ':sequencecheck' => '1', )); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->render(); $this->check_contains_textarea('answer', $response); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->render(); $this->assertMatchesRegularExpression('/' . preg_quote(s($response), '/') . '/', $this->currentoutput); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_general_feedback_expectation($q)); } public function test_responsetemplate(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $q = \test_question_maker::make_question('essay', 'responsetemplate'); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $prefix = $this->quba->get_field_prefix($this->slot); $fieldname = $prefix . 'answer'; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', 'Once upon a time'); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(1); // Save. $this->quba->process_all_actions(null, array( 'slots' => $this->slot, $fieldname => 'Once upon a time there was a little green frog.', $fieldname . 'format' => FORMAT_HTML, $prefix . ':sequencecheck' => '1', )); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->render(); $this->check_contains_textarea('answer', 'Once upon a time there was a little green frog.'); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_feedback_expectation()); $this->check_step_count(2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->render(); $this->assertMatchesRegularExpression( '/' . preg_quote(s('Once upon a time there was a little green frog.'), '/') . '/', $this->currentoutput); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_general_feedback_expectation($q)); } public function test_deferred_feedback_html_editor_with_files_attempt_on_last(): void { global $CFG, $USER, $PAGE; $this->resetAfterTest(true); $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); $usercontextid = \context_user::instance($USER->id)->id; $fs = get_file_storage(); // Create an essay question in the DB. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question = $generator->create_question('essay', 'editorfilepicker', array('category' => $cat->id)); // Start attempt at the question. $q = question_bank::load_question($question->id); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_step_count(1); // Process a response and check the expected result. // First we need to get the draft item ids. $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->save_file_to_draft_area($usercontextid, $editordraftid, 'smile.txt', ':-)'); $this->save_file_to_draft_area($usercontextid, $attachementsdraftid, 'greeting.txt', 'Hello world!'); $this->process_submission(array( 'answer' => 'Here is a picture: smile.', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->save_quba(); // Save the same response again, and verify no new step is created. $this->load_quba(); $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->process_submission(array( 'answer' => 'Here is a picture: smile.', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); // Now submit all and finish. $this->finish(); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_step_count(3); $this->save_quba(); // Now start a new attempt based on the old one. $this->load_quba(); $oldqa = $this->get_question_attempt(); $q = question_bank::load_question($question->id); $this->quba = question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($q, 1); $this->quba->start_question_based_on($this->slot, $oldqa); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(1); $this->save_quba(); // Now save the same response again, and ensure that a new step is not created. $this->load_quba(); $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->process_submission(array( 'answer' => 'Here is a picture: smile.', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(1); } public function test_deferred_feedback_html_editor_with_files_attempt_on_last_no_files_uploaded(): void { global $CFG, $USER, $PAGE; $this->resetAfterTest(true); $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); $usercontextid = \context_user::instance($USER->id)->id; $fs = get_file_storage(); // Create an essay question in the DB. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question = $generator->create_question('essay', 'editorfilepicker', array('category' => $cat->id)); // Start attempt at the question. $q = question_bank::load_question($question->id); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_step_count(1); // Process a response and check the expected result. // First we need to get the draft item ids. $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->process_submission(array( 'answer' => 'I refuse to draw you a picture, so there!', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->save_quba(); // Now submit all and finish. $this->finish(); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_step_count(3); $this->save_quba(); // Now start a new attempt based on the old one. $this->load_quba(); $oldqa = $this->get_question_attempt(); $q = question_bank::load_question($question->id); $this->quba = question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($q, 1); $this->quba->start_question_based_on($this->slot, $oldqa); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(1); $this->save_quba(); // Check the display. $this->load_quba(); $this->render(); $this->assertMatchesRegularExpression('/I refuse to draw you a picture, so there!/', $this->currentoutput); } public function test_deferred_feedback_plain_attempt_on_last(): void { global $CFG, $USER; $this->resetAfterTest(true); $this->setAdminUser(); $usercontextid = \context_user::instance($USER->id)->id; // Create an essay question in the DB. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question = $generator->create_question('essay', 'plain', array('category' => $cat->id)); // Start attempt at the question. $q = question_bank::load_question($question->id); $this->start_attempt_at_question($q, 'deferredfeedback', 1); $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_step_count(1); // Process a response and check the expected result. $this->process_submission(array( 'answer' => 'Once upon a time there was a frog called Freddy. He lived happily ever after.', 'answerformat' => FORMAT_PLAIN, )); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->save_quba(); // Now submit all and finish. $this->finish(); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_step_count(3); $this->save_quba(); // Now start a new attempt based on the old one. $this->load_quba(); $oldqa = $this->get_question_attempt(); $q = question_bank::load_question($question->id); $this->quba = question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($q, 1); $this->quba->start_question_based_on($this->slot, $oldqa); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(1); $this->save_quba(); // Check the display. $this->load_quba(); $this->render(); // Test taht no HTML comment has been added to the response. $this->assertMatchesRegularExpression( '/Once upon a time there was a frog called Freddy. He lived happily ever after.(?!<!--)/', $this->currentoutput); // Test for the hash of an empty file area. $this->assertStringNotContainsString('d41d8cd98f00b204e9800998ecf8427e', $this->currentoutput); } public function test_deferred_feedback_html_editor_with_files_attempt_wrong_filetypes(): void { global $CFG, $USER, $PAGE; $this->resetAfterTest(true); $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); $usercontextid = \context_user::instance($USER->id)->id; $fs = get_file_storage(); // Create an essay question in the DB. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question = $generator->create_question('essay', 'editorfilepicker', array('category' => $cat->id)); // Start attempt at the question. $q = question_bank::load_question($question->id); $q->filetypeslist = '.pdf,.docx'; $this->start_attempt_at_question($q, 'deferredfeedback', 1); $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_step_count(1); // Process a response and check the expected result. // First we need to get the draft item ids. $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->save_file_to_draft_area($usercontextid, $editordraftid, 'smile.txt', ':-)'); $this->save_file_to_draft_area($usercontextid, $attachementsdraftid, 'greeting.txt', 'Hello world!'); $this->process_submission(array( 'answer' => 'Here is a picture: smile.', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_step_count(2); $this->save_quba(); // Now submit all and finish. $this->finish(); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_step_count(3); $this->save_quba(); } public function test_deferred_feedback_html_editor_with_files_attempt_correct_filetypes(): void { global $CFG, $USER, $PAGE; $this->resetAfterTest(true); $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); $usercontextid = \context_user::instance($USER->id)->id; $fs = get_file_storage(); // Create an essay question in the DB. $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question = $generator->create_question('essay', 'editorfilepicker', array('category' => $cat->id)); // Start attempt at the question. $q = question_bank::load_question($question->id); $q->filetypeslist = '.txt,.docx'; $this->start_attempt_at_question($q, 'deferredfeedback', 1); $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_step_count(1); // Process a response and check the expected result. // First we need to get the draft item ids. $this->render(); if (!preg_match('/env=editor&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('Editor draft item id not found.'); } $editordraftid = $matches[1]; if (!preg_match('/env=filemanager&action=browse&.*?itemid=(\d+)&/', $this->currentoutput, $matches)) { throw new \coding_exception('File manager draft item id not found.'); } $attachementsdraftid = $matches[1]; $this->save_file_to_draft_area($usercontextid, $editordraftid, 'smile.txt', ':-)'); $this->save_file_to_draft_area($usercontextid, $attachementsdraftid, 'greeting.txt', 'Hello world!'); $this->process_submission(array( 'answer' => 'Here is a picture: smile.', 'answerformat' => FORMAT_HTML, 'answer:itemid' => $editordraftid, 'attachments' => $attachementsdraftid)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_step_count(2); $this->save_quba(); // Now submit all and finish. $this->finish(); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_step_count(3); $this->save_quba(); } public function test_deferred_feedback_word_limits(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. /** @var qtype_essay_question $q */ $q = \test_question_maker::make_question('essay', 'editor'); $q->minwordlimit = 3; $q->maxwordlimit = 7; $this->start_attempt_at_question($q, 'deferredfeedback', 1); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', ''); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save a response that is too short (and give the word-count code a tricky case). $response = '

    One

    • Two
    '; $this->process_submission(['answer' => $response, 'answerformat' => FORMAT_HTML]); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', $response); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->assertStringContainsString('This question requires a response of at least 3 words and you are ' . 'attempting to submit 2 words. Please expand your response and try again.', $this->currentoutput); // Save a response that is just long enough. $this->process_submission(['answer' => '

    One two three.

    ', 'answerformat' => FORMAT_HTML]); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', '

    One two three.

    '); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save a response that is as long as possible short. $this->process_submission(['answer' => '

    One two three four five six seven.

    ', 'answerformat' => FORMAT_HTML]); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', '

    One two three four five six seven.

    '); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_feedback_expectation()); // Save a response that is just too long. $this->process_submission(['answer' => '

    One two three four five six seven eight.

    ', 'answerformat' => FORMAT_HTML]); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->render(); $this->check_contains_textarea('answer', '

    One two three four five six seven eight.

    '); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_feedback_expectation()); $this->assertStringContainsString('The word limit for this question is 7 words and you are ' . 'attempting to submit 8 words. Please shorten your response and try again.', $this->currentoutput); // Now submit all and finish. $this->finish(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->render(); $this->check_current_output( $this->get_contains_question_text_expectation($q), $this->get_contains_general_feedback_expectation($q)); $this->assertStringContainsString('Word count: 8, more than the limit of 7 words.', $this->currentoutput); } } PK\ h  !type/essay/tests/restore_test.phpnu[. namespace qtype_essay; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); /** * Test restore logic. * * @package qtype_essay * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class restore_test extends \restore_date_testcase { /** * Test missing qtype_essay_options creation. * * Old backup files may contain essays with no qtype_essay_options record. * During restore, we add default options for any questions like that. * That is what is tested in this file. */ public function test_restore_create_missing_qtype_essay_options(): void { global $DB; // Create a course with one essay question in its question bank. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $qbank = $generator->create_module('qbank', ['course' => $course->id]); $context = \context_module::instance($qbank->cmid); $category = question_get_default_category($context->id, true); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $essay = $questiongenerator->create_question('essay', null, array('category' => $category->id)); // Remove the options record, which means that the backup will look like a backup made in an old Moodle. $DB->delete_records('qtype_essay_options', ['questionid' => $essay->id]); // Do backup and restore. $newcourseid = $this->backup_and_restore($course); $modinfo = get_fast_modinfo($newcourseid); $newqbanks = array_filter( $modinfo->get_instances_of('qbank'), static fn($qbank) => $qbank->get_name() === 'Question bank 1' ); $newqbank = reset($newqbanks); // Verify that the restored question has options. $newcategory = question_get_default_category(\context_module::instance($newqbank->id)->id, true); $newessay = $DB->get_record_sql('SELECT q.* 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 = ? AND q.qtype = ?', [$newcategory->id, 'essay']); $this->assertTrue($DB->record_exists('qtype_essay_options', ['questionid' => $newessay->id])); } } PK\ ?;?;type/essay/tests/helper.phpnu[. /** * Test helpers for the essay question type. * * @package qtype_essay * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test helper class for the essay question type. * * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_test_helper extends question_test_helper { public function get_test_questions() { return ['editor', 'editorfilepicker', 'plain', 'monospaced', 'responsetemplate', 'noinline', 'files']; } /** * Helper method to reduce duplication. * @return qtype_essay_question */ protected function initialise_essay_question() { question_bank::load_question_definition_classes('essay'); $q = new qtype_essay_question(); test_question_maker::initialise_a_question($q); $q->name = 'Essay question (HTML editor)'; $q->questiontext = 'Please write a story about a frog.'; $q->generalfeedback = 'I hope your story had a beginning, a middle and an end.'; $q->responseformat = 'editor'; $q->responserequired = 1; $q->responsefieldlines = 10; $q->minwordlimit = null; $q->maxwordlimit = null; $q->attachments = 0; $q->attachmentsrequired = 0; $q->maxbytes = 0; $q->filetypeslist = null; $q->graderinfo = ''; $q->graderinfoformat = FORMAT_HTML; $q->qtype = question_bank::get_qtype('essay'); return $q; } /** * Makes an essay question using the HTML editor as input. * @return qtype_essay_question */ public function make_essay_question_editor() { return $this->initialise_essay_question(); } /** * Make the data what would be received from the editing form for an essay * question using the HTML editor allowing embedded files as input, and up * to three attachments. * * @return stdClass the data that would be returned by $form->get_gata(); */ public function get_essay_question_form_data_editor() { $fromform = new stdClass(); $fromform->name = 'Essay question (HTML editor)'; $fromform->questiontext = array('text' => 'Please write a story about a frog.', 'format' => FORMAT_HTML); $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'I hope your story had a beginning, a middle and an end.', 'format' => FORMAT_HTML); $fromform->responseformat = 'editor'; $fromform->responserequired = 1; $fromform->responsefieldlines = 10; $fromform->attachments = 0; $fromform->attachmentsrequired = 0; $fromform->maxbytes = 0; $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Makes an essay question using the HTML editor allowing embedded files as * input, and up to three attachments. * @return qtype_essay_question */ public function make_essay_question_editorfilepicker() { $q = $this->initialise_essay_question(); $q->responseformat = 'editorfilepicker'; $q->attachments = 3; return $q; } /** * Makes an essay question using the HTML editor allowing embedded files as * input, and up to two attachments, two needed. * @return qtype_essay_question */ public function make_essay_question_editorfilepickertworequired() { $q = $this->initialise_essay_question(); $q->responseformat = 'editorfilepicker'; $q->attachments = 2; $q->attachmentsrequired = 2; return $q; } /** * Make the data what would be received from the editing form for an essay * question using the HTML editor allowing embedded files as input, and up * to three attachments. * * @return stdClass the data that would be returned by $form->get_gata(); */ public function get_essay_question_form_data_editorfilepicker() { $fromform = new stdClass(); $fromform->name = 'Essay question with filepicker and attachments'; $fromform->questiontext = array('text' => 'Please write a story about a frog.', 'format' => FORMAT_HTML); $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'I hope your story had a beginning, a middle and an end.', 'format' => FORMAT_HTML); $fromform->responseformat = 'editorfilepicker'; $fromform->responserequired = 1; $fromform->responsefieldlines = 10; $fromform->attachments = 3; $fromform->attachmentsrequired = 0; $fromform->maxbytes = 0; $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Makes an essay question using plain text input. * @return qtype_essay_question */ public function make_essay_question_plain() { $q = $this->initialise_essay_question(); $q->responseformat = 'plain'; return $q; } /** * Make the data what would be received from the editing form for an essay * question using the HTML editor allowing embedded files as input, and up * to three attachments. * * @return stdClass the data that would be returned by $form->get_gata(); */ public function get_essay_question_form_data_plain() { $fromform = new stdClass(); $fromform->name = 'Essay question with filepicker and attachments'; $fromform->questiontext = array('text' => 'Please write a story about a frog.', 'format' => FORMAT_HTML); $fromform->defaultmark = 1.0; $fromform->generalfeedback = array('text' => 'I hope your story had a beginning, a middle and an end.', 'format' => FORMAT_HTML); $fromform->responseformat = 'plain'; $fromform->responserequired = 1; $fromform->responsefieldlines = 10; $fromform->attachments = 0; $fromform->attachmentsrequired = 0; $fromform->maxbytes = 0; $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } /** * Makes an essay question using monospaced input. * @return qtype_essay_question */ public function make_essay_question_monospaced() { $q = $this->initialise_essay_question(); $q->responseformat = 'monospaced'; return $q; } public function make_essay_question_responsetemplate() { $q = $this->initialise_essay_question(); $q->responsetemplate = 'Once upon a time'; $q->responsetemplateformat = FORMAT_HTML; return $q; } /** * Makes an essay question without an online text editor. * @return qtype_essay_question */ public function make_essay_question_noinline() { $q = $this->initialise_essay_question(); $q->responseformat = 'noinline'; $q->attachments = 3; $q->attachmentsrequired = 1; $q->filetypeslist = ''; $q->maxbytes = 0; return $q; } /** * Creates an empty draft area for attachments. * @return int The draft area's itemid. */ protected function make_attachment_draft_area() { $draftid = 0; $contextid = 0; $component = 'question'; $filearea = 'response_attachments'; // Create an empty file area. file_prepare_draft_area($draftid, $contextid, $component, $filearea, null); return $draftid; } /** * Creates an attachment in the provided attachment draft area. * @param int $draftid The itemid for the draft area in which the file should be created. * @param string $name The filename for the file to be created. * @param string $contents The contents of the file to be created. */ protected function make_attachment($draftid, $name, $contents) { global $USER; $fs = get_file_storage(); $usercontext = context_user::instance($USER->id); // Create the file in the provided draft area. $fileinfo = array( 'contextid' => $usercontext->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $draftid, 'filepath' => '/', 'filename' => $name, ); $fs->create_file_from_string($fileinfo, $contents); } /** * Generates a draft file area that contains the provided number of attachments. You should ensure * that a user is logged in with setUser before you run this function. * * @param int $attachments The number of attachments to generate. * @return int The itemid of the generated draft file area. */ public function make_attachments($attachments) { $draftid = $this->make_attachment_draft_area(); // Create the relevant amount of dummy attachments in the given draft area. for ($i = 0; $i < $attachments; ++$i) { $this->make_attachment($draftid, $i, $i); } return $draftid; } /** * Generates a question_file_saver that contains the provided number of attachments. You should ensure * that a user is logged in with setUser before you run this function. * * @param int $:attachments The number of attachments to generate. * @return question_file_saver a question_file_saver that contains the given amount of dummy files, for use in testing. */ public function make_attachments_saver($attachments) { return new question_file_saver($this->make_attachments($attachments), 'question', 'response_attachments'); } /** * Create a question with images embedded in the questiontext, generalfeedback and graderinfo. * * @return stdClass */ public function get_essay_question_form_data_files() { global $CFG, $USER; $fromform = new stdClass(); $usercontext = context_user::instance($USER->id); $questiontextdraftid = 1; file_prepare_draft_area($questiontextdraftid, $usercontext->id, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = $usercontext->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $questiontextdraftid; $filerecord->filepath = '/'; $filerecord->filename = '1.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/essay/tests/fixtures/1.png'); $feedbackdraftid = 2; file_prepare_draft_area($feedbackdraftid, $usercontext->id, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = $usercontext->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $feedbackdraftid; $filerecord->filepath = '/'; $filerecord->filename = '2.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/essay/tests/fixtures/2.png'); $graderinfodraftid = 3; file_prepare_draft_area($graderinfodraftid, $usercontext->id, null, null, null); $fs = get_file_storage(); $filerecord = new stdClass(); $filerecord->contextid = $usercontext->id; $filerecord->component = 'user'; $filerecord->filearea = 'draft'; $filerecord->itemid = $graderinfodraftid; $filerecord->filepath = '/'; $filerecord->filename = '3.png'; $fs->create_file_from_pathname($filerecord, $CFG->dirroot . '/question/type/essay/tests/fixtures/3.png'); $fromform->name = 'Essay question (HTML editor)'; $questionfileurl = $CFG->wwwroot . '/draftfile.php/' . $usercontext->id . '/user/draft/' . $questiontextdraftid . '/1.png'; $fromform->questiontext = [ 'text' => 'Please write a story about a frog. ', 'format' => FORMAT_HTML, 'itemid' => $questiontextdraftid, ]; $fromform->defaultmark = 1.0; $feedbackfileurl = $CFG->wwwroot . '/draftfile.php/' . $usercontext->id . '/user/draft/' . $feedbackdraftid . '/2.png'; $fromform->generalfeedback = [ 'text' => 'I hope your story had a beginning, a middle and an end. ', 'format' => FORMAT_HTML, 'itemid' => $feedbackdraftid, ]; $fromform->responseformat = 'editor'; $fromform->responserequired = 1; $fromform->responsefieldlines = 10; $fromform->attachments = 0; $fromform->attachmentsrequired = 0; $fromform->maxbytes = 0; $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $graderinfourl = $CFG->wwwroot . '/draftfile.php/' . $usercontext->id . '/user/draft/' . $graderinfodraftid . '/3.png'; $fromform->graderinfo = [ 'text' => '', 'format' => FORMAT_HTML, 'itemid' => $graderinfodraftid, ]; $fromform->responsetemplate = ['text' => '', 'format' => FORMAT_HTML]; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } } PK\5z5`5`2type/essay/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_essay; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of essay question attempts. * * @package qtype_essay * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_essay_deferredfeedback_history98220(): void { $quiz = (object) array( 'id' => '4140', 'course' => '5012', 'name' => 'M887 Online Examination: 19th April 2010, 10am - 1pm', 'intro' => '

    M887 Examination Paper 19th April 2010

    Postgraduate Computing
    Web Systems Integration

    Begin by pressing Start Attempt (below)

    ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '1271665800', 'timeclose' => '1271682000', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '1', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71727591', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '100', 'grade' => '100', 'timecreated' => '0', 'timemodified' => '1272274569', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '388325', 'uniqueid' => '388326', 'quiz' => '4140', 'userid' => '118065', 'attempt' => '1', 'sumgrades' => '51', 'timestart' => '1271667586', 'timefinish' => '1271678351', 'timemodified' => '1273069013', 'layout' => '90042,0,90043,0,90045,0,90052,0,90053,0,90054,0,90055,0,90056,0,90057,0,90058,0,90059,0,90046,0,90044,0,90047,0,90048,0,90049,0', 'preview' => '0', ); $question = (object) array( 'id' => '90056', 'category' => '8619', 'parent' => '0', 'name' => 'Question 6', 'questiontext' => '

    Give two examples of facilities within XML schemas that cannot be found in Document Type Definitions (DTDs).
    (2 marks)

    ', 'questiontextformat' => '1', 'defaultmark' => '2', 'penalty' => '0', 'qtype' => 'essay', 'length' => '1', 'stamp' => 'learn.open.ac.uk+100205101651+5eB30s', 'version' => 'learn.open.ac.uk+100209161823+oZCX9n', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '1265365011', 'timemodified' => '1265732303', 'createdby' => '219095', 'modifiedby' => '25483', 'unlimited' => '0', 'maxmark' => '2', 'options' => (object) array( 'answers' => array( 303772 => (object) array( 'question' => '90056', 'answer' => '', 'fraction' => '0', 'feedback' => '', 'id' => 303772, ), ), ), 'hints' => false, ); $qsession = (object) array( 'id' => '3962570', 'attemptid' => '388326', 'questionid' => '90056', 'newest' => '10517712', 'newgraded' => '10517712', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 10094242 => (object) array( 'attempt' => '388326', 'question' => '90056', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1271667586', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 10094242, ), 10096161 => (object) array( 'attempt' => '388326', 'question' => '90056', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => '

    Variable typeing

    Namespaces

    ', 'timestamp' => '1271670445', 'event' => '2', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 10096161, ), 10097144 => (object) array( 'attempt' => '388326', 'question' => '90056', 'originalquestion' => '0', 'seq_number' => '2', 'answer' => '

    Variable can be typed

    xml Schemas fully support Namespaces

    ', 'timestamp' => '1271671440', 'event' => '2', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 10097144, ), 10103710 => (object) array( 'attempt' => '388326', 'question' => '90056', 'originalquestion' => '0', 'seq_number' => '3', 'answer' => '

    Variable can be typed

    xml Schemas fully support Namespaces

    ', 'timestamp' => '1271671440', 'event' => '8', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 10103710, ), 10517712 => (object) array( 'attempt' => '388326', 'question' => '90056', 'originalquestion' => '0', 'seq_number' => '4', 'answer' => '

    Variable can be typed

    xml Schemas fully support Namespaces

    ', 'timestamp' => '1273068477', 'event' => '9', 'grade' => '2', 'raw_grade' => '2', 'penalty' => '0', 'id' => 10517712, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'manualgraded', 'questionid' => 90056, 'variant' => 1, 'maxmark' => 2, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "Give two examples of facilities within XML schemas that cannot be found in Document Type Definitions (DTDs).\n_(2 marks)_", 'rightanswer' => '', 'responsesummary' => "Variable can be typed \n\nxml Schemas fully support Namespaces", 'timemodified' => 1273068477, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1271667586, 'userid' => 118065, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1271670445, 'userid' => 118065, 'data' => array('answer' => '

    Variable typeing

    Namespaces

    ', 'answerformat' => FORMAT_HTML), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1271671440, 'userid' => 118065, 'data' => array('answer' => '

    Variable can be typed

    xml Schemas fully support Namespaces

    ', 'answerformat' => FORMAT_HTML), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'needsgrading', 'fraction' => null, 'timecreated' => 1271671440, 'userid' => 118065, 'data' => array('answer' => '

    Variable can be typed

    xml Schemas fully support Namespaces

    ', '-finish' => 1, 'answerformat' => FORMAT_HTML), ), 4 => (object) array( 'sequencenumber' => 4, 'state' => 'mangrright', 'fraction' => 1, 'timecreated' => 1273068477, 'userid' => null, 'data' => array('-comment' => '', '-mark' => 2, '-maxmark' => 2, 'answerformat' => FORMAT_HTML), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_essay_deferredfeedback_history820(): void { $quiz = (object) array( 'id' => '142', 'course' => '187', 'name' => 'Questionnaire', 'intro' => '

    B680 is pioneering the use of the eAssessment module in the OU VLE (Virtual Learning Environment). We believe that the module is fit for purpose but we need users\' (students and ALs) experience to confirm this. Your answers to this short questionnaire therefore are of wide importance to the OU VLE Development Programme. If you could complete this short questionnaire after attempting Practice CTMA04 it would be greatly appreciated.

    The questionnaire has 15 questions and we would like you to answer as many of these as possible. When you have completed your answers you will see a End test button, similar to the one in Practice CTMA 04, which you will need to click. This will move you to a Summary page. Please click the \'Submit all and finish\' button when you are happy to submit your final answers. Please complete the questionnaire only once. At a later stage the B680 Course Team will analyse the students\' answers to the questions.

    ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71727591', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '0', 'grade' => '0', 'timecreated' => '0', 'timemodified' => '1178202609', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '4246', 'uniqueid' => '4246', 'quiz' => '142', 'userid' => '96864', 'attempt' => '1', 'sumgrades' => '0', 'timestart' => '1179134211', 'timefinish' => '1179134998', 'timemodified' => '1179134869', 'layout' => '3664,3716,0,3663,3717,0,3718,3719,0,3720,0,3733,3727,0,3728,3730,0,3731,3732,0,3726,3729,0', 'preview' => '0', ); $question = (object) array( 'id' => '3729', 'category' => '163', 'parent' => '0', 'name' => 'Question 98', 'questiontext' => 'If you answered ‘No’ to the previous question please expand on your problem here.

    ', 'questiontextformat' => '1', 'defaultmark' => '0', 'penalty' => '0', 'qtype' => 'essay', 'length' => '1', 'stamp' => 'learn.open.ac.uk+070312094434+k2HaUF', 'version' => 'learn.open.ac.uk+070501173219+spx2IM', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '0', 'options' => (object) array( 'answers' => array( 11264 => (object) array( 'question' => '3729', 'answer' => '', 'fraction' => '0', 'feedback' => '', 'id' => 11264, ), ), ), 'hints' => false, ); $qsession = (object) array( 'id' => '47133', 'attemptid' => '4246', 'questionid' => '3729', 'newest' => '107502', 'newgraded' => '107407', 'sumpenalty' => '0', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '1', ); $qstates = array( 107407 => (object) array( 'attempt' => '4246', 'question' => '3729', 'originalquestion' => '0', 'seq_number' => '0', 'answer' => '', 'timestamp' => '1179134211', 'event' => '0', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 107407, ), 107484 => (object) array( 'attempt' => '4246', 'question' => '3729', 'originalquestion' => '0', 'seq_number' => '1', 'answer' => 'it would be better to point our a \'red colour\' on the number which indicates the questions that we have done wrong. similar to previously, from question 1 to 10, green colour shows the right answer and red colour shows the wrong answer, so that we do not need to click on each answer to find out if it is right or wrong.', 'timestamp' => '1179134869', 'event' => '2', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 107484, ), 107502 => (object) array( 'attempt' => '4246', 'question' => '3729', 'originalquestion' => '0', 'seq_number' => '2', 'answer' => 'it would be better to point our a \'red colour\' on the number which indicates the questions that we have done wrong. similar to previously, from question 1 to 10, green colour shows the right answer and red colour shows the wrong answer, so that we do not need to click on each answer to find out if it is right or wrong.', 'timestamp' => '1179134869', 'event' => '8', 'grade' => '0', 'raw_grade' => '0', 'penalty' => '0', 'id' => 107502, ), ); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'manualgraded', 'questionid' => 3729, 'variant' => 1, 'maxmark' => 0, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "If you answered ‘No’ to the previous question please expand on your problem here.", 'rightanswer' => '', 'responsesummary' => 'it would be better to point our a \'red colour\' on the number which indicates the questions that we have done wrong. similar to previously, from question 1 to 10, green colour shows the right answer and red colour shows the wrong answer, so that we do not need to click on each answer to find out if it is right or wrong.', 'timemodified' => 1179134869, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1179134211, 'userid' => 96864, 'data' => array(), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'complete', 'fraction' => null, 'timecreated' => 1179134869, 'userid' => 96864, 'data' => array('answer' => 'it would be better to point our a \'red colour\' on the number which indicates the questions that we have done wrong. similar to previously, from question 1 to 10, green colour shows the right answer and red colour shows the wrong answer, so that we do not need to click on each answer to find out if it is right or wrong.', 'answerformat' => FORMAT_HTML), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'needsgrading', 'fraction' => null, 'timecreated' => 1179134869, 'userid' => 96864, 'data' => array('-finish' => 1, 'answer' => 'it would be better to point our a \'red colour\' on the number which indicates the questions that we have done wrong. similar to previously, from question 1 to 10, green colour shows the right answer and red colour shows the wrong answer, so that we do not need to click on each answer to find out if it is right or wrong.', 'answerformat' => FORMAT_HTML), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_essay_deferredfeedback_missing(): void { $quiz = (object) array( 'id' => '142', 'course' => '187', 'name' => 'Questionnaire', 'intro' => '

    B680 is pioneering the use of the eAssessment module in the OU VLE (Virtual Learning Environment). We believe that the module is fit for purpose but we need users\' (students and ALs) experience to confirm this. Your answers to this short questionnaire therefore are of wide importance to the OU VLE Development Programme. If you could complete this short questionnaire after attempting Practice CTMA04 it would be greatly appreciated.

    The questionnaire has 15 questions and we would like you to answer as many of these as possible. When you have completed your answers you will see a End test button, similar to the one in Practice CTMA 04, which you will need to click. This will move you to a Summary page. Please click the \'Submit all and finish\' button when you are happy to submit your final answers. Please complete the questionnaire only once. At a later stage the B680 Course Team will analyse the students\' answers to the questions.

    ', 'introformat' => FORMAT_HTML, 'questiondecimalpoints' => '-1', 'showuserpicture' => '1', 'showblocks' => '1', 'timeopen' => '0', 'timeclose' => '0', 'preferredbehaviour' => 'deferredfeedback', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'review' => '71727591', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '0', 'sumgrades' => '0', 'grade' => '0', 'timecreated' => '0', 'timemodified' => '1178202609', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'timelimit' => '0', ); $attempt = (object) array( 'id' => '4246', 'uniqueid' => '4246', 'quiz' => '142', 'userid' => '96864', 'attempt' => '1', 'sumgrades' => '0', 'timestart' => '1179134211', 'timefinish' => '1179134998', 'timemodified' => '1179134869', 'layout' => '3664,3716,0,3663,3717,0,3718,3719,0,3720,0,3733,3727,0,3728,3730,0,3731,3732,0,3726,3729,0', 'preview' => '0', ); $question = (object) array( 'id' => '3729', 'category' => '163', 'parent' => '0', 'name' => 'Question 98', 'questiontext' => 'If you answered ‘No’ to the previous question please expand on your problem here.

    ', 'questiontextformat' => '1', 'defaultmark' => '0', 'penalty' => '0', 'qtype' => 'essay', 'length' => '1', 'stamp' => 'learn.open.ac.uk+070312094434+k2HaUF', 'version' => 'learn.open.ac.uk+070501173219+spx2IM', 'hidden' => '0', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'timecreated' => '0', 'timemodified' => '0', 'createdby' => null, 'modifiedby' => null, 'unlimited' => null, 'maxmark' => '0', 'options' => (object) array( 'answers' => array( 11264 => (object) array( 'question' => '3729', 'answer' => '', 'fraction' => '0', 'feedback' => '', 'id' => 11264, ), ), ), 'hints' => false, ); $qa = $this->updater->supply_missing_question_attempt( $quiz, $attempt, $question); $expectedqa = (object) array( 'behaviour' => 'manualgraded', 'questionid' => 3729, 'variant' => 1, 'maxmark' => 0, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => "If you answered ‘No’ to the previous question please expand on your problem here.", 'rightanswer' => '', 'responsesummary' => '', 'timemodified' => 1179134211, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1179134211, 'userid' => 96864, 'data' => array(), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\"j 4type/essay/tests/behat/file_type_restriction.featurenu[@qtype @qtype_essay Feature: In a essay question, limit submittable file types In order to constrain student submissions for marking As a teacher I need to limit the submittable file types Background: Given the following "users" exist: | username | | teacher | | student | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | defaultmark | | Test questions | essay | TF1 | First question | 20 | And the following "activities" exist: | activity | name | intro | course | idnumber | grade | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 20 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And I am on the "Quiz 1" "mod_quiz > edit" page logged in as teacher And I click on "Edit question TF1" "link" And I set the field "Allow attachments" to "1" And I set the field "Response format" to "No online text" And I set the field "Require attachments" to "1" And I set the field "filetypeslist[filetypes]" to ".txt" And I press "Save changes" And I am on the "Quiz 1" "mod_quiz > edit" page And I set the field "version" to "Always latest" Then I log out @javascript Scenario: Preview an Essay question and submit a response with a correct filetype. Given the following "user private file" exists: | user | student | | filepath | lib/tests/fixtures/empty.txt | When I am on the "Quiz 1" "quiz activity" page logged in as "student" And I press "Attempt quiz" And I should see "First question" And I should see "You can drag and drop files here to add them." And I click on "Add..." "button" And I click on "Private files" "link" in the ".fp-repo-area" "css_element" And I click on "empty.txt" "link" And I click on "Select this file" "button" # Wait for the page to "settle". And I wait until the page is ready Then I should not see "These file types are not allowed here:" @javascript Scenario: Preview an Essay question and try to submit a response with an incorrect filetype. Given the following "user private file" exists: | user | student | | filepath | lib/tests/fixtures/upload_users.csv | When I am on the "Quiz 1" "quiz activity" page logged in as "student" And I press "Attempt quiz" And I should see "First question" And I should see "You can drag and drop files here to add them." And I click on "Add..." "button" And I click on "Private files" "link" in the ".fp-repo-area" "css_element" Then I should see "No files available" PK\ @y.type/essay/tests/behat/non_form_fields.featurenu[@editor @qtype @qtype_essay @javascript Feature: Set editor values when the editor is not in a form As an automated tester In order to use a non-form editor I need to set values Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | essay | essay | editorfilepicker | Scenario: Preview an Essay question that uses the HTML editor with embedded files. When I am on the "essay" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the following fields to these values: | Answer text Question 1 |

    The cat sat on the mat. Then it ate a frog.

    | And I press "Submit and finish" PK\l  "type/essay/tests/behat/add.featurenu[@qtype @qtype_essay Feature: Test creating an Essay question As a teacher In order to test my students I need to be able to create an Essay question Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | Scenario: Create an Essay question with Response format set to 'HTML editor' When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Essay" question filling the form with: | Question name | essay-001 | | Question text | Write an essay with 500 words. | | General feedback | This is general feedback | | Response format | HTML editor | Then I should see "essay-001" Scenario: Create an Essay question with Response format set to 'HTML editor with the file picker' When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Essay" question filling the form with: | Question name | essay-002 | | Question text | Write an essay with 500 words. | | General feedback | This is general feedback | | id_responseformat | editorfilepicker | Then I should see "essay-002" @javascript Scenario: Create an Essay question for testing some default options When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I add a "Essay" question filling the form with: | Question name | essay-003 | | Question text | Write an essay with 500 words. | | General feedback | This is general feedback | | id_responseformat | editorfilepicker | | id_responserequired | 0 | | id_responsefieldlines | 15 | | id_attachments | 2 | | id_attachmentsrequired | 2 | | id_maxbytes | 10240 | Then I should see "essay-003" # Checking that the next new question form displays user preferences settings. And I press "Create a new question ..." And I set the field "item_qtype_essay" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | id_responseformat | editorfilepicker | | id_responserequired | 0 | | id_responsefieldlines | 15 | | id_attachments | 2 | | id_attachmentsrequired | 2 | | id_maxbytes | 10240 | PK\ #type/essay/tests/behat/edit.featurenu[@qtype @qtype_essay Feature: Test editing an Essay question As a teacher In order to be able to update my Essay question I need to edit them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | essay | essay-001 | editor | | Test questions | essay | essay-002 | editorfilepicker | | Test questions | essay | essay-003 | plain | Scenario: Edit an Essay question When I am on the "Course 1" "core_question > course question bank" page logged in as teacher And I choose "Edit question" action for "essay-001" in the question bank And I set the following fields to these values: | Question name | | And I press "id_submitbutton" And I should see "You must supply a value here." And I set the following fields to these values: | Question name | Edited essay-001 name | | Response format | No online text | And I press "id_submitbutton" And I should see "When \"No online text\" is selected, or responses are optional, you must allow at least one attachment." And I set the following fields to these values: | Response format | Plain text | And I press "id_submitbutton" Then I should see "Edited essay-001 name" PK\9Z,,2type/essay/tests/behat/edit_min_max_fields.featurenu[@qtype @qtype_essay Feature: In an essay question, let the question author choose the min/max number of words for input text In order to constrain student submissions for marking As a teacher I need to choose the appropriate minimum and/or maximum number of words for input text Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | minwordlimit | maxwordlimit | | Test questions | essay | essay-min-max | editor | 0 | 0 | Scenario: Minimum/Maximum word limit are enabled but not set. When I am on the "essay-min-max" "core_question > edit" page logged in as teacher And I set the field "minwordenabled" to "1" And I click on "Save changes" "button" Then I should see "Minimum word limit is enabled but is not set" Scenario: Minimum/Maximum word limit cannot be set to a negative number. When I am on the "essay-min-max" "core_question > edit" page logged in as teacher And I set the field "minwordenabled" to "1" And I set the field "id_minwordlimit" to "-10" And I click on "Save changes" "button" Then I should see "Minimum word limit cannot be a negative number" Scenario: Maximum word limit cannot be greater than minimum word limit. When I am on the "essay-min-max" "core_question > edit" page logged in as teacher And I set the field "minwordenabled" to "1" And I set the field "id_minwordlimit" to "500" And I set the field "maxwordenabled" to "1" And I set the field "id_maxwordlimit" to "450" And I click on "Save changes" "button" Then I should see "Maximum word limit must be greater than minimum word limit" @javascript Scenario: Modify the question to see 'Minimum word limit' and 'Maximum word limit' are hidden when 'Require text' field is set to 'Text input is optional' When I am on the "essay-min-max" "core_question > edit" page logged in as teacher And I should see "Minimum word limit" And I should see "Maximum word limit" And I set the field "Require text" to "Text input is optional" Then I should not see "Minimum word limit" And I should not see "Minimum word limit" @javascript Scenario: Minimum/Maximum word limit can be unset after being set. When I am on the "essay-min-max" "core_question > edit" page logged in as teacher And I set the following fields to these values: | minwordenabled | 1 | | id_minwordlimit | 100 | | maxwordenabled | 1 | | id_maxwordlimit | 200 | And I click on "Save changes and continue editing" "button" Then the following fields match these values: | minwordenabled | 1 | | id_minwordlimit | 100 | | maxwordenabled | 1 | | id_maxwordlimit | 200 | And I set the following fields to these values: | minwordenabled | 0 | | maxwordenabled | 0 | And I click on "Save changes and continue editing" "button" And the following fields match these values: | minwordenabled | 0 | | id_minwordlimit | | | maxwordenabled | 0 | | id_maxwordlimit | | PK\#,type/essay/tests/behat/max_file_size.featurenu[@qtype @qtype_essay Feature: In an essay question, let the question author choose the maxbytes for attachments In order to constrain student submissions for marking As a teacher I need to choose the appropriate maxbytes for attachments Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | maxbytes | | Course 1 | C1 | 0 | 1048576 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | attachments | maxbytes | | Test questions | essay | essay-1-512KB | editor | 1 | 524288 | | Test questions | essay | essay-1-max | editor | 1 | 0 | @javascript @_switch_window Scenario: Preview an Essay question and see the allowed maximum file sizes and number of attachments. When I am on the "essay-1-512KB" "core_question > preview" page logged in as teacher Then I should see "Please write a story about a frog." And I should see "Maximum file size: 512 KB, maximum number of files: 1" @javascript @_switch_window Scenario: Preview an Essay question with Course upload limit and see the allowed maximum file size. When I am on the "essay-1-max" "core_question > preview" page logged in as teacher Then I should see "Please write a story about a frog." And I should see "Maximum file size: 1 MB, maximum number of files: 1" PK\O%type/essay/tests/behat/export.featurenu[@qtype @qtype_essay Feature: Test exporting Essay questions As a teacher In order to be able to reuse my Essay questions I need to export them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | essay | essay-001 | editor | | Test questions | essay | essay-002 | editorfilepicker | | Test questions | essay | essay-003 | plain | Scenario: Export 3 Essay questions When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" Then following "click here" should download a file that: | Has mimetype | text/xml | | Contains text in xml element | essay-001 | | Contains text in xml element | essay-002 | | Contains text in xml element | essay-003 | # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout # step we avoid behat doing the reset until we are off that page. And I log out PK\Hϯ%type/essay/tests/behat/import.featurenu[@qtype @qtype_essay Feature: Test importing Essay questions As a teacher In order to reuse Essay questions I need to import them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | @javascript @_file_upload Scenario: import Essay question. When I am on the "Course 1" "core_question > course question import" page logged in as teacher And I set the field "id_format_xml" to "1" And I upload "question/type/essay/tests/fixtures/testquestion.moodle.xml" file to "Import" filemanager And I press "id_submitbutton" Then I should see "Parsing questions from import file." And I should see "Importing 1 questions from file" And I should see "Write an essay with 500 words." And I press "Continue" And I should see "essay-001" And I choose "Edit question" action for "essay-001" in the question bank And the field "id_maxwordlimit" matches value "20" PK\,i991type/essay/tests/behat/backup_and_restore.featurenu[@qtype @qtype_essay Feature: Test duplicating a quiz containing an Essay question As a teacher In order re-use my courses containing Essay questions I need to be able to backup and restore them Background: And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | essay | essay-001 | editor | | Test questions | essay | essay-002 | editorfilepicker | | Test questions | essay | essay-003 | plain | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | essay-001 | 1 | | essay-002 | 1 | | essay-003 | 1 | And the following config values are set as admin: | enableasyncbackup | 0 | @javascript Scenario: Backup and restore a course containing 3 Essay questions When I am on the "Course 1" course page logged in as admin And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | And I restore "test_backup.mbz" backup into a new course using this options: | Schema | Course name | Course 2 | | Schema | Course short name | C2 | And I am on the "Course 2" "core_question > course question bank" page Then I should see "essay-001" And I should see "essay-002" And I should see "essay-003" And I choose "Edit question" action for "essay-001" in the question bank Then the following fields match these values: | Question name | essay-001 | | Question text | Please write a story about a frog. | | General feedback | I hope your story had a beginning, a middle and an end. | | Response format | HTML editor | | Require text | Require the student to enter text | And I press "Cancel" And I choose "Edit question" action for "essay-002" in the question bank Then the following fields match these values: | Question name | essay-002 | | Question text | Please write a story about a frog. | | General feedback | I hope your story had a beginning, a middle and an end. | | Response format | HTML editor with file picker | | Require text | Require the student to enter text | And I press "Cancel" And I choose "Edit question" action for "essay-003" in the question bank Then the following fields match these values: | Question name | essay-003 | | Question text | Please write a story about a frog. | | General feedback | I hope your story had a beginning, a middle and an end. | | Response format | Plain text | | Require text | Require the student to enter text | PK\<&type/essay/tests/behat/preview.featurenu[@qtype @qtype_essay Feature: Preview Essay questions As a teacher In order to check my Essay questions will work for students I need to preview them Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | essay | essay-001 | editor | | Test questions | essay | essay-002 | editorfilepicker | | Test questions | essay | essay-003 | plain | @javascript @_switch_window Scenario: Preview an Essay question that uses the HTML editor. When I am on the "essay-001" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I should see "Please write a story about a frog." @javascript @_switch_window Scenario: Preview an Essay question that uses the HTML editor with embedded files. When I am on the "essay-002" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I should see "Please write a story about a frog." And I should see "You can drag and drop files here to add them." @javascript @_switch_window Scenario: Preview an Essay question that uses a plain text area. When I am on the "essay-003" "core_question > preview" page logged in as teacher And I expand all fieldsets And I set the field "How questions behave" to "Immediate feedback" And I press "Save preview options and start again" And I should see "Please write a story about a frog." PK\Vw!w!type/essay/questiontype.phpnu[. /** * Question type class for the essay question type. * * @package qtype * @subpackage essay * @copyright 2005 Mark Nielsen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/questionlib.php'); /** * The essay question type. * * @copyright 2005 Mark Nielsen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay extends question_type { public function is_manual_graded() { return true; } public function response_file_areas() { return array('attachments', 'answer'); } public function get_question_options($question) { global $DB; $question->options = $DB->get_record('qtype_essay_options', array('questionid' => $question->id), '*', MUST_EXIST); parent::get_question_options($question); } public function save_defaults_for_new_questions(stdClass $fromform): void { parent::save_defaults_for_new_questions($fromform); $this->set_default_value('responseformat', $fromform->responseformat); $this->set_default_value('responserequired', $fromform->responserequired); $this->set_default_value('responsefieldlines', $fromform->responsefieldlines); $this->set_default_value('attachments', $fromform->attachments); $this->set_default_value('attachmentsrequired', $fromform->attachmentsrequired); $this->set_default_value('maxbytes', $fromform->maxbytes); } public function save_question_options($formdata) { global $DB; $context = $formdata->context; $options = $DB->get_record('qtype_essay_options', array('questionid' => $formdata->id)); if (!$options) { $options = new stdClass(); $options->questionid = $formdata->id; $options->id = $DB->insert_record('qtype_essay_options', $options); } $options->responseformat = $formdata->responseformat; $options->responserequired = $formdata->responserequired; $options->responsefieldlines = $formdata->responsefieldlines; $options->minwordlimit = isset($formdata->minwordenabled) ? $formdata->minwordlimit : null; $options->maxwordlimit = isset($formdata->maxwordenabled) ? $formdata->maxwordlimit : null; $options->attachments = $formdata->attachments; if ((int)$formdata->attachments === 0 && $formdata->attachmentsrequired > 0) { // Adjust the value for the field 'attachmentsrequired' when the field 'attachments' is set to 'No'. $options->attachmentsrequired = 0; } else { $options->attachmentsrequired = $formdata->attachmentsrequired; } if (!isset($formdata->filetypeslist)) { $options->filetypeslist = null; } else { $options->filetypeslist = $formdata->filetypeslist; } $options->maxbytes = $formdata->maxbytes ?? 0; $options->graderinfo = $this->import_or_save_files($formdata->graderinfo, $context, 'qtype_essay', 'graderinfo', $formdata->id); $options->graderinfoformat = $formdata->graderinfo['format']; $options->responsetemplate = $formdata->responsetemplate['text']; $options->responsetemplateformat = $formdata->responsetemplate['format']; $DB->update_record('qtype_essay_options', $options); } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $question->responseformat = $questiondata->options->responseformat; $question->responserequired = $questiondata->options->responserequired; $question->responsefieldlines = $questiondata->options->responsefieldlines; $question->minwordlimit = $questiondata->options->minwordlimit; $question->maxwordlimit = $questiondata->options->maxwordlimit; $question->attachments = $questiondata->options->attachments; $question->attachmentsrequired = $questiondata->options->attachmentsrequired; $question->graderinfo = $questiondata->options->graderinfo; $question->graderinfoformat = $questiondata->options->graderinfoformat; $question->responsetemplate = $questiondata->options->responsetemplate; $question->responsetemplateformat = $questiondata->options->responsetemplateformat; $filetypesutil = new \core_form\filetypes_util(); $question->filetypeslist = $filetypesutil->normalize_file_types($questiondata->options->filetypeslist); $question->maxbytes = $questiondata->options->maxbytes; } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('qtype_essay_options', array('questionid' => $questionid)); parent::delete_question($questionid, $contextid); } /** * @return array the different response formats that the question type supports. * internal name => human-readable name. */ public function response_formats() { return array( 'editor' => get_string('formateditor', 'qtype_essay'), 'editorfilepicker' => get_string('formateditorfilepicker', 'qtype_essay'), 'plain' => get_string('formatplain', 'qtype_essay'), 'monospaced' => get_string('formatmonospaced', 'qtype_essay'), 'noinline' => get_string('formatnoinline', 'qtype_essay'), ); } /** * @return array the choices that should be offerd when asking if a response is required */ public function response_required_options() { return array( 1 => get_string('responseisrequired', 'qtype_essay'), 0 => get_string('responsenotrequired', 'qtype_essay'), ); } /** * @return array the choices that should be offered for the input box size. */ public function response_sizes() { $choices = [ 2 => get_string('nlines', 'qtype_essay', 2), 3 => get_string('nlines', 'qtype_essay', 3), ]; for ($lines = 5; $lines <= 40; $lines += 5) { $choices[$lines] = get_string('nlines', 'qtype_essay', $lines); } return $choices; } /** * @return array the choices that should be offered for the number of attachments. */ public function attachment_options() { return array( 0 => get_string('no'), 1 => '1', 2 => '2', 3 => '3', -1 => get_string('unlimited'), ); } /** * @return array the choices that should be offered for the number of required attachments. */ public function attachments_required_options() { return array( 0 => get_string('attachmentsoptional', 'qtype_essay'), 1 => '1', 2 => '2', 3 => '3' ); } /** * Return array of the choices that should be offered for the maximum file sizes. * @return array|lang_string[]|string[] */ public function max_file_size_options() { global $CFG, $COURSE; return get_max_upload_sizes($CFG->maxbytes, $COURSE->maxbytes); } public function move_files($questionid, $oldcontextid, $newcontextid) { parent::move_files($questionid, $oldcontextid, $newcontextid); $fs = get_file_storage(); $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_essay', 'graderinfo', $questionid); } protected function delete_files($questionid, $contextid) { parent::delete_files($questionid, $contextid); $fs = get_file_storage(); $fs->delete_area_files($contextid, 'qtype_essay', 'graderinfo', $questionid); } } PK\|)|)type/essay/edit_essay_form.phpnu[. /** * Defines the editing form for the essay question type. * * @package qtype * @subpackage essay * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Essay question type editing form. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_edit_form extends question_edit_form { protected function definition_inner($mform) { $qtype = question_bank::get_qtype('essay'); $mform->addElement('header', 'responseoptions', get_string('responseoptions', 'qtype_essay')); $mform->setExpanded('responseoptions'); $mform->addElement('select', 'responseformat', get_string('responseformat', 'qtype_essay'), $qtype->response_formats()); $mform->setDefault('responseformat', $this->get_default_value('responseformat', 'editor')); $mform->addElement('select', 'responserequired', get_string('responserequired', 'qtype_essay'), $qtype->response_required_options()); $mform->setDefault('responserequired', $this->get_default_value('responserequired', 1)); $mform->hideIf('responserequired', 'responseformat', 'eq', 'noinline'); $mform->addElement('select', 'responsefieldlines', get_string('responsefieldlines', 'qtype_essay'), $qtype->response_sizes()); $mform->setDefault('responsefieldlines', $this->get_default_value('responsefieldlines', 10)); $mform->hideIf('responsefieldlines', 'responseformat', 'eq', 'noinline'); // Create a text box that can be enabled/disabled for max/min word limits options. $wordlimitoptions = ['size' => '6', 'maxlength' => '6']; $strminwordlimit = get_string('minwordlimit', 'qtype_essay'); $mingrp[] = $mform->createElement('checkbox', 'minwordenabled', '', get_string('enable')); $mform->setDefault('minwordenabled', 0); $mingrp[] = $mform->createElement('text', 'minwordlimit', '', $wordlimitoptions + ['aria-label' => $strminwordlimit]); $mform->setType('minwordlimit', PARAM_INT); $mform->addGroup($mingrp, 'mingroup', $strminwordlimit, ' ', false); $mform->addHelpButton('mingroup', 'minwordlimit', 'qtype_essay'); $mform->disabledIf('minwordlimit', 'minwordenabled', 'notchecked'); $mform->hideIf('mingroup', 'responserequired', 'eq', '0'); $mform->hideIf('mingroup', 'responseformat', 'eq', 'noinline'); $strmaxwordlimit = get_string('maxwordlimit', 'qtype_essay'); $maxgrp[] = $mform->createElement('checkbox', 'maxwordenabled', '', get_string('enable')); $mform->setDefault('maxwordenabled', 0); $maxgrp[] = $mform->createElement('text', 'maxwordlimit', '', $wordlimitoptions + ['aria-label' => $strmaxwordlimit]); $mform->setType('maxwordlimit', PARAM_INT); $mform->addGroup($maxgrp, 'maxgroup', $strmaxwordlimit, ' ', false); $mform->addHelpButton('maxgroup', 'maxwordlimit', 'qtype_essay'); $mform->disabledIf('maxwordlimit', 'maxwordenabled', 'notchecked'); $mform->hideIf('maxgroup', 'responserequired', 'eq', '0'); $mform->hideIf('maxgroup', 'responseformat', 'eq', 'noinline'); $mform->addElement('select', 'attachments', get_string('allowattachments', 'qtype_essay'), $qtype->attachment_options()); $mform->setDefault('attachments', $this->get_default_value('attachments', 0)); $mform->addElement('select', 'attachmentsrequired', get_string('attachmentsrequired', 'qtype_essay'), $qtype->attachments_required_options()); $mform->setDefault('attachmentsrequired', $this->get_default_value('attachmentsrequired', 0)); $mform->addHelpButton('attachmentsrequired', 'attachmentsrequired', 'qtype_essay'); $mform->hideIf('attachmentsrequired', 'attachments', 'eq', 0); $mform->addElement('filetypes', 'filetypeslist', get_string('acceptedfiletypes', 'qtype_essay')); $mform->addHelpButton('filetypeslist', 'acceptedfiletypes', 'qtype_essay'); $mform->hideIf('filetypeslist', 'attachments', 'eq', 0); $mform->addElement('select', 'maxbytes', get_string('maxbytes', 'qtype_essay'), $qtype->max_file_size_options()); $mform->setDefault('maxbytes', $this->get_default_value('maxbytes', 0)); $mform->hideIf('maxbytes', 'attachments', 'eq', 0); $mform->addElement('header', 'responsetemplateheader', get_string('responsetemplateheader', 'qtype_essay')); $mform->addElement('editor', 'responsetemplate', get_string('responsetemplate', 'qtype_essay'), array('rows' => 10), array_merge($this->editoroptions, array('maxfiles' => 0))); $mform->addHelpButton('responsetemplate', 'responsetemplate', 'qtype_essay'); $mform->addElement('header', 'graderinfoheader', get_string('graderinfoheader', 'qtype_essay')); $mform->setExpanded('graderinfoheader'); $mform->addElement('editor', 'graderinfo', get_string('graderinfo', 'qtype_essay'), array('rows' => 10), $this->editoroptions); } protected function data_preprocessing($question) { $question = parent::data_preprocessing($question); if (empty($question->options)) { return $question; } $question->responseformat = $question->options->responseformat; $question->responserequired = $question->options->responserequired; $question->responsefieldlines = $question->options->responsefieldlines; $question->minwordenabled = $question->options->minwordlimit ? 1 : 0; $question->minwordlimit = $question->options->minwordlimit; $question->maxwordenabled = $question->options->maxwordlimit ? 1 : 0; $question->maxwordlimit = $question->options->maxwordlimit; $question->attachments = $question->options->attachments; $question->attachmentsrequired = $question->options->attachmentsrequired; $question->filetypeslist = $question->options->filetypeslist; $question->maxbytes = $question->options->maxbytes; $draftid = file_get_submitted_draft_itemid('graderinfo'); $question->graderinfo = array(); $question->graderinfo['text'] = file_prepare_draft_area( $draftid, // Draftid $this->context->id, // context 'qtype_essay', // component 'graderinfo', // filarea !empty($question->id) ? (int) $question->id : null, // itemid $this->fileoptions, // options $question->options->graderinfo // text. ); $question->graderinfo['format'] = $question->options->graderinfoformat; $question->graderinfo['itemid'] = $draftid; $question->responsetemplate = array( 'text' => $question->options->responsetemplate, 'format' => $question->options->responsetemplateformat, ); return $question; } public function validation($fromform, $files) { $errors = parent::validation($fromform, $files); // Don't allow both 'no inline response' and 'no attachments' to be selected, // as these options would result in there being no input requested from the user. if ($fromform['responseformat'] == 'noinline' && !$fromform['attachments']) { $errors['attachments'] = get_string('mustattach', 'qtype_essay'); } // If 'no inline response' is set, force the teacher to require attachments; // otherwise there will be nothing to grade. if ($fromform['responseformat'] == 'noinline' && !$fromform['attachmentsrequired']) { $errors['attachmentsrequired'] = get_string('mustrequire', 'qtype_essay'); } // Don't allow the teacher to require more attachments than they allow; as this would // create a condition that it's impossible for the student to meet. if ($fromform['attachments'] > 0 && $fromform['attachments'] < $fromform['attachmentsrequired'] ) { $errors['attachmentsrequired'] = get_string('mustrequirefewer', 'qtype_essay'); } if ($fromform['responserequired']) { if (isset($fromform['minwordenabled'])) { if (!is_numeric($fromform['minwordlimit'])) { $errors['mingroup'] = get_string('err_numeric', 'form'); } if ($fromform['minwordlimit'] < 0) { $errors['mingroup'] = get_string('err_minwordlimitnegative', 'qtype_essay'); } if (!$fromform['minwordlimit']) { $errors['mingroup'] = get_string('err_minwordlimit', 'qtype_essay'); } } if (isset($fromform['maxwordenabled'])) { if (!is_numeric($fromform['maxwordlimit'])) { $errors['maxgroup'] = get_string('err_numeric', 'form'); } if ($fromform['maxwordlimit'] < 0) { $errors['maxgroup'] = get_string('err_maxwordlimitnegative', 'qtype_essay'); } if (!$fromform['maxwordlimit']) { $errors['maxgroup'] = get_string('err_maxwordlimit', 'qtype_essay'); } } if (isset($fromform['maxwordenabled']) && isset($fromform['minwordenabled'])) { if ($fromform['maxwordlimit'] < $fromform['minwordlimit'] && $fromform['maxwordlimit'] > 0 && $fromform['minwordlimit'] > 0) { $errors['maxgroup'] = get_string('err_maxminmismatch', 'qtype_essay'); } } } return $errors; } public function qtype() { return 'essay'; } } PK\%b type/calculated/styles.cssnu[.que.calculated .answer { padding: 0.3em; display: inline; } .que.calculated .answer input[type="text"] { width: 30%; } /*rtl:ignore*/ #page-question-type-calculated input[name^="answer"], #page-question-type-calculated input[name^="unit"], #page-question-type-calculated input[name^="multiplier"], #page-question-type-calculated input[name^="calcmax"], #page-question-type-calculated input[name^="calcmin"], #page-question-type-calculated input[name^="number"], #page-question-type-calculated input[name^="tolerance"] { direction: ltr; text-align: left; } /* Editing form. */ body#page-question-type-calculated div[id^=fgroup_id_][id*=answeroptions_], body#page-question-type-calculatedmulti div[id^=fitem_id_][id*=answer_] { background: #eee; margin-top: 0; margin-bottom: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-bottom: 0; } body#page-question-type-calculated div[id^=fgroup_id_][id*=answeroptions_] .fgrouplabel label, body#page-question-type-calculatedmulti div[id^=fitem_id_][id*=answer_] .fgrouplabel label { font-weight: bold; } body#page-question-type-calculated div[id^=fgroup_id_][id*=answeroptions_] label[for^='id_answer_'], body#page-question-type-calculated div[id^=fgroup_id_][id*=answertolerance_] label[for^='id_tolerance_'], body#page-question-type-calculated div[id^=fgroup_id_][id*=answerdisplay_] label[for^='id_correctanswerlength_'], body#page-question-type-calculatedmulti div[id^=fgroup_id_][id*=answerdisplay_] label[for^='id_correctanswerlength_'] { position: absolute; left: -10000px; font-weight: normal; font-size: 1em; } body#page-question-type-calculated div[id^=fgroup_id_][id*=answertolerance_], body#page-question-type-calculated div[id^=fgroup_id_][id*=answerdisplay_], body#page-question-type-calculatedmulti div[id^=fitem_id_][id*=fraction_], body#page-question-type-calculatedmulti div[id^=fgroup_id_][id*=answertolerance_], body#page-question-type-calculatedmulti div[id^=fgroup_id_][id*=answerdisplay_] { background: #eee; margin-bottom: 0; margin-top: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-top: 0; border-bottom: 0; } body#page-question-type-calculated div[id^=fitem_id_][id*=feedback_], body#page-question-type-calculatedmulti div[id^=fitem_id_][id*=feedback_] { background: #eee; margin-bottom: 2em; margin-top: 0; padding-bottom: 5px; padding-top: 5px; border: 1px solid #bbb; border-top: 0; } /* stylelint-disable declaration-no-important */ div[id^="fgroup_id_answeroptions_"].mb-3, div[id^="fgroup_id_answertolerance_"].mb-3, div[id^="fgroup_id_answerdisplay_"].mb-3 { margin-bottom: 0 !important; } /* stylelint-enable declaration-no-important */ PK\ type/calculated/renderer.phpnu[. /** * Calculated question renderer class. * * @package qtype * @subpackage calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/numerical/renderer.php'); /** * Generates the output for calculated questions. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_renderer extends qtype_numerical_renderer { public function correct_response(question_attempt $qa) { $question = $qa->get_question(); $answer = $question->get_correct_response(); if (!$answer) { return ''; } $response = $answer['answer']; if ($question->unitdisplay != qtype_numerical::UNITNONE && $question->unitdisplay != qtype_numerical::UNITINPUT) { $response = $question->ap->add_unit($response); } return get_string('correctansweris', 'qtype_shortanswer', $response); } } PK\Vºtype/calculated/db/upgrade.phpnu[. /** * Calculated question type upgrade code. * * @package qtype * @subpackage calculated * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the calculated question type. * @param int $oldversion the version we are upgrading from. */ function xmldb_qtype_calculated_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\ ahhtype/calculated/db/install.xmlnu[
    PK\ ˌ**!type/calculated/db/upgradelib.phpnu[. /** * Class for converting attempt data for calculated questions when upgrading * attempts to the new question engine. * * This class is used by the code in question/engine/upgrade/upgradelib.php. * * @package qtype_calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_qe2_attempt_updater extends question_qtype_attempt_updater { protected $selecteditem = null; /** @var array variable name => value */ protected $values = []; /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */ protected $search = []; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $safevalue = []; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $prettyvalue = []; public function question_summary() { return ''; // Done later, after we know which dataset is used. } public function right_answer() { foreach ($this->question->options->answers as $ans) { if ($ans->fraction > 0.999) { $right = $this->calculate($ans->answer); if (empty($this->question->options->units)) { return $right; } $unit = reset($this->question->options->units); $unit = $unit->unit; if (!empty($this->question->options->unitsleft)) { return $unit . ' ' . $right; } else { return $right . ' ' . $unit; } } } } protected function parse_response($state) { if (strpos($state->answer, '-') < 7) { // Broken state, skip it. throw new coding_exception("Brokes state {$state->id} for calculated question {$state->question}. (It did not specify a dataset."); } [$datasetbit, $realanswer] = explode('-', $state->answer, 2); $selecteditem = substr($datasetbit, 7); if (is_null($this->selecteditem)) { $this->load_dataset($selecteditem); } else if ($this->selecteditem != $selecteditem) { $this->logger->log_assumption("Different states for calculated question {$state->question} used different dataset items. Ignoring the change in state {$state->id} and coninuting to use item {$this->selecteditem}."); } if (!$realanswer) { return ['', '']; } if (strpos($realanswer, '|||||') === false) { $answer = $realanswer; $unit = ''; } else { [$answer, $unit] = explode('|||||', $realanswer, 2); } return [$answer, $unit]; } public function response_summary($state) { [$answer, $unit] = $this->parse_response($state); if (empty($answer) && empty($unit)) { $resp = null; } else { $resp = $answer; } if (!empty($unit)) { if (!empty($this->question->options->unitsleft)) { $resp = trim($unit . ' ' . $resp); } else { $resp = trim($resp . ' ' . $unit); } } return $resp; } public function was_answered($state) { return !empty($state->answer); } public function set_first_step_data_elements($state, &$data) { $this->parse_response($state); $this->updater->qa->questionsummary = $this->to_text( $this->replace_expressions_in_text($this->question->questiontext)); $this->updater->qa->rightanswer = $this->right_answer($this->question); foreach ($this->values as $name => $value) { $data['_var_' . $name] = $value; } $data['_separators'] = '.$,'; } public function supply_missing_first_step_data(&$data) { $data['_separators'] = '.$,'; } public function set_data_elements_for_step($state, &$data) { if (empty($state->answer)) { return; } [$answer, $unit] = $this->parse_response($state); if ( !empty($this->question->options->showunits) && $this->question->options->showunits == 1 ) { // Multichoice units. $data['answer'] = $answer; $data['unit'] = $unit; } else if (!empty($this->question->options->unitsleft)) { if (!empty($unit)) { $data['answer'] = $unit . ' ' . $answer; } else { $data['answer'] = $answer; } } else { if (!empty($unit)) { $data['answer'] = $answer . ' ' . $unit; } else { $data['answer'] = $answer; } } } public function load_dataset($selecteditem) { $this->selecteditem = $selecteditem; $this->updater->qa->variant = $selecteditem; $this->values = $this->qeupdater->load_dataset( $this->question->id, $selecteditem); // Prepare an array for {@link substitute_values()}. $this->search = []; $this->safevalue = []; $this->prettyvalue = []; foreach ($this->values as $name => $value) { if (!is_numeric($value)) { $a = new stdClass(); $a->name = '{' . $name . '}'; $a->value = $value; throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); } $this->search[] = '{' . $name . '}'; $this->safevalue[] = '(' . $value . ')'; $this->prettyvalue[] = $this->format_float($value); } } /** * This function should be identical to * {@link qtype_calculated_variable_substituter::format_float()}. Except that * we do not try to do locale-aware replacement of the decimal point. * * Having to copy it here is a pain, but it is the standard rule about not * using library code (which may change in future) in upgrade code, which * exists at a point in time. * * Display a float properly formatted with a certain number of decimal places. * @param number $x the number to format * @param int $length restrict to this many decimal places or significant * figures. If null, the number is not rounded. * @param int format 1 => decimalformat, 2 => significantfigures. * @return string formtted number. */ public function format_float($x, $length = null, $format = null) { if (!is_null($length) && !is_null($format)) { if ($format == 1) { // Decimal places. $x = sprintf('%.' . $length . 'F', $x); } else if ($format == 2) { // Significant figures. $x = sprintf('%.' . $length . 'g', $x); } } return $x; } /** * Evaluate an expression using the variable values. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ public function calculate($expression) { return $this->calculate_raw($this->substitute_values_for_eval($expression)); } /** * Evaluate an expression after the variable values have been substituted. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ protected function calculate_raw($expression) { try { // In older PHP versions this this is a way to validate code passed to eval. // The trick came from http://php.net/manual/en/function.eval.php. if (@eval('return true; $result = ' . $expression . ';')) { return eval('return ' . $expression . ';'); } } catch (Throwable $e) { // PHP7 and later now throws ParseException and friends from eval(), // which is much better. } // In either case of an invalid $expression, we end here. return '[Invalid expression ' . $expression . ']'; } /** * Substitute variable placehodlers like {a} with their value wrapped in (). * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_for_eval($expression) { return str_replace($this->search, $this->safevalue, $expression ?? ''); } /** * Substitute variable placehodlers like {a} with their value without wrapping * the value in anything. * @param string $text some content with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_pretty($text) { return str_replace($this->search, $this->prettyvalue, $text ?? ''); } /** * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) * in some text with the corresponding values. * @param string $text the text to process. * @return string the text with values substituted. */ public function replace_expressions_in_text($text, $length = null, $format = null) { if ($text === null || $text === '') { return $text; } $vs = $this; // Can't see to use $this in a PHP closure. $text = preg_replace_callback( '~\{=([^{}]*(?:\{[^{}]+}[^{}]*)*)}~', function ($matches) use ($vs, $format, $length) { return $vs->format_float($vs->calculate($matches[1]), $length, $format); }, $text ); return $this->substitute_values_pretty($text); } } PK\F]++,type/calculated/lang/en/qtype_calculated.phpnu[. /** * Strings for component 'qtype_calculated', language 'en', branch 'MOODLE_20_STABLE' * * @package qtype * @subpackage calculated * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['additem'] = 'Add item'; $string['addmoreanswerblanks'] = 'Add another answer blank.'; $string['addsets'] = 'Add set(s)'; $string['answerformula'] = 'Answer {$a} formula'; $string['answerdisplay'] = 'Answer display'; $string['answerhdr'] = 'Answer'; $string['answerstoleranceparam'] = 'Answers tolerance parameters'; $string['answerwithtolerance'] = '{$a->answer} (±{$a->tolerance} {$a->tolerancetype})'; $string['anyvalue'] = 'Any value'; $string['atleastoneanswer'] = 'You need to provide at least one answer.'; $string['atleastonerealdataset'] = 'There should be at least one real dataset in question text'; $string['atleastonewildcard'] = 'There should be at least one wild card in answer formula or question text'; $string['calcdistribution'] = 'Distribution'; $string['calclength'] = 'Decimal places'; $string['calcmax'] = 'Maximum'; $string['calcmin'] = 'Minimum'; $string['choosedatasetproperties'] = 'Choose wildcards dataset properties'; $string['choosedatasetproperties_help'] = 'A dataset is a set of values inserted in place of a wildcard. You can create a private dataset for a specific question, or a shared dataset that can be used for other calculated questions within the category.'; $string['correctanswerformula'] = 'Correct answer formula'; $string['correctanswershows'] = 'Correct answer shows'; $string['correctanswershowsformat'] = 'Format'; $string['correctfeedback'] = 'For any correct response'; $string['dataitemdefined'] = 'with {$a} numerical values already defined is available'; $string['datasetrole'] = ' The wild cards {x..} will be substituted by a numerical value from their dataset'; $string['decimals'] = 'with {$a}'; $string['deleteitem'] = 'Delete item'; $string['deletelastitem'] = 'Delete last item'; $string['distributionoption'] = 'Select distribution option'; $string['editdatasets'] = 'Edit the wildcards datasets'; $string['editdatasets_help'] = 'Wildcard values may be created by entering a number in each wild card field then clicking the add button. To automatically generate 10 or more values, select the number of values required before clicking the add button. A uniform distribution means any value between the limits is equally likely to be generated; a loguniform distribution means that values towards the lower limit are more likely.'; $string['editdatasets_link'] = 'question/type/calculated'; $string['existingcategory1'] = 'will use an already existing shared dataset'; $string['existingcategory2'] = 'a file from an already existing set of files that are also used by other questions in this category'; $string['existingcategory3'] = 'a link from an already existing set of links that are also used by other questions in this category'; $string['forceregeneration'] = 'force regeneration'; $string['forceregenerationall'] = 'Force regeneration of all wildcards'; $string['forceregenerationshared'] = 'Force regeneration of only non-shared wildcards'; $string['functiontakesatleasttwo'] = 'The function {$a} must have at least two arguments'; $string['functiontakesnoargs'] = 'The function {$a} does not take any arguments'; $string['functiontakesonearg'] = 'The function {$a} must have exactly one argument'; $string['functiontakesoneortwoargs'] = 'The function {$a} must have either one or two arguments'; $string['functiontakestwoargs'] = 'The function {$a} must have exactly two arguments'; $string['generatevalue'] = 'Generate a new value between'; $string['getnextnow'] = 'Get new \'Item to Add\' now'; $string['hexanotallowed'] = 'Dataset {$a->name} hexadecimal format value {$a->value} is not allowed'; $string['illegalformulasyntax'] = 'Illegal formula syntax starting with \'{$a}\''; $string['incorrectfeedback'] = 'For any incorrect response'; $string['item(s)'] = 'item(s)'; $string['itemno'] = 'Item {$a}'; $string['itemscount'] = 'Items
    Count'; $string['itemtoadd'] = 'Item to add'; $string['keptcategory1'] = 'will use the same existing shared dataset as before'; $string['keptcategory2'] = 'a file from the same category reusable set of files as before'; $string['keptcategory3'] = 'a link from the same category reusable set of links as before'; $string['keptlocal1'] = 'will use the same existing private dataset as before'; $string['keptlocal2'] = 'a file from the same question private set of files as before'; $string['keptlocal3'] = 'a link from the same question private set of links as before'; $string['lastitem(s)'] = 'last items(s)'; $string['lengthoption'] = 'Select length option'; $string['loguniform'] = 'Loguniform'; $string['loguniformbit'] = 'digits, from a loguniform distribution'; $string['makecopynextpage'] = 'Next page (new question)'; $string['mandatoryhdr'] = 'Mandatory wild cards present in answers'; $string['max'] = 'Max'; $string['min'] = 'Min'; $string['minmax'] = 'Range of Values'; $string['missingformula'] = 'Missing formula'; $string['missingname'] = 'Missing question name'; $string['missingquestiontext'] = 'Missing question text'; $string['mustenteraformulaorstar'] = 'You must enter a formula or \'*\'.'; $string['newcategory1'] = 'will use a new shared dataset'; $string['newcategory2'] = 'a file from a new set of files that may also be used by other questions in this category'; $string['newcategory3'] = 'a link from a new set of links that may also be used by other questions in this category'; $string['newlocal1'] = 'will use a new private dataset'; $string['newlocal2'] = 'a file from a new set of files that will only be used by this question'; $string['newlocal3'] = 'a link from a new set of links that will only be used by this question'; $string['nextitemtoadd'] = 'Next \'Item to Add\''; $string['nextpage'] = 'Next page'; $string['nocoherencequestionsdatyasetcategory'] = 'For question id {$a->qid}, the category id {$a->qcat} is not identical with the shared wild card {$a->name} category id {$a->sharedcat}. Edit the question.'; $string['nocommaallowed'] = 'The , cannot be used, use . as in 0.013 or 1.3e-2'; $string['nodataset'] = 'nothing - it is not a wild card'; $string['nosharedwildcard'] = 'No shared wild card in this category'; $string['notvalidnumber'] = 'Wild card value is not a valid number '; $string['oneanswertrueansweroutsidelimits'] = 'At least one correct answer outside the true value limits.
    Modify the answers tolerance settings available as Advanced parameters'; $string['param'] = 'Param {{$a}}'; $string['partiallycorrectfeedback'] = 'For any partially correct response'; $string['pluginname'] = 'Calculated'; $string['pluginname_help'] = 'Calculated questions enable individual numerical questions to be created using wildcards in curly brackets that are substituted with individual values when the quiz is taken. For example, the question "What is the area of a rectangle of length {l} and width {w}?" would have correct answer formula "{l}*{w}" (where * denotes multiplication).'; $string['pluginname_link'] = 'question/type/calculated'; $string['pluginnameadding'] = 'Adding a Calculated question'; $string['pluginnameediting'] = 'Editing a Calculated question'; $string['pluginnamesummary'] = 'Calculated questions are like numerical questions but with the numbers used selected randomly from a set when the quiz is taken.'; $string['privacy:metadata'] = 'The Calculated question type plugin does not store any personal data.'; $string['possiblehdr'] = 'Possible wild cards present only in the question text'; $string['questiondatasets'] = 'Question datasets'; $string['questiondatasets_help'] = 'Question datasets of wild cards that will be used in each individual question'; $string['questionstoredname'] = 'Question stored name'; $string['replacewithrandom'] = 'Replace with a random value'; $string['reuseifpossible'] = 'Reuse previous value if available'; $string['sharedwildcard'] = 'Shared wild card {{$a}}'; $string['sharedwildcardname'] = 'Shared wild card '; $string['sharedwildcards'] = 'Shared wild cards'; $string['significantfigures'] = 'with {$a}'; $string['significantfiguresformat'] = 'significant figures'; $string['synchronize'] = 'Synchronise the data from shared datasets with other questions in a quiz'; $string['synchronizeno'] = 'Do not synchronise'; $string['synchronizeyes'] = 'Synchronise'; $string['synchronizeyesdisplay'] = 'Synchronise and display the shared datasets name as prefix of the question name'; $string['tolerance'] = 'Tolerance ±'; $string['tolerancetype'] = 'Type'; $string['trueanswerinsidelimits'] = 'Correct answer : {$a->correct} inside limits of true value {$a->true}'; $string['trueansweroutsidelimits'] = 'ERROR Correct answer : {$a->correct} outside limits of true value {$a->true}'; $string['uniform'] = 'Uniform'; $string['uniformbit'] = 'decimals, from a uniform distribution'; $string['updatecategory'] = 'Update the category'; $string['updatedatasetparam'] = 'Update the datasets parameters'; $string['updatetolerancesparam'] = 'Update the answers tolerance parameters'; $string['usedinquestion'] = 'Used in Question'; $string['youmustaddatleastoneitem'] = 'You must add at least one dataset item before you can save this question.'; $string['youmustaddatleastonevalue'] = 'You must add at least one set of wild card(s) values before you can save this question.'; $string['newsetwildcardvalues'] = 'new set(s) of wild card(s) values'; $string['setno'] = 'Set {$a}'; $string['setwildcardvalues'] = 'set(s) of wild card(s) values'; $string['showitems'] = 'Display'; $string['updatewildcardvalues'] = 'Update the wild card(s) values'; $string['unsupportedformulafunction'] = 'The function {$a} is not supported'; $string['useadvance'] = 'Use the advance button to see the errors'; $string['wildcard'] = 'Wild card {{$a}}'; $string['wildcardparam'] = 'Wild cards parameters used to generate the values'; $string['wildcardrole'] = 'The wild cards {x..} will be substituted by a numerical value from the generated values'; $string['wildcards'] = 'Wild cards {a}...{z}'; $string['wildcardvalues'] = 'Wild card(s) values'; $string['wildcardvaluesgenerated'] = 'Wild card(s) values generated'; $string['zerosignificantfiguresnotallowed'] = 'The correct answer cannot have zero significant figures!'; PK\'vQHtype/calculated/backup/moodle2/restore_qtype_calculated_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * restore plugin class that provides the necessary information * needed to restore one calculated qtype plugin * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_qtype_calculated_plugin extends restore_qtype_plugin { /** * Returns the paths to be handled by the plugin at question level */ protected function define_question_plugin_structure() { $paths = array(); // This qtype uses question_answers, add them. $this->add_question_question_answers($paths); // This qtype uses question_numerical_options and question_numerical_units, add them. $this->add_question_numerical_options($paths); $this->add_question_numerical_units($paths); // This qtype uses question datasets, add them. $this->add_question_datasets($paths); // Add own qtype stuff. $elename = 'calculated_record'; $elepath = $this->get_pathfor('/calculated_records/calculated_record'); $paths[] = new restore_path_element($elename, $elepath); $elename = 'calculated_option'; $elepath = $this->get_pathfor('/calculated_options/calculated_option'); $paths[] = new restore_path_element($elename, $elepath); return $paths; // And we return the interesting paths. } /** * Process the qtype/calculated_record element */ public function process_calculated_record($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its // question_calculated too. if ($questioncreated) { // Adjust some columns. $data->question = $newquestionid; $data->answer = $this->get_mappingid('question_answer', $data->answer); // Insert record. $newitemid = $DB->insert_record('question_calculated', $data); } } /** * Process the qtype/calculated_option element */ public function process_calculated_option($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Detect if the question is created or mapped. $oldquestionid = $this->get_old_parentid('question'); $newquestionid = $this->get_new_parentid('question'); $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its // question_calculated too. if ($questioncreated) { // Adjust some columns. $data->question = $newquestionid; // Insert record. $newitemid = $DB->insert_record('question_calculated_options', $data); } } #[\Override] public static function convert_backup_to_questiondata(array $backupdata): \stdClass { $questiondata = parent::convert_backup_to_questiondata($backupdata); $qtype = $questiondata->qtype; foreach ($backupdata["plugin_qtype_{$qtype}_question"]['calculated_records']['calculated_record'] as $record) { foreach ($questiondata->options->answers as &$answer) { if ($answer->id == $record['answer']) { $answer->tolerance = $record['tolerance']; $answer->tolerancetype = $record['tolerancetype']; $answer->correctanswerlength = $record['correctanswerlength']; $answer->correctanswerformat = $record['correctanswerformat']; continue 2; } } } if (isset($backupdata["plugin_qtype_{$qtype}_question"]['calculated_options'])) { $questiondata->options = (object) array_merge( (array) $questiondata->options, $backupdata["plugin_qtype_{$qtype}_question"]['calculated_options']['calculated_option'][0], ); } return $questiondata; } #[\Override] protected function define_excluded_identity_hash_fields(): array { return [ // These option fields are present in the database, but are only used by calculatedmulti. '/options/synchronize', '/options/single', '/options/shuffleanswers', '/options/correctfeedback', '/options/correctfeedbackformat', '/options/partiallycorrectfeedback', '/options/partiallycorrectfeedbackformat', '/options/incorrectfeedback', '/options/incorrectfeedbackformat', '/options/answernumbering', '/options/shownumcorrect', ]; } } PK\;Gtype/calculated/backup/moodle2/backup_qtype_calculated_plugin.class.phpnu[. /** * @package moodlecore * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Provides the information to backup calculated questions * * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_qtype_calculated_plugin extends backup_qtype_plugin { /** * Returns the qtype information to attach to question element */ protected function define_question_plugin_structure() { // Define the virtual plugin element with the condition to fulfill. // Note: we use $this->pluginname so for extended plugins this will work // automatically: calculatedsimple and calculatedmulti. $plugin = $this->get_plugin_element(null, '../../qtype', $this->pluginname); // Create one standard named plugin element (the visible container). $pluginwrapper = new backup_nested_element($this->get_recommended_name()); // Connect the visible container ASAP. $plugin->add_child($pluginwrapper); // This qtype uses standard question_answers, add them here // to the tree before any other information that will use them. $this->add_question_question_answers($pluginwrapper); // This qtype uses standard numerical units, add them here. $this->add_question_numerical_units($pluginwrapper); // This qtype uses standard numerical options, add them here. $this->add_question_numerical_options($pluginwrapper); // This qtype uses standard datasets, add them here. $this->add_question_datasets($pluginwrapper); // Now create the qtype own structures. $calculatedrecords = new backup_nested_element('calculated_records'); $calculatedrecord = new backup_nested_element('calculated_record', array('id'), array( 'answer', 'tolerance', 'tolerancetype', 'correctanswerlength', 'correctanswerformat')); $calculatedoptions = new backup_nested_element('calculated_options'); $calculatedoption = new backup_nested_element('calculated_option', array('id'), array( 'synchronize', 'single', 'shuffleanswers', 'correctfeedback', 'correctfeedbackformat', 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', 'incorrectfeedback', 'incorrectfeedbackformat', 'answernumbering', 'shownumcorrect')); // Now the own qtype tree. $pluginwrapper->add_child($calculatedrecords); $calculatedrecords->add_child($calculatedrecord); $pluginwrapper->add_child($calculatedoptions); $calculatedoptions->add_child($calculatedoption); // Set source to populate the data. $calculatedrecord->set_source_table('question_calculated', array('question' => backup::VAR_PARENTID)); $calculatedoption->set_source_table('question_calculated_options', array('question' => backup::VAR_PARENTID)); // Don't need to annotate ids nor files. return $plugin; } /** * Returns one array with filearea => mappingname elements for the qtype * * Used by {@link get_components_and_fileareas} to know about all the qtype * files to be processed both in backup and restore. */ public static function get_qtype_fileareas() { return array( 'instruction' => 'question_created', 'correctfeedback' => 'question_created', 'partiallycorrectfeedback' => 'question_created', 'incorrectfeedback' => 'question_created'); } } PK\n&type/calculated/backup/moodle1/lib.phpnu[. /** * @package qtype * @subpackage calculated * @copyright 2011 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Calculated question type conversion handler */ class moodle1_qtype_calculated_handler extends moodle1_qtype_handler { /** * @return array */ public function get_question_subpaths() { return array( 'ANSWERS/ANSWER', 'CALCULATED', 'CALCULATED/NUMERICAL_UNITS/NUMERICAL_UNIT', 'CALCULATED/DATASET_DEFINITIONS/DATASET_DEFINITION', 'CALCULATED/DATASET_DEFINITIONS/DATASET_DEFINITION/DATASET_ITEMS/DATASET_ITEM' ); } /** * Appends the calculated specific information to the question */ public function process_question(array $data, array $raw) { // Convert and write the answers first. if (isset($data['answers'])) { $this->write_answers($data['answers'], $this->pluginname); } // Convert and write the numerical units and numerical options. if (isset($data['calculated'][0]['numerical_units'])) { $numericalunits = $data['calculated'][0]['numerical_units']; } else { $numericalunits = array(); } $numericaloptions = $this->get_default_numerical_options( $data['oldquestiontextformat'], $numericalunits); $this->write_numerical_units($numericalunits); $this->write_numerical_options($numericaloptions); // Write dataset_definitions. if (isset($data['calculated'][0]['dataset_definitions']['dataset_definition'])) { $datasetdefinitions = $data['calculated'][0]['dataset_definitions']['dataset_definition']; } else { $datasetdefinitions = array(); } $this->write_dataset_definitions($datasetdefinitions); // Write calculated_records. $this->xmlwriter->begin_tag('calculated_records'); foreach ($data['calculated'] as $calculatedrecord) { $record = array( 'id' => $this->converter->get_nextid(), 'answer' => $calculatedrecord['answer'], 'tolerance' => $calculatedrecord['tolerance'], 'tolerancetype' => $calculatedrecord['tolerancetype'], 'correctanswerlength' => $calculatedrecord['correctanswerlength'], 'correctanswerformat' => $calculatedrecord['correctanswerformat'] ); $this->write_xml('calculated_record', $record, array('/calculated_record/id')); } $this->xmlwriter->end_tag('calculated_records'); // Write calculated_options. $options = array( 'calculate_option' => array( 'id' => $this->converter->get_nextid(), 'synchronize' => 0, 'single' => 0, 'shuffleanswers' => 0, 'correctfeedback' => null, 'correctfeedbackformat' => FORMAT_HTML, 'partiallycorrectfeedback' => null, 'partiallycorrectfeedbackformat' => FORMAT_HTML, 'incorrectfeedback' => null, 'incorrectfeedbackformat' => FORMAT_HTML, 'answernumbering' => 'abc' ) ); $this->write_xml('calculated_options', $options, array('/calculated_options/calculate_option/id')); } } PK\eqNNtype/calculated/pix/icon.gifnu[GIF89a!,%iǖὺX}Bi)ں F;PK\77type/calculated/pix/icon.svgnu[PK\s°type/calculated/lib.phpnu[. /** * Serve question type files * * @since Moodle 2.0 * @package qtype_calculated * @copyright Dongsheng Cai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Checks file access for calculated questions. * * @package qtype_calculated * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @param string $filearea file area * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool */ function qtype_calculated_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); question_pluginfile($course, $context, 'qtype_calculated', $filearea, $args, $forcedownload, $options); } PK\ type/calculated/version.phpnu[. /** * Version information for the calculated question type. * * @package qtype * @subpackage calculated * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_calculated'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->dependencies = [ 'qtype_numerical' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\!-!-(type/calculated/edit_calculated_form.phpnu[. /** * Defines the editing form for the calculated question type. * * @package qtype * @subpackage calculated * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/numerical/edit_numerical_form.php'); /** * Calculated question type editing form definition. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_edit_form extends qtype_numerical_edit_form { /** * Handle to the question type for this question. * * @var qtype_calculated */ public $qtypeobj; public $questiondisplay; public $activecategory; public $categorychanged = false; public $initialname = ''; public $reload = false; public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { global $CFG, $DB; $this->question = $question; $this->reload = optional_param('reload', false, PARAM_BOOL); if (!$this->reload) { // Use database data as this is first pass. if (isset($this->question->id)) { // Remove prefix #{..}# if exists. $this->initialname = $question->name; $question->name = question_bank::get_qtype($this->qtype()) ->clean_technical_prefix_from_question_name($question->name); } } parent::__construct($submiturl, $question, $category, $contexts, $formeditable); } public function get_per_answer_fields($mform, $label, $gradeoptions, &$repeatedoptions, &$answersoption) { $repeated = parent::get_per_answer_fields($mform, $label, $gradeoptions, $repeatedoptions, $answersoption); // Reorganise answer options group. 0 is the answer. 1 is tolerance. 2 is Grade. $answeroptions = $repeated[0]->getElements(); // Tolerance field will be part of its own group. $tolerance = $answeroptions[1]; // Update Answer options group to contain only answer and grade fields. $answeroptions[0]->setSize(55); $answeroptions = array($answeroptions[0], $answeroptions[2]); $repeated[0]->setElements($answeroptions); // Update answer field and group label. $repeated[0]->setLabel(get_string('answerformula', 'qtype_calculated', '{no}') . ' ='); $answeroptions[0]->setLabel(get_string('answerformula', 'qtype_calculated', '{no}') . ' ='); // Get feedback field to re append later. $feedback = array_pop($repeated); // Create tolerance group. $answertolerance = array(); $tolerance->setLabel(get_string('tolerance', 'qtype_calculated') . '='); $answertolerance[] = $tolerance; $answertolerance[] = $mform->createElement('select', 'tolerancetype', get_string('tolerancetype', 'qtype_calculated'), $this->qtypeobj->tolerance_types()); $repeated[] = $mform->createElement('group', 'answertolerance', get_string('tolerance', 'qtype_calculated'), $answertolerance, null, false); $repeatedoptions['tolerance']['default'] = 0.01; // Create display group. $answerdisplay = array(); $answerdisplay[] = $mform->createElement('select', 'correctanswerlength', get_string('answerdisplay', 'qtype_calculated'), range(0, 9)); $repeatedoptions['correctanswerlength']['default'] = 2; $answerlengthformats = array( '1' => get_string('decimalformat', 'qtype_numerical'), '2' => get_string('significantfiguresformat', 'qtype_calculated') ); $answerdisplay[] = $mform->createElement('select', 'correctanswerformat', get_string('correctanswershowsformat', 'qtype_calculated'), $answerlengthformats); $repeated[] = $mform->createElement('group', 'answerdisplay', get_string('answerdisplay', 'qtype_calculated'), $answerdisplay, null, false); // Add feedback. $repeated[] = $feedback; return $repeated; } /** * Add question-type specific form fields. * * @param MoodleQuickForm $mform the form being built. */ protected function definition_inner($mform) { $this->qtypeobj = question_bank::get_qtype($this->qtype()); $label = get_string('sharedwildcards', 'qtype_calculated'); $mform->addElement('hidden', 'initialcategory', 1); $mform->addElement('hidden', 'reload', 1); $mform->setType('initialcategory', PARAM_INT); $mform->setType('reload', PARAM_BOOL); $html2 = $this->qtypeobj->print_dataset_definitions_category($this->question); $mform->insertElementBefore( $mform->createElement('static', 'listcategory', $label, $html2), 'name'); if (isset($this->question->id)) { $mform->insertElementBefore($mform->createElement('static', 'initialname', get_string('questionstoredname', 'qtype_calculated'), format_string($this->initialname)), 'name'); }; $addfieldsname = 'updatecategory'; $addstring = get_string('updatecategory', 'qtype_calculated'); $mform->registerNoSubmitButton($addfieldsname); $mform->insertElementBefore( $mform->createElement('submit', $addfieldsname, $addstring), 'listcategory'); $mform->registerNoSubmitButton('createoptionbutton'); // Editing as regular question. $mform->setType('single', PARAM_INT); $mform->addElement('hidden', 'shuffleanswers', '1'); $mform->setType('shuffleanswers', PARAM_INT); $mform->addElement('hidden', 'answernumbering', 'abc'); $mform->setType('answernumbering', PARAM_SAFEDIR); $this->add_per_answer_fields($mform, get_string('answerhdr', 'qtype_calculated', '{no}'), question_bank::fraction_options(), 1, 1); $repeated = array(); $this->add_unit_options($mform, $this); $this->add_unit_fields($mform, $this); $this->add_interactive_settings(); // Hidden elements. $mform->addElement('hidden', 'synchronize', ''); $mform->setType('synchronize', PARAM_INT); $mform->addElement('hidden', 'wizard', 'datasetdefinitions'); $mform->setType('wizard', PARAM_ALPHA); } protected function can_preview() { return false; // Generally not possible for calculated questions on this page. } public function data_preprocessing($question) { $question = parent::data_preprocessing($question); $question = $this->data_preprocessing_answers($question); $question = $this->data_preprocessing_hints($question); $question = $this->data_preprocessing_units($question); $question = $this->data_preprocessing_unit_options($question); if (isset($question->options->synchronize)) { $question->synchronize = $question->options->synchronize; } return $question; } protected function data_preprocessing_answers($question, $withanswerfiles = false) { $question = parent::data_preprocessing_answers($question, $withanswerfiles); if (empty($question->options->answers)) { return $question; } $key = 0; foreach ($question->options->answers as $answer) { // See comment in the parent method about this hack. unset($this->_form->_defaultValues["tolerancetype[{$key}]"]); unset($this->_form->_defaultValues["correctanswerlength[{$key}]"]); unset($this->_form->_defaultValues["correctanswerformat[{$key}]"]); $question->tolerancetype[$key] = $answer->tolerancetype; $question->correctanswerlength[$key] = $answer->correctanswerlength; $question->correctanswerformat[$key] = $answer->correctanswerformat; $key++; } return $question; } public function qtype() { return 'calculated'; } /** * Validate the equations in the some question content. * @param array $errors where errors are being accumulated. * @param string $field the field being validated. * @param string $text the content of that field. * @return array the updated $errors array. */ protected function validate_text($errors, $field, $text) { $problems = qtype_calculated_find_formula_errors_in_text($text); if ($problems) { $errors[$field] = $problems; } return $errors; } public function validation($data, $files) { $errors = parent::validation($data, $files); // Verifying for errors in {=...} in question text. $errors = $this->validate_text($errors, 'questiontext', $data['questiontext']['text']); $errors = $this->validate_text($errors, 'generalfeedback', $data['generalfeedback']['text']); // Check that the answers use datasets. $answers = $data['answer']; $mandatorydatasets = array(); foreach ($answers as $key => $answer) { $problems = qtype_calculated_find_formula_errors($answer); if ($problems) { $errors['answeroptions['.$key.']'] = $problems; } $mandatorydatasets += $this->qtypeobj->find_dataset_names($answer); $errors = $this->validate_text($errors, 'feedback[' . $key . ']', $data['feedback'][$key]['text']); } if (empty($mandatorydatasets)) { foreach ($answers as $key => $answer) { $errors['answeroptions['.$key.']'] = get_string('atleastonewildcard', 'qtype_calculated'); } } // Validate the answer format. foreach ($answers as $key => $answer) { $trimmedanswer = trim($answer); if (trim($answer)) { if ($data['correctanswerformat'][$key] == 2 && $data['correctanswerlength'][$key] == '0') { $errors['answerdisplay['.$key.']'] = get_string('zerosignificantfiguresnotallowed', 'qtype_calculated'); } } } return $errors; } protected function is_valid_answer($answer, $data) { return !qtype_calculated_find_formula_errors($answer); } protected function valid_answer_message($answer) { if (!$answer) { return get_string('mustenteraformulaorstar', 'qtype_calculated'); } else { return qtype_calculated_find_formula_errors($answer); } } } PK\E?K?Ktype/calculated/question.phpnu[. /** * Calculated question definition class. * * @package qtype * @subpackage calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/numerical/question.php'); require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); /** * Represents a calculated question. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_question extends qtype_numerical_question implements qtype_calculated_question_with_expressions { /** @var qtype_calculated_dataset_loader helper for loading the dataset. */ public $datasetloader; /** @var qtype_calculated_variable_substituter stores the dataset we are using. */ public $vs; /** * @var bool wheter the dataset item to use should be chose based on attempt * start time, rather than randomly. */ public $synchronised; public function start_attempt(question_attempt_step $step, $variant) { qtype_calculated_question_helper::start_attempt($this, $step, $variant); parent::start_attempt($step, $variant); } public function apply_attempt_state(question_attempt_step $step) { qtype_calculated_question_helper::apply_attempt_state($this, $step); parent::apply_attempt_state($step); } public function calculate_all_expressions() { $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext); $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback); foreach ($this->answers as $ans) { if ($ans->answer && $ans->answer !== '*') { $ans->answer = $this->vs->calculate($ans->answer, $ans->correctanswerlength, $ans->correctanswerformat); } $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback, $ans->correctanswerlength, $ans->correctanswerformat); } // Replace expressions in hints referring MDL-36733. // Calculation through calculation() function in replace_expressions_in_text() function. // Validation through qtype_calculated_find_formula_errors() function in calculate() function. foreach ($this->hints as $hint) { $hint->hint = $this->vs->replace_expressions_in_text($hint->hint); } } public function get_num_variants() { return $this->datasetloader->get_number_of_items(); } public function get_variants_selection_seed() { if (!empty($this->synchronised) && $this->datasetloader->datasets_are_synchronised($this->category)) { return 'category' . $this->category; } else { return parent::get_variants_selection_seed(); } } public function get_correct_response() { $answer = $this->get_correct_answer(); if (!$answer) { return array(); } $response = array('answer' => $this->vs->format_float($answer->answer, $answer->correctanswerlength, $answer->correctanswerformat)); if ($this->has_separate_unit_field()) { $response['unit'] = $this->ap->get_default_unit(); } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) { $response['answer'] = $this->ap->add_unit($response['answer']); } return $response; } } /** * This interface defines the method that a quetsion type must implement if it * is to work with {@link qtype_calculated_question_helper}. * * As well as this method, the class that implements this interface must have * fields * public $datasetloader; // of type qtype_calculated_dataset_loader * public $vs; // of type qtype_calculated_variable_substituter * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface qtype_calculated_question_with_expressions { /** * Replace all the expression in the question definition with the values * computed from the selected dataset by calling $this->vs->calculate() and * $this->vs->replace_expressions_in_text() on the parts of the question * that require it. */ public function calculate_all_expressions(); } /** * Helper class for questions that use datasets. Works with the interface * {@link qtype_calculated_question_with_expressions} and the class * {@link qtype_calculated_dataset_loader} to set up the value of each variable * in start_attempt, and restore that in apply_attempt_state. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_calculated_question_helper { public static function start_attempt( qtype_calculated_question_with_expressions $question, question_attempt_step $step, $variant) { $question->vs = new qtype_calculated_variable_substituter( $question->datasetloader->get_values($variant), get_string('decsep', 'langconfig')); $question->calculate_all_expressions(); foreach ($question->vs->get_values() as $name => $value) { $step->set_qt_var('_var_' . $name, $value); } } public static function apply_attempt_state( qtype_calculated_question_with_expressions $question, question_attempt_step $step) { $values = array(); foreach ($step->get_qt_data() as $name => $value) { if (substr($name, 0, 5) === '_var_') { $values[substr($name, 5)] = $value; } } $question->vs = new qtype_calculated_variable_substituter( $values, get_string('decsep', 'langconfig')); $question->calculate_all_expressions(); } } /** * This class is responsible for loading the dataset that a question needs from * the database. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_dataset_loader { /** @var int the id of the question we are helping. */ protected $questionid; /** @var int the id of the question we are helping. */ protected $itemsavailable = null; /** * Constructor * @param int $questionid the question to load datasets for. */ public function __construct($questionid) { $this->questionid = $questionid; } /** * Get the number of items (different values) in each dataset used by this * question. This is the minimum number of items in any dataset used by this * question. * @return int the number of items available. */ public function get_number_of_items() { global $DB; if (is_null($this->itemsavailable)) { $this->itemsavailable = $DB->get_field_sql(' SELECT MIN(qdd.itemcount) FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? ', array($this->questionid), MUST_EXIST); } return $this->itemsavailable; } /** * Actually query the database for the values. * @param int $itemnumber which set of values to load. * @return array name => value; */ protected function load_values($itemnumber) { global $DB; return $DB->get_records_sql_menu(' SELECT qdd.name, qdi.value FROM {question_dataset_items} qdi JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? AND qdi.itemnumber = ? ', array($this->questionid, $itemnumber)); } /** * Load a particular set of values for each dataset used by this question. * @param int $itemnumber which set of values to load. * 0 < $itemnumber <= {@link get_number_of_items()}. * @return array name => value. */ public function get_values($itemnumber) { if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) { $a = new stdClass(); $a->id = $this->questionid; $a->item = $itemnumber; throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a); } return $this->load_values($itemnumber); } public function datasets_are_synchronised($category) { global $DB; // We need to ensure that there are synchronised datasets, and that they // all use the right category. $categories = $DB->get_record_sql(' SELECT MAX(qdd.category) AS max, MIN(qdd.category) AS min FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? AND qdd.category <> 0 ', array($this->questionid)); return $categories && $categories->max == $category && $categories->min == $category; } } /** * This class holds the current values of all the variables used by a calculated * question. * * It can compute formulae using those values, and can substitute equations * embedded in text. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_variable_substituter { /** @var array variable name => value */ protected $values; /** @var string character to use for the decimal point in displayed numbers. */ protected $decimalpoint; /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */ protected $search; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $safevalue; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $prettyvalue; /** * Constructor * @param array $values variable name => value. */ public function __construct(array $values, $decimalpoint) { $this->values = $values; $this->decimalpoint = $decimalpoint; // Prepare an array for {@link substitute_values()}. $this->search = array(); foreach ($values as $name => $value) { if (!is_numeric($value)) { $a = new stdClass(); $a->name = '{' . $name . '}'; $a->value = $value; throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); } $this->search[] = '{' . $name . '}'; $this->safevalue[] = '(' . $value . ')'; $this->prettyvalue[] = $this->format_float($value); } } /** * Display a float properly formatted with a certain number of decimal places. * @param number $x the number to format * @param int $length restrict to this many decimal places or significant * figures. If null, the number is not rounded. * @param int format 1 => decimalformat, 2 => significantfigures. * @return string formtted number. */ public function format_float($x, $length = null, $format = null) { if (is_nan($x)) { $x = 'NAN'; } else if (is_infinite($x)) { $x = ($x < 0) ? '-INF' : 'INF'; } else if (!is_null($length) && !is_null($format)) { if ($format == '1' ) { // Answer is to have $length decimals. // Decimal places. $x = sprintf('%.' . $length . 'F', $x); } else if ($x) { // Significant figures does only apply if the result is non-zero. $answer = $x; // Convert to positive answer. if ($answer < 0) { $answer = -$answer; $sign = '-'; } else { $sign = ''; } // Determine the format 0.[1-9][0-9]* for the answer... $p10 = 0; while ($answer < 1) { --$p10; $answer *= 10; } while ($answer >= 1) { ++$p10; $answer /= 10; } // ... and have the answer rounded of to the correct length. $answer = round($answer, $length); // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. if ($answer >= 1) { ++$p10; $answer /= 10; } // Have the answer written on a suitable format. // Either scientific or plain numeric. if (-2 > $p10 || 4 < $p10) { // Use scientific format. $exponent = 'e'.--$p10; $answer *= 10; if (1 == $length) { $x = $sign.$answer.$exponent; } else { // Attach additional zeros at the end of $answer. $answer .= (1 == strlen($answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $x = $sign .substr($answer, 0, $length +1).$exponent; } } else { // Stick to plain numeric format. $answer *= "1e{$p10}"; if (0.1 <= $answer / "1e{$length}") { $x = $sign.$answer; } else { // Could be an idea to add some zeros here. $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $oklen = $length + ($p10 < 1 ? 2-$p10 : 1); $x = $sign.substr($answer, 0, $oklen); } } } else { $x = 0.0; } } return str_replace('.', $this->decimalpoint, $x); } /** * Return an array of the variables and their values. * @return array name => value. */ public function get_values() { return $this->values; } /** * Evaluate an expression using the variable values. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ public function calculate($expression) { // Make sure no malicious code is present in the expression. Refer MDL-46148 for details. if ($error = qtype_calculated_find_formula_errors($expression)) { throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error); } $expression = $this->substitute_values_for_eval($expression); if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) { // Some placeholders were not substituted. throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', '{' . reset($datasets) . '}'); } return $this->calculate_raw($expression); } /** * Evaluate an expression after the variable values have been substituted. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ protected function calculate_raw($expression) { try { // In older PHP versions this this is a way to validate code passed to eval. // The trick came from http://php.net/manual/en/function.eval.php. if (@eval('return true; $result = ' . $expression . ';')) { return eval('return ' . $expression . ';'); } } catch (Throwable $e) { // PHP7 and later now throws ParseException and friends from eval(), // which is much better. } // In either case of an invalid $expression, we end here. throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression); } /** * Substitute variable placehodlers like {a} with their value wrapped in (). * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_for_eval($expression) { return str_replace($this->search, $this->safevalue, $expression); } /** * Substitute variable placehodlers like {a} with their value without wrapping * the value in anything. * @param string $text some content with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_pretty($text) { return str_replace($this->search, $this->prettyvalue, $text); } /** * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) * in some text with the corresponding values. * @param string $text the text to process. * @return string the text with values substituted. */ public function replace_expressions_in_text($text, $length = null, $format = null) { $vs = $this; // Can't use $this in a PHP closure. $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX, function ($matches) use ($vs, $format, $length) { return $vs->format_float($vs->calculate($matches[1]), $length, $format); }, $text); return $this->substitute_values_pretty($text); } } PK\m3type/calculated/classes/qtype_calculated_answer.phpnu[. declare(strict_types=1); namespace qtype_calculated; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/numerical/question.php'); /** * Class to represent a calculated question answer. * * @package qtype_calculated * @copyright 2023 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_answer extends \qtype_numerical_answer { /** @var int The length of the correct answer. */ public $correctanswerlength; /** @var int The format of the correct answer. */ public $correctanswerformat; /** @var int The max tolerance of the correct answer. */ public $max; /** @var int The min tolerance of the correct answer. */ public $min; } PK\'Y,type/calculated/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qtype_calculated. * * @package qtype_calculated * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qtype_calculated\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qtype_calculated implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\"rtype/calculated/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This file describes API changes in /question/type/calculated/*. === 4.3 === * \qtype_calculated answers are now of the type \qtype_calculated\qtype_calculated_answer which extends \qtype_numerical_answer. Plugins extending \qtype_calculated and still reference \qtype_numerical_answer in their code (e.g. in test helper functions) should update their code to reference \qtype_calculated\qtype_calculated_answer instead. PK\!??+type/calculated/datasetdefinitions_form.phpnu[. /** * Defines the editing form for the calculated question data set definitions. * * @package qtype * @subpackage calculated * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); /** * Calculated question data set definitions editing form definition. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_dataset_dependent_definitions_form extends question_wizard_form { /** * Question object with options and answers already loaded by get_question_options * Be careful how you use this it is needed sometimes to set up the structure of the * form in definition_inner but data is always loaded into the form with set_defaults. * * @var object */ protected $question; /** * Reference to question type object * * @var question_dataset_dependent_questiontype */ protected $qtypeobj; /** * Add question-type specific form fields. * * @param MoodleQuickForm $mform the form being built. */ public function __construct($submiturl, $question) { // Validate the question category. if (!isset($question->categoryobject)) { throw new moodle_exception('categorydoesnotexist', 'question'); } $question->category = $question->categoryobject->id; $this->question = $question; $this->qtypeobj = question_bank::get_qtype($this->question->qtype); parent::__construct($submiturl); } protected function definition() { global $SESSION; $mform = $this->_form; $mform->setDisableShortforms(); $possibledatasets = $this->qtypeobj->find_dataset_names($this->question->questiontext); $mandatorydatasets = array(); if (isset($this->question->options->answers)) { foreach ($this->question->options->answers as $answer) { $mandatorydatasets += $this->qtypeobj->find_dataset_names($answer->answer); } } else { foreach ($SESSION->calculated->questionform->answers as $answer) { $mandatorydatasets += $this->qtypeobj->find_dataset_names($answer); } } $key = 0; $datadefscat= array(); $datadefscat = $this->qtypeobj->get_dataset_definitions_category($this->question); $datasetmenus = array(); $label = "
    ".get_string('datasetrole', 'qtype_calculated')."
    "; // Explaining the role of datasets so other strings can be shortened. $mform->addElement('html', $label); $mform->addElement('header', 'mandatoryhdr', get_string('mandatoryhdr', 'qtype_calculated')); $labelsharedwildcard = get_string('sharedwildcard', 'qtype_calculated'); foreach ($mandatorydatasets as $datasetname) { if (!isset($datasetmenus[$datasetname])) { list($options, $selected) = $this->qtypeobj->dataset_options($this->question, $datasetname); unset($options['0']); // Mandatory... $label = get_string('wildcard', 'qtype_calculated', $datasetname); $mform->addElement('select', "dataset[{$key}]", $label, $options); if (isset($datadefscat[$datasetname])) { $mform->addElement('static', "there is a category", get_string('sharedwildcard', 'qtype_calculated', $datasetname), get_string('dataitemdefined', 'qtype_calculated', $datadefscat[$datasetname])); } $mform->setDefault("dataset[{$key}]", $selected); $datasetmenus[$datasetname] = ''; $key++; } } $mform->addElement('header', 'possiblehdr', get_string('possiblehdr', 'qtype_calculated')); foreach ($possibledatasets as $datasetname) { if (!isset($datasetmenus[$datasetname])) { list($options, $selected) = $this->qtypeobj->dataset_options( $this->question, $datasetname, false); $label = get_string('wildcard', 'qtype_calculated', $datasetname); $mform->addElement('select', "dataset[{$key}]", $label, $options); if (isset($datadefscat[$datasetname])) { $mform->addElement('static', "there is a category", get_string('sharedwildcard', 'qtype_calculated', $datasetname), get_string('dataitemdefined', 'qtype_calculated', $datadefscat[$datasetname])); } $mform->setDefault("dataset[{$key}]", $selected); $datasetmenus[$datasetname] = ''; $key++; } } // Temporary strings. $mform->addElement('header', 'synchronizehdr', get_string('synchronize', 'qtype_calculated')); $mform->addElement('radio', 'synchronize', '', get_string('synchronizeno', 'qtype_calculated'), 0); $mform->addElement('radio', 'synchronize', '', get_string('synchronizeyes', 'qtype_calculated'), 1); $mform->addElement('radio', 'synchronize', '', get_string('synchronizeyesdisplay', 'qtype_calculated'), 2); if (isset($this->question->options) && isset($this->question->options->synchronize)) { $mform->setDefault('synchronize', $this->question->options->synchronize); } else { $mform->setDefault('synchronize', 0); } $this->add_action_buttons(false, get_string('nextpage', 'qtype_calculated')); $this->add_hidden_fields(); $mform->addElement('hidden', 'category'); $mform->setType('category', PARAM_SEQUENCE); $mform->addElement('hidden', 'wizard', 'datasetitems'); $mform->setType('wizard', PARAM_ALPHA); } public function validation($data, $files) { $errors = parent::validation($data, $files); $datasets = $data['dataset']; $countvalid = 0; foreach ($datasets as $key => $dataset) { if ($dataset != '0') { $countvalid++; } } if (!$countvalid) { foreach ($datasets as $key => $dataset) { $errors['dataset['.$key.']'] = get_string('atleastonerealdataset', 'qtype_calculated'); } } return $errors; } } PK\>Gdd%type/calculated/datasetitems_form.phpnu[. /** * Defines the editing form for the calculated question data set items. * * @package qtype * @subpackage calculated * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); /** * Calculated question data set items editing form definition. * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_dataset_dependent_items_form extends question_wizard_form { /** * Question object with options and answers already loaded by get_question_options * Be careful how you use this it is needed sometimes to set up the structure of the * form in definition_inner but data is always loaded into the form with set_defaults. * * @var object */ public $question; /** * Reference to question type object * * @var question_dataset_dependent_questiontype */ public $qtypeobj; /** @var stdClass the question category. */ protected $category; /** @var context the context of the question category. */ protected $categorycontext; public $datasetdefs; public $maxnumber = -1; public $regenerate; public $noofitems; public $outsidelimit = false; public $commentanswers = array(); /** * Add question-type specific form fields. * * @param MoodleQuickForm $mform the form being built. */ public function __construct($submiturl, $question, $regenerate) { global $SESSION; // Validate the question category. if (!isset($question->categoryobject)) { throw new moodle_exception('categorydoesnotexist', 'question'); } $question->category = $question->categoryobject->id; $this->category = $question->categoryobject; $this->categorycontext = context::instance_by_id($this->category->contextid); $this->regenerate = $regenerate; $this->question = $question; $this->qtypeobj = question_bank::get_qtype($this->question->qtype); // Get the dataset defintions for this question. if (empty($question->id)) { $this->datasetdefs = $this->qtypeobj->get_dataset_definitions( $question->id, $SESSION->calculated->definitionform->dataset); } else { if (empty($question->options)) { $this->get_question_options($question); } $this->datasetdefs = $this->qtypeobj->get_dataset_definitions( $question->id, array()); } foreach ($this->datasetdefs as $datasetdef) { // Get maxnumber. if ($this->maxnumber == -1 || $datasetdef->itemcount < $this->maxnumber) { $this->maxnumber = $datasetdef->itemcount; } } foreach ($this->datasetdefs as $defid => $datasetdef) { if (isset($datasetdef->id)) { $this->datasetdefs[$defid]->items = $this->qtypeobj->get_database_dataset_items($datasetdef->id); } } parent::__construct($submiturl); } protected function definition() { global $PAGE; $labelsharedwildcard = get_string("sharedwildcard", "qtype_calculated"); $mform = $this->_form; $mform->setDisableShortforms(); $strquestionlabel = $this->qtypeobj->comment_header($this->question); if ($this->maxnumber != -1 ) { $this->noofitems = $this->maxnumber; } else { $this->noofitems = 0; } $label = get_string("sharedwildcards", "qtype_calculated"); $html2 = $this->qtypeobj->print_dataset_definitions_category_shared( $this->question, $this->datasetdefs); $mform->addElement('static', 'listcategory', $label, $html2); // ...----------------------------------------------------------------------. $mform->addElement('submit', 'updatedatasets', get_string('updatedatasetparam', 'qtype_calculated')); $mform->registerNoSubmitButton('updatedatasets'); $mform->addElement('header', 'additemhdr', get_string('itemtoadd', 'qtype_calculated')); $idx = 1; $data = array(); $j = (($this->noofitems) * count($this->datasetdefs))+1; foreach ($this->datasetdefs as $defkey => $datasetdef) { if ($datasetdef->category |= 0 ) { $name = get_string('sharedwildcard', 'qtype_calculated', $datasetdef->name); } else { $name = get_string('wildcard', 'qtype_calculated', $datasetdef->name); } $mform->addElement('float', "number[{$j}]", $name); $this->qtypeobj->custom_generator_tools_part($mform, $idx, $j); $idx++; $mform->addElement('hidden', "definition[{$j}]"); $mform->setType("definition[{$j}]", PARAM_RAW); $mform->addElement('hidden', "itemid[{$j}]"); $mform->setType("itemid[{$j}]", PARAM_RAW); $mform->addElement('static', "divider[{$j}]", '', '
    '); $mform->setType("divider[{$j}]", PARAM_RAW); $j++; } $mform->addElement('header', 'updateanswershdr', get_string('answerstoleranceparam', 'qtype_calculated')); $mform->addElement('submit', 'updateanswers', get_string('updatetolerancesparam', 'qtype_calculated')); $mform->setAdvanced('updateanswers', true); $mform->registerNoSubmitButton('updateanswers'); $answers = fullclone($this->question->options->answers); $key1 =1; foreach ($answers as $key => $answer) { $ans = shorten_text($answer->answer, 17, true); if ($ans === '*') { $mform->addElement('static', 'answercomment[' . ($this->noofitems+$key1) . ']', $ans); $mform->addElement('hidden', 'tolerance['.$key.']', ''); $mform->setType('tolerance['.$key.']', PARAM_FLOAT); // No need to worry about localisation, as the value of this field will not be shown to users anymore. $mform->setAdvanced('tolerance['.$key.']', true); $mform->addElement('hidden', 'tolerancetype['.$key.']', ''); $mform->setType('tolerancetype['.$key.']', PARAM_RAW); $mform->setAdvanced('tolerancetype['.$key.']', true); $mform->addElement('hidden', 'correctanswerlength['.$key.']', ''); $mform->setType('correctanswerlength['.$key.']', PARAM_RAW); $mform->setAdvanced('correctanswerlength['.$key.']', true); $mform->addElement('hidden', 'correctanswerformat['.$key.']', ''); $mform->setType('correctanswerformat['.$key.']', PARAM_RAW); $mform->setAdvanced('correctanswerformat['.$key.']', true); } else if ( $ans !== '' ) { $mform->addElement('static', 'answercomment[' . ($this->noofitems+$key1) . ']', $ans); $mform->addElement('float', 'tolerance['.$key.']', get_string('tolerance', 'qtype_calculated')); $mform->setAdvanced('tolerance['.$key.']', true); $mform->addElement('select', 'tolerancetype['.$key.']', get_string('tolerancetype', 'qtype_numerical'), $this->qtypeobj->tolerance_types()); $mform->setAdvanced('tolerancetype['.$key.']', true); $mform->addElement('select', 'correctanswerlength['.$key.']', get_string('correctanswershows', 'qtype_calculated'), range(0, 9)); $mform->setAdvanced('correctanswerlength['.$key.']', true); $answerlengthformats = array( '1' => get_string('decimalformat', 'qtype_numerical'), '2' => get_string('significantfiguresformat', 'qtype_calculated') ); $mform->addElement('select', 'correctanswerformat['.$key.']', get_string('correctanswershowsformat', 'qtype_calculated'), $answerlengthformats); $mform->setAdvanced('correctanswerformat['.$key.']', true); $mform->addElement('static', 'dividertolerance', '', '
    '); $mform->setAdvanced('dividertolerance', true); } $key1++; } $addremoveoptions = array(); $addremoveoptions['1']='1'; for ($i=10; $i<=100; $i+=10) { $addremoveoptions["{$i}"] = "{$i}"; } $showoptions = Array(); $showoptions['1']='1'; $showoptions['2']='2'; $showoptions['5']='5'; for ($i=10; $i<=100; $i+=10) { $showoptions["{$i}"] = "{$i}"; } $mform->addElement('header', 'addhdr', get_string('add', 'moodle')); $mform->closeHeaderBefore('addhdr'); if ($this->qtypeobj->supports_dataset_item_generation()) { $radiogrp = array(); $radiogrp[] =& $mform->createElement('radio', 'nextpageparam[forceregeneration]', null, get_string('reuseifpossible', 'qtype_calculated'), 0); $radiogrp[] =& $mform->createElement('radio', 'nextpageparam[forceregeneration]', null, get_string('forceregenerationshared', 'qtype_calculated'), 1); $radiogrp[] =& $mform->createElement('radio', 'nextpageparam[forceregeneration]', null, get_string('forceregenerationall', 'qtype_calculated'), 2); $mform->addGroup($radiogrp, 'forceregenerationgrp', get_string('nextitemtoadd', 'qtype_calculated'), "
    ", false); } $mform->addElement('submit', 'getnextbutton', get_string('getnextnow', 'qtype_calculated')); $mform->addElement('static', "dividera", '', '
    '); $addgrp = array(); $addgrp[] =& $mform->createElement('submit', 'addbutton', get_string('add', 'moodle')); $addgrp[] =& $mform->createElement('select', "selectadd", get_string('additem', 'qtype_calculated'), $addremoveoptions); $addgrp[] = & $mform->createElement('static', "stat", "Items", get_string('newsetwildcardvalues', 'qtype_calculatedsimple')); $mform->addGroup($addgrp, 'addgrp', get_string('additem', 'qtype_calculated'), ' ', false); $mform->addElement('static', "divideradd", '', ''); if ($this->noofitems > 0) { $mform->addElement('header', 'deleteitemhdr', get_string('delete', 'moodle')); $deletegrp = array(); $deletegrp[] = $mform->createElement('submit', 'deletebutton', get_string('delete', 'moodle')); $deletegrp[] = $mform->createElement('select', 'selectdelete', get_string('deleteitem', 'qtype_calculated')."1", $addremoveoptions); $deletegrp[] = $mform->createElement('static', "stat", "Items", get_string('setwildcardvalues', 'qtype_calculatedsimple')); $mform->addGroup($deletegrp, 'deletegrp', '', ' ', false); } else { $mform->addElement('static', 'warning', '', '' . get_string('youmustaddatleastoneitem', 'qtype_calculated').''); } $addgrp1 = array(); $addgrp1[] = $mform->createElement('submit', 'showbutton', get_string('showitems', 'qtype_calculated')); $addgrp1[] = $mform->createElement('select', "selectshow", '' , $showoptions); $addgrp1[] = $mform->createElement('static', "stat", '', get_string('setwildcardvalues', 'qtype_calculated')); $mform->addGroup($addgrp1, 'addgrp1', '', ' ', false); $mform->registerNoSubmitButton('showbutton'); $mform->closeHeaderBefore('addgrp1'); // ...----------------------------------------------------------------------. $j = $this->noofitems * count($this->datasetdefs); $k = optional_param('selectshow', 1, PARAM_INT); for ($i = $this->noofitems; $i >= 1; $i--) { if ($k > 0) { $mform->addElement('header', 'setnoheader' . $i, "" . get_string('setno', 'qtype_calculated', $i)."  "); } foreach ($this->datasetdefs as $defkey => $datasetdef) { if ($k > 0) { if ($datasetdef->category == 0 ) { $mform->addElement('float', "number[{$j}]", get_string('wildcard', 'qtype_calculated', $datasetdef->name)); } else { $mform->addElement('float', "number[{$j}]", get_string( 'sharedwildcard', 'qtype_calculated', $datasetdef->name)); } } else { $mform->addElement('hidden', "number[{$j}]" , ''); $mform->setType("number[{$j}]", PARAM_LOCALISEDFLOAT); // Localisation handling has to be done manually. } $mform->addElement('hidden', "itemid[{$j}]"); $mform->setType("itemid[{$j}]", PARAM_INT); $mform->addElement('hidden', "definition[{$j}]"); $mform->setType("definition[{$j}]", PARAM_NOTAGS); $data[$datasetdef->name] =$datasetdef->items[$i]->value; $j--; } if ('' != $strquestionlabel && ($k > 0 )) { // ... $this->outsidelimit || !empty($this->numbererrors ). $repeated[] = $mform->addElement('static', "answercomment[{$i}]", $strquestionlabel); // Decode equations in question text. $qtext = $this->qtypeobj->substitute_variables( $this->question->questiontext, $data); $textequations = $this->qtypeobj->find_formulas($qtext); if ($textequations != '' && count($textequations) > 0 ) { $mform->addElement('static', "divider1[{$j}]", '', 'Formulas {=..} in question text'); foreach ($textequations as $key => $equation) { if ($formulaerrors = qtype_calculated_find_formula_errors($equation)) { $str = $formulaerrors; } else { eval('$str = '.$equation.';'); } $equation = shorten_text($equation, 17, true); $mform->addElement('static', "textequation", "{={$equation}}", "=".$str); } } } $k--; } $mform->addElement('static', 'outsidelimit', '', ''); // Submit buttons. if ($this->noofitems > 0) { $buttonarray = []; $buttonarray[] = $mform->createElement( 'submit', 'savechanges', get_string('savechanges')); if (\core\plugininfo\qbank::is_plugin_enabled('qbank_previewquestion')) { $previewlink = $PAGE->get_renderer('qbank_previewquestion')->question_preview_link($this->question->id, $this->categorycontext, true); } $buttonarray[] = $mform->createElement('static', 'previewlink', '', $previewlink); $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false); $mform->closeHeaderBefore('buttonar'); } $this->add_hidden_fields(); $mform->addElement('hidden', 'category'); $mform->setType('category', PARAM_SEQUENCE); $mform->addElement('hidden', 'wizard', 'datasetitems'); $mform->setType('wizard', PARAM_ALPHA); } public function set_data($question) { $formdata = array(); $fromform = new stdClass(); if (isset($question->options)) { $answers = $question->options->answers; if (count($answers)) { if (optional_param('updateanswers', false, PARAM_BOOL) || optional_param('updatedatasets', false, PARAM_BOOL)) { foreach ($answers as $key => $answer) { $fromform->tolerance[$key]= $this->_form->getElementValue( 'tolerance['.$key.']'); $answer->tolerance = $fromform->tolerance[$key]; $fromform->tolerancetype[$key]= $this->_form->getElementValue( 'tolerancetype['.$key.']'); if (is_array($fromform->tolerancetype[$key])) { $fromform->tolerancetype[$key]= $fromform->tolerancetype[$key][0]; } $answer->tolerancetype = $fromform->tolerancetype[$key]; $fromform->correctanswerlength[$key]= $this->_form->getElementValue( 'correctanswerlength['.$key.']'); if (is_array($fromform->correctanswerlength[$key])) { $fromform->correctanswerlength[$key] = $fromform->correctanswerlength[$key][0]; } $answer->correctanswerlength = $fromform->correctanswerlength[$key]; $fromform->correctanswerformat[$key] = $this->_form->getElementValue( 'correctanswerformat['.$key.']'); if (is_array($fromform->correctanswerformat[$key])) { $fromform->correctanswerformat[$key] = $fromform->correctanswerformat[$key][0]; } $answer->correctanswerformat = $fromform->correctanswerformat[$key]; } $this->qtypeobj->save_question_calculated($question, $fromform); } else { foreach ($answers as $key => $answer) { $formdata['tolerance['.$key.']'] = $answer->tolerance; $formdata['tolerancetype['.$key.']'] = $answer->tolerancetype; $formdata['correctanswerlength['.$key.']'] = $answer->correctanswerlength; $formdata['correctanswerformat['.$key.']'] = $answer->correctanswerformat; } } } } // Fill out all data sets and also the fields for the next item to add. $j = $this->noofitems * count($this->datasetdefs); for ($itemnumber = $this->noofitems; $itemnumber >= 1; $itemnumber--) { $data = array(); foreach ($this->datasetdefs as $defid => $datasetdef) { if (isset($datasetdef->items[$itemnumber])) { $value = $datasetdef->items[$itemnumber]->value; if ($this->_form->getElementType("number[{$j}]") == 'hidden') { // Some of the number elements are from the float type and some are from the hidden type. // We need to manually handle localised floats for hidden elements. $value = format_float($value, -1); } $formdata["number[{$j}]"] = $value; $formdata["definition[{$j}]"] = $defid; $formdata["itemid[{$j}]"] = $datasetdef->items[$itemnumber]->id; $data[$datasetdef->name] = $datasetdef->items[$itemnumber]->value; } $j--; } $comment = $this->qtypeobj->comment_on_datasetitems($this->qtypeobj, $question->id, $question->questiontext, $answers, $data, $itemnumber); if ($comment->outsidelimit) { $this->outsidelimit=$comment->outsidelimit; } $totalcomment=''; foreach ($question->options->answers as $key => $answer) { $totalcomment .= $comment->stranswers[$key].'
    '; } $formdata['answercomment['.$itemnumber.']'] = $totalcomment; } $formdata['nextpageparam[forceregeneration]'] = $this->regenerate; $formdata['selectdelete'] = '1'; $formdata['selectadd'] = '1'; $j = $this->noofitems * count($this->datasetdefs)+1; $data = array(); // Data for comment_on_datasetitems later. // Dataset generation defaults. if ($this->qtypeobj->supports_dataset_item_generation()) { $itemnumber = $this->noofitems+1; foreach ($this->datasetdefs as $defid => $datasetdef) { if (!optional_param('updatedatasets', false, PARAM_BOOL) && !optional_param('updateanswers', false, PARAM_BOOL)) { $value = $this->qtypeobj->generate_dataset_item($datasetdef->options); } else { $value = $this->_form->getElementValue("number[{$j}]"); } if ($this->_form->getElementType("number[{$j}]") == 'hidden') { // Some of the number elements are from the float type and some are from the hidden type. // We need to manually handle localised floats for hidden elements. $value = format_float($value, -1); } $formdata["number[{$j}]"] = $value; $formdata["definition[{$j}]"] = $defid; $formdata["itemid[{$j}]"] = isset($datasetdef->items[$itemnumber]) ? $datasetdef->items[$itemnumber]->id : 0; $data[$datasetdef->name] = $formdata["number[{$j}]"]; $j++; } } // Existing records override generated data depending on radio element. $j = $this->noofitems * count($this->datasetdefs) + 1; if (!$this->regenerate && !optional_param('updatedatasets', false, PARAM_BOOL) && !optional_param('updateanswers', false, PARAM_BOOL)) { $itemnumber = $this->noofitems + 1; foreach ($this->datasetdefs as $defid => $datasetdef) { if (isset($datasetdef->items[$itemnumber])) { $formdata["number[{$j}]"] = $datasetdef->items[$itemnumber]->value; $formdata["definition[{$j}]"] = $defid; $formdata["itemid[{$j}]"] = $datasetdef->items[$itemnumber]->id; $data[$datasetdef->name] = $datasetdef->items[$itemnumber]->value; } $j++; } } $comment = $this->qtypeobj->comment_on_datasetitems($this->qtypeobj, $question->id, $question->questiontext, $answers, $data, ($this->noofitems + 1)); if (isset($comment->outsidelimit) && $comment->outsidelimit) { $this->outsidelimit=$comment->outsidelimit; } $key1 = 1; foreach ($question->options->answers as $key => $answer) { $formdata['answercomment['.($this->noofitems+$key1).']'] = $comment->stranswers[$key]; $key1++; } if ($this->outsidelimit) { $formdata['outsidelimit']= '' . get_string('oneanswertrueansweroutsidelimits', 'qtype_calculated') . ''; } $formdata = $this->qtypeobj->custom_generator_set_data($this->datasetdefs, $formdata); parent::set_data((object)($formdata + (array)$question)); } public function validation($data, $files) { $errors = array(); if (isset($data['savechanges']) && ($this->noofitems==0) ) { $errors['warning'] = get_string('warning', 'mnet'); } if ($this->outsidelimit) { $errors['outsidelimits'] = get_string('oneanswertrueansweroutsidelimits', 'qtype_calculated'); } $numbers = $data['number']; foreach ($numbers as $key => $number) { if (! is_numeric($number)) { if (stristr($number, ',')) { $errors['number['.$key.']'] = get_string('nocommaallowed', 'qtype_calculated'); } else { $errors['number['.$key.']'] = get_string('notvalidnumber', 'qtype_calculated'); } } else if (stristr($number, 'x')) { $a = new stdClass(); $a->name = ''; $a->value = $number; $errors['number['.$key.']'] = get_string('hexanotallowed', 'qtype_calculated', $a); } else if (is_nan($number)) { $errors['number['.$key.']'] = get_string('notvalidnumber', 'qtype_calculated'); } } return $errors; } } PK\[,,'type/calculated/tests/question_test.phpnu[. namespace qtype_calculated; use qtype_calculated; use question_attempt_step; use question_bank; use question_classified_response; use question_display_options; use question_state; use qtype_numerical; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for qtype_calculated_definition. * * @package qtype_calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_test extends \advanced_testcase { /** * Test is complete response * * @covers \qtype_calculated */ public function test_is_complete_response(): void { $question = \test_question_maker::make_question('calculated'); $this->assertFalse($question->is_complete_response([])); $this->assertTrue($question->is_complete_response(['answer' => '0'])); $this->assertTrue($question->is_complete_response(['answer' => 0])); $this->assertFalse($question->is_complete_response(['answer' => 'test'])); } /** * Test is gradable response * * @covers \qtype_calculated */ public function test_is_gradable_response(): void { $question = \test_question_maker::make_question('calculated'); $this->assertFalse($question->is_gradable_response([])); $this->assertTrue($question->is_gradable_response(['answer' => '0'])); $this->assertTrue($question->is_gradable_response(['answer' => 0])); $this->assertTrue($question->is_gradable_response(['answer' => 'test'])); } /** * Test grading * * @covers \qtype_calculated */ public function test_grading(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals([0, question_state::$gradedwrong], $question->grade_response(['answer' => $values['a'] - $values['b']])); $this->assertEquals([1, question_state::$gradedright], $question->grade_response(['answer' => $values['a'] + $values['b']])); } /** * Test get correct response * * @covers \qtype_calculated */ public function test_get_correct_response(): void { // Testing with 3.0 + 0.1416. $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 3); $values = $question->vs->get_values(); $this->assertSame(['answer' => '3.01' ], $question->get_correct_response()); foreach ($question->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 2; } $this->assertSame(['answer' => '3.0' ], $question->get_correct_response()); // Testing with 1.0 + 5.0. $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertSame(['answer' => '6.00' ], $question->get_correct_response()); foreach ($question->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 2; } $this->assertSame(['answer' => '6.0' ], $question->get_correct_response()); // Testing with 31.0 + 0.01416 . $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 4); $values = $question->vs->get_values(); $this->assertSame(['answer' => '31.01' ], $question->get_correct_response()); foreach ($question->answers as $answer) { $answer->correctanswerlength = 3; $answer->correctanswerformat = 2; } $this->assertSame(['answer' => '31.0' ], $question->get_correct_response()); } /** * Test get question summary * * @covers \qtype_calculated */ public function test_get_question_summary(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $qsummary = $question->get_question_summary(); $this->assertEquals('What is ' . $values['a'] . ' + ' . $values['b'] . '?', $qsummary); } /** * Test summarise response * * @covers \qtype_calculated */ public function test_summarise_response(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals('3.1', $question->summarise_response(['answer' => '3.1'])); } /** * Test classify response * * @covers \qtype_calculated */ public function test_classify_response(): void { $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals([ new question_classified_response(13, $values['a'] + $values['b'], 1.0)], $question->classify_response(['answer' => $values['a'] + $values['b']])); $this->assertEquals([ new question_classified_response(14, $values['a'] - $values['b'], 0.0)], $question->classify_response(['answer' => $values['a'] - $values['b']])); $this->assertEquals([ new question_classified_response(17, 7 * $values['a'], 0.0)], $question->classify_response(['answer' => 7 * $values['a']])); $this->assertEquals([ question_classified_response::no_response()], $question->classify_response(['answer' => ''])); } /** * Test classify response no star * * @covers \qtype_calculated */ public function test_classify_response_no_star(): void { $question = \test_question_maker::make_question('calculated'); unset($question->answers[17]); $question->start_attempt(new question_attempt_step(), 1); $values = $question->vs->get_values(); $this->assertEquals([ new question_classified_response(13, $values['a'] + $values['b'], 1.0)], $question->classify_response(['answer' => $values['a'] + $values['b']])); $this->assertEquals([ new question_classified_response(14, $values['a'] - $values['b'], 0.0)], $question->classify_response(['answer' => $values['a'] - $values['b']])); $this->assertEquals([ new question_classified_response(0, 7 * $values['a'], 0.0)], $question->classify_response(['answer' => 7 * $values['a']])); $this->assertEquals([ question_classified_response::no_response()], $question->classify_response(['answer' => ''])); } /** * Test get variants selection seed q not synchronised * * @covers \qtype_calculated */ public function test_get_variants_selection_seed_q_not_synchronised(): void { $question = \test_question_maker::make_question('calculated'); $this->assertEquals($question->stamp, $question->get_variants_selection_seed()); } /** * Test get variants selection seed q synchronised datasets not * * @covers \qtype_calculated */ public function test_get_variants_selection_seed_q_synchronised_datasets_not(): void { $question = \test_question_maker::make_question('calculated'); $question->synchronised = true; $this->assertEquals($question->stamp, $question->get_variants_selection_seed()); } /** * Test get variants selection seed q synchronised * * @covers \qtype_calculated */ public function test_get_variants_selection_seed_q_synchronised(): void { $question = \test_question_maker::make_question('calculated'); $question->synchronised = true; $question->datasetloader->set_are_synchronised($question->category, true); $this->assertEquals('category' . $question->category, $question->get_variants_selection_seed()); } /** * Test get question definition for external rendering * * @covers \qtype_calculated */ public function test_get_question_definition_for_external_rendering(): void { $this->resetAfterTest(); $question = \test_question_maker::make_question('calculated'); $question->start_attempt(new question_attempt_step(), 1); $qa = \test_question_maker::get_a_qa($question); $displayoptions = new question_display_options(); $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions); $this->assertNotEmpty($options); $this->assertEquals(0, $options['unitgradingtype']); $this->assertEquals(0, $options['unitpenalty']); $this->assertEquals(qtype_numerical::UNITNONE, $options['unitdisplay']); $this->assertEmpty($options['unitsleft']); } /** * Test that the grading of negative responses does not show false ERROR. * * @return void * @throws \coding_exception * @throws \dml_exception * @covers \qtype_calculated */ public function test_grading_of_negative_responses(): void { global $DB; $this->resetAfterTest(); // Create a question. $q = \test_question_maker::get_question_data('calculated', 'mult'); $q->id = 99; // Add units for the question. The issue to test only applies if the answer contains a unit string. $units = []; $unit = new \stdClass(); $unit->question = $q->id; $unit->multiplier = 1.0; $unit->unit = "cm"; $units[] = $unit; $DB->insert_records("question_numerical_units", $units); $qtype = new qtype_calculated(); $qtypeobj = question_bank::get_qtype($qtype->name()); $fakedata = ["a" => "5.7", "b" => "3.3"]; $result = $qtype->comment_on_datasetitems($qtypeobj, $q->id, $q->questiontext, $q->options->answers, $fakedata, 1); // Make sure "ERROR" is not part of the answers. foreach ($result->stranswers as $answer) { $this->assertFalse(strstr($answer, "ERROR"), "Assert that 'ERROR' is not part of the answer!"); } } } PK\w..,type/calculated/tests/question_type_test.phpnu[. namespace qtype_calculated; use qtype_calculated; use qtype_numerical; use question_bank; use question_possible_response; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); require_once($CFG->dirroot . '/question/type/calculated/tests/helper.php'); /** * Unit tests for question/type/calculated/questiontype.php. * * @package qtype_calculated * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @covers \question_type * @covers \qtype_calculated */ final class question_type_test extends \advanced_testcase { protected $tolerance = 0.00000001; protected $qtype; protected function setUp(): void { parent::setUp(); $this->qtype = new qtype_calculated(); } protected function tearDown(): void { $this->qtype = null; parent::tearDown(); } public function test_name(): void { $this->assertEquals($this->qtype->name(), 'calculated'); } public function test_can_analyse_responses(): void { $this->assertTrue($this->qtype->can_analyse_responses()); } public function test_get_random_guess_score(): void { $q = \test_question_maker::get_question_data('calculated'); $q->options->answers[17]->fraction = 0.1; $this->assertEquals(0.1, $this->qtype->get_random_guess_score($q)); } public function test_load_question(): void { $this->resetAfterTest(); $syscontext = \context_system::instance(); /** @var core_question_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $generator->create_question_category(['contextid' => $syscontext->id]); $fromform = \test_question_maker::get_question_form_data('calculated'); $fromform->category = $category->id . ',' . $syscontext->id; $question = new \stdClass(); $question->category = $category->id; $question->qtype = 'calculated'; $question->createdby = 0; $this->qtype->save_question($question, $fromform); $questiondata = question_bank::load_question_data($question->id); $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); $this->assertEquals(0, $questiondata->parent); $this->assertEquals($fromform->name, $questiondata->name); $this->assertEquals($fromform->questiontext, $questiondata->questiontext); $this->assertEquals($fromform->questiontextformat, $questiondata->questiontextformat); $this->assertEquals('', $questiondata->generalfeedback); $this->assertEquals(0, $questiondata->generalfeedbackformat); $this->assertEquals($fromform->defaultmark, $questiondata->defaultmark); $this->assertEquals(0, $questiondata->penalty); $this->assertEquals('calculated', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); $this->assertEquals($category->contextid, $questiondata->contextid); $this->assertCount(1, $questiondata->hints); $hint = array_pop($questiondata->hints); $this->assertEquals('Add', $hint->hint); $this->assertEquals(FORMAT_HTML, $hint->hintformat); // Options. $this->assertEquals($questiondata->id, $questiondata->options->question); $this->assertCount(1, $questiondata->options->units); $unit = array_pop($questiondata->options->units); $this->assertEquals($unit->unit, 'x'); $this->assertEquals($unit->multiplier, '1.0'); $this->assertEquals(qtype_numerical::UNITOPTIONAL, $questiondata->options->showunits); $this->assertEquals(0, $questiondata->options->unitgradingtype); // Unit role is none, so this is 0. $this->assertEquals($fromform->unitpenalty, $questiondata->options->unitpenalty); $this->assertEquals($fromform->unitsleft, $questiondata->options->unitsleft); // Build the expected answer base. $answerbase = [ 'question' => $questiondata->id, 'answerformat' => 0, ]; $expectedanswers = []; foreach ($fromform->answer as $key => $value) { $answer = $answerbase + [ 'answer' => $fromform->answer[$key], 'fraction' => (float)$fromform->fraction[$key], 'tolerance' => $fromform->tolerance[$key], 'tolerancetype' => $fromform->tolerancetype[$key], 'correctanswerlength' => $fromform->correctanswerlength[$key], 'correctanswerformat' => $fromform->correctanswerformat[$key], 'feedback' => $fromform->feedback[$key]['text'], 'feedbackformat' => $fromform->feedback[$key]['format'], ]; $expectedanswers[] = (object)$answer; } // Need to get rid of ids. $gotanswers = array_map(function($answer) { unset($answer->id); return $answer; }, $questiondata->options->answers); // Compare answers. $this->assertEquals($expectedanswers, array_values($gotanswers)); } protected function get_possible_response($ans, $tolerance, $type) { $a = new \stdClass(); $a->answer = $ans; $a->tolerance = $tolerance; $a->tolerancetype = get_string($type, 'qtype_numerical'); return get_string('answerwithtolerance', 'qtype_calculated', $a); } public function test_get_possible_responses(): void { $q = \test_question_maker::get_question_data('calculated'); $this->assertEquals(array( $q->id => array( 13 => new question_possible_response( $this->get_possible_response('{a} + {b}', 0.001, 'nominal'), 1.0), 14 => new question_possible_response( $this->get_possible_response('{a} - {b}', 0.001, 'nominal'), 0.0), 17 => new question_possible_response('*', 0.0), null => question_possible_response::no_response() ), ), $this->qtype->get_possible_responses($q)); } public function test_get_possible_responses_no_star(): void { $q = \test_question_maker::get_question_data('calculated'); unset($q->options->answers[17]); $this->assertEquals(array( $q->id => array( 13 => new question_possible_response( $this->get_possible_response('{a} + {b}', 0.001, 'nominal'), 1), 14 => new question_possible_response( $this->get_possible_response('{a} - {b}', 0.001, 'nominal'), 0), 0 => new question_possible_response( get_string('didnotmatchanyanswer', 'question'), 0), null => question_possible_response::no_response() ), ), $this->qtype->get_possible_responses($q)); } public function test_get_short_question_name(): void { $this->resetAfterTest(); // Enable multilang filter to on content and heading. filter_set_global_state('multilang', TEXTFILTER_ON); filter_set_applies_to_strings('multilang', 1); $filtermanager = \filter_manager::instance(); $filtermanager->reset_caches(); $context = \context_system::instance(); $longmultilangquestionname = "Lorem ipsum dolor sit amet, consetetur sadipscing elitrLorem ipsum dolor sit amet, consetetur sadipscing elitr"; $shortmultilangquestionname = "Lorem ipsumLorem ipsum"; $longquestionname = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr"; $shortquestionname = "Lorem ipsum"; $this->assertEquals("Lorem ipsum dolor...", $this->qtype->get_short_question_name($longmultilangquestionname, 20)); $this->assertEquals("Lorem ipsum", $this->qtype->get_short_question_name($shortmultilangquestionname, 20)); $this->assertEquals("Lorem ipsum dolor...", $this->qtype->get_short_question_name($longquestionname, 20)); $this->assertEquals("Lorem ipsum", $this->qtype->get_short_question_name($shortquestionname, 20)); } public function test_placehodler_regex(): void { preg_match_all(qtype_calculated::PLACEHODLER_REGEX, '= {={a} + {b}}', $matches); $this->assertEquals([['{a}', '{b}'], ['a', 'b']], $matches); } public function test_formulas_in_text_regex(): void { preg_match_all(qtype_calculated::FORMULAS_IN_TEXT_REGEX, '= {={a} + {b}}', $matches); $this->assertEquals([['{={a} + {b}}'], ['{a} + {b}']], $matches); } public function test_find_dataset_names(): void { $this->assertEquals([], $this->qtype->find_dataset_names('Frog.')); $this->assertEquals(['a' => 'a', 'b' => 'b'], $this->qtype->find_dataset_names('= {={a} + {b}}')); $this->assertEquals(['a' => 'a', 'b' => 'b'], $this->qtype->find_dataset_names('What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)')); $this->assertEquals(['a' => 'a', 'b' => 'b', 'c' => 'c'], $this->qtype->find_dataset_names('

    If called with $a = {a} and $b = {b}, what does this PHP function return?

                            /**
                             * What does this do?
                             */
                            function mystery($a, $b) {
                                return {c}*$a + $b;
                            }
                            
    ')); } public function test_calculate_answer_nan_inf(): void { $answer = qtype_calculated_calculate_answer('acos(1.1)', [], 0.1, 1, 2, 2); $this->assertIsObject($answer); $this->assertNan($answer->answer); $answer = qtype_calculated_calculate_answer('log(0.0)', [], 0.1, 1, 2, 2); $this->assertIsObject($answer); $this->assertInfinite($answer->answer); // Actually -INF. // Dividing by zero is hard to test, so get +INF another way. $answer = qtype_calculated_calculate_answer('abs(log(0.0))', [], 0.1, 1, 2, 2); $this->assertIsObject($answer); $this->assertInfinite($answer->answer); } } PK\&&*type/calculated/tests/walkthrough_test.phpnu[. namespace qtype_calculated; use question_hint; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Unit tests for the calculated question type. * * @package qtype_calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_interactive(): void { // Create a gapselect question. $q = \test_question_maker::make_question('calculated'); $q->hints = array( new question_hint(1, 'This is the first hint.', FORMAT_HTML), new question_hint(2, 'This is the second hint.', FORMAT_HTML), ); $this->start_attempt_at_question($q, 'interactive', 3, 1); $values = $q->vs->get_values(); $this->assertEquals($values, $q->datasetloader->load_values(1)); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Submit blank. $this->process_submission(array('-submit' => 1, 'answer' => '')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Sumit something that does not look like a number. $this->process_submission(array('-submit' => 1, 'answer' => 'newt')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), new \question_pattern_expectation('/' . preg_quote(get_string('invalidnumber', 'qtype_numerical'), '/') . '/'), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'answer' => $values['a'] + $values['b'])); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mark_summary(3), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_no_hint_visible_expectation()); } } PK\P^1type/calculated/tests/formula_validation_test.phpnu[. namespace qtype_calculated; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); /** * Unit tests for formula validation code. * * @package qtype_calculated * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class formula_validation_test extends \basic_testcase { protected function assert_nonempty_string($actual) { $this->assertIsString($actual); $this->assertNotEquals('', $actual); } public function test_simple_equations_ok(): void { $this->assertFalse(qtype_calculated_find_formula_errors(1)); $this->assertFalse(qtype_calculated_find_formula_errors('1 + 1')); $this->assertFalse(qtype_calculated_find_formula_errors('{x} + {y}')); $this->assertFalse(qtype_calculated_find_formula_errors('{x}*{y}')); $this->assertFalse(qtype_calculated_find_formula_errors('{x}*({y}+1)')); } public function test_simple_equations_errors(): void { $this->assert_nonempty_string(qtype_calculated_find_formula_errors('{a{b}}')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('{a{b}}')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('{a}({b})')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('2({b})')); } public function test_safe_functions_ok(): void { $this->assertFalse(qtype_calculated_find_formula_errors('abs(-1)')); $this->assertFalse(qtype_calculated_find_formula_errors('tan(pi())')); $this->assertFalse(qtype_calculated_find_formula_errors('log(10)')); $this->assertFalse(qtype_calculated_find_formula_errors('log(64, 2)')); $this->assertFalse(qtype_calculated_find_formula_errors('atan2(1.0, 1.0)')); $this->assertFalse(qtype_calculated_find_formula_errors('max(1.0, 1.0)')); $this->assertFalse(qtype_calculated_find_formula_errors('max(1.0, 1.0, 2.0)')); $this->assertFalse(qtype_calculated_find_formula_errors('max(1.0, 1.0, 2, 3)')); } public function test_php_comments_blocked(): void { $this->assert_nonempty_string(qtype_calculated_find_formula_errors('# No need for this.')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('/* Also blocked. */')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('1 + 1 /* Blocked too. */')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('// As is this.')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('1/*2')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('/*{a*///{x}}')); } public function test_dangerous_functions_blocked(): void { $this->assert_nonempty_string(qtype_calculated_find_formula_errors('eval(1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('system(1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('base64_decode(1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('unserialize(1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('cos(tan(1) + abs(cos(eval)) * pi())')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('eval (CONSTANTREADASSTRING)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors("eval \t ()")); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('"eval"()')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('?>assert_nonempty_string(qtype_calculated_find_formula_errors('?>assert_nonempty_string(qtype_calculated_find_formula_errors('abs(-1, 1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('abs()')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('pi(1)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('log()')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('log(64, 2, 3)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('atan2(1.0)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('atan2(1.0, 1.0, 2.0)')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors('max(1.0)')); } public function test_validation_of_formulas_in_text_ok(): void { $this->assertFalse(qtype_calculated_find_formula_errors_in_text( '

    Look no equations.

    ')); $this->assertFalse(qtype_calculated_find_formula_errors_in_text( '

    Simple variable: {x}.

    ')); $this->assertFalse(qtype_calculated_find_formula_errors_in_text( '

    This is an equation: {=1+1}, as is this: {={x}+{y}}.

    ' . '

    Here is a more complex one: {=sin(2*pi()*{theta})}.

    ')); } public function test_validation_of_formulas_in_text_bad_function(): void { $this->assert_nonempty_string(qtype_calculated_find_formula_errors_in_text( '

    This is an equation: {=eval(1)}.

    ')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors_in_text( '

    Good: {=1+1}, bad: {=eval(1)}, good: {={x}+{y}}.

    ')); $this->assert_nonempty_string(qtype_calculated_find_formula_errors_in_text( '

    Bad: {=eval(1)}, bad: {=system(1)}.

    ')); } } PK\uX55 type/calculated/tests/helper.phpnu[. /** * Test helpers for the calculated question type. * * @package qtype * @subpackage calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculated/question.php'); require_once($CFG->dirroot . '/question/type/numerical/question.php'); require_once($CFG->dirroot . '/question/type/numerical/questiontype.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Test helper class for the calculated question type. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_test_helper extends question_test_helper { public function get_test_questions() { return ['sum', 'mult']; } /** * Makes a calculated question about summing two numbers. * @return qtype_calculated_question */ public function make_calculated_question_sum() { question_bank::load_question_definition_classes('calculated'); $q = new qtype_calculated_question(); test_question_maker::initialise_a_question($q); $q->name = 'Simple sum'; $q->questiontext = 'What is {a} + {b}?'; $q->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $q->answers = [ 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} + {b}', 1.0, 'Very good.', FORMAT_HTML, 0), 14 => new \qtype_calculated\qtype_calculated_answer(14, '{a} - {b}', 0.0, 'Add. not subtract!.', FORMAT_HTML, 0), 17 => new \qtype_calculated\qtype_calculated_answer(17, '*', 0.0, 'Completely wrong.', FORMAT_HTML, 0), ]; foreach ($q->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 1; } $q->qtype = question_bank::get_qtype('calculated'); $q->unitdisplay = qtype_numerical::UNITNONE; $q->unitgradingtype = 0; $q->unitpenalty = 0; $q->ap = new qtype_numerical_answer_processor([]); $q->synchronised = false; $q->datasetloader = new qtype_calculated_test_dataset_loader(0, [ ['a' => 1, 'b' => 5], ['a' => 3, 'b' => 4], ['a' => 3, 'b' => 0.01416], ['a' => 31, 'b' => 0.01416], ]); return $q; } /** * Makes a calculated question about summing two numbers. * @return qtype_calculated_question */ public function get_calculated_question_data_sum() { question_bank::load_question_definition_classes('calculated'); $qdata = new stdClass(); test_question_maker::initialise_question_data($qdata); $qdata->qtype = 'calculated'; $qdata->name = 'Simple sum'; $qdata->questiontext = 'What is {a} + {b}?'; $qdata->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->unitgradingtype = 0; $qdata->options->unitpenalty = 0.0; $qdata->options->showunits = qtype_numerical::UNITNONE; $qdata->options->unitsleft = 0; $qdata->options->synchronize = 0; $qdata->options->answers = [ 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} + {b}', 1.0, 'Very good.', FORMAT_HTML, 0.001), 14 => new \qtype_calculated\qtype_calculated_answer(14, '{a} - {b}', 0.0, 'Add. not subtract!.', FORMAT_HTML, 0.001), 17 => new \qtype_calculated\qtype_calculated_answer(17, '*', 0.0, 'Completely wrong.', FORMAT_HTML, 0), ]; foreach ($qdata->options->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 1; } $qdata->options->units = []; return $qdata; } /** * Makes a calculated question about summing two numbers. * @return qtype_calculated_question */ public function get_calculated_question_form_data_sum() { question_bank::load_question_definition_classes('calculated'); $fromform = new stdClass(); $fromform->name = 'Simple sum'; $fromform->questiontext = 'What is {a} + {b}?'; $fromform->defaultmark = 1.0; $fromform->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; $fromform->unitrole = '0'; $fromform->unitpenalty = 0.1; $fromform->unitgradingtypes = '1'; $fromform->unitsleft = '0'; $fromform->nounits = 1; $fromform->multiplier = []; $fromform->multiplier[0] = '1.0'; $fromform->synchronize = 0; $fromform->answernumbering = 0; $fromform->shuffleanswers = 0; $fromform->noanswers = 6; $fromform->answer = []; $fromform->answer[0] = '{a} + {b}'; $fromform->answer[1] = '{a} - {b}'; $fromform->answer[2] = '*'; $fromform->fraction = []; $fromform->fraction[0] = '1.0'; $fromform->fraction[1] = '0.0'; $fromform->fraction[2] = '0.0'; $fromform->tolerance = []; $fromform->tolerance[0] = 0.001; $fromform->tolerance[1] = 0.001; $fromform->tolerance[2] = 0; $fromform->tolerancetype[0] = 1; $fromform->tolerancetype[1] = 1; $fromform->tolerancetype[2] = 1; $fromform->correctanswerlength[0] = 2; $fromform->correctanswerlength[1] = 2; $fromform->correctanswerlength[2] = 2; $fromform->correctanswerformat[0] = 1; $fromform->correctanswerformat[1] = 1; $fromform->correctanswerformat[2] = 1; $fromform->feedback = []; $fromform->feedback[0] = []; $fromform->feedback[0]['format'] = FORMAT_HTML; $fromform->feedback[0]['text'] = 'Very good.'; $fromform->feedback[1] = []; $fromform->feedback[1]['format'] = FORMAT_HTML; $fromform->feedback[1]['text'] = 'Add. not subtract!'; $fromform->feedback[2] = []; $fromform->feedback[2]['format'] = FORMAT_HTML; $fromform->feedback[2]['text'] = 'Completely wrong.'; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $fromform->hint = [ [ 'text' => 'Add', 'format' => FORMAT_HTML, ], ]; $fromform->unit = [ 'x', ]; $fromform->multiplier = [ '1.0', ]; return $fromform; } /** * Makes a calculated question about multiplying two numbers. * * @return qtype_calculated_question * @throws coding_exception */ public function make_calculated_question_mult() { question_bank::load_question_definition_classes('calculated'); $q = new qtype_calculated_question(); test_question_maker::initialise_a_question($q); $q->name = 'Simple multiplication'; $q->questiontext = 'What is {a} * {b}?'; $q->generalfeedback = 'Generalfeedback: {={a} * {b}} is the right answer.'; $q->answers = [ 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} * {b}', 1.0, 'Positive.', FORMAT_HTML, 0), 14 => new \qtype_calculated\qtype_calculated_answer(14, '-{a} * {b}', 0.0, 'Negative.', FORMAT_HTML, 0), ]; foreach ($q->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 1; } $q->qtype = question_bank::get_qtype('calculated'); $q->unitdisplay = qtype_numerical::UNITOPTIONAL; $q->unitgradingtype = 0; $q->unitpenalty = 0; $q->unit = 'cm'; $q->ap = new qtype_numerical_answer_processor([]); $q->synchronised = false; $q->datasetloader = new qtype_calculated_test_dataset_loader(0, [ ['a' => 1, 'b' => 5], ['a' => 3, 'b' => 4], ['a' => 3, 'b' => 0.01416], ['a' => 31, 'b' => 0.01416], ]); return $q; } /** * Makes a calculated question about multiplying two numbers. * * @return qtype_calculated_question * @throws coding_exception */ public function get_calculated_question_data_mult() { question_bank::load_question_definition_classes('calculated'); $qdata = new stdClass(); test_question_maker::initialise_question_data($qdata); $qdata->qtype = 'calculated'; $qdata->name = 'Simple multiplication'; $qdata->questiontext = 'What is {a} * {b}?'; $qdata->generalfeedback = 'Generalfeedback: {={a} * {b}} is the right answer.'; $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->unitgradingtype = 0; $qdata->options->unitpenalty = 0.0; $qdata->options->showunits = qtype_numerical::UNITOPTIONAL; $qdata->unit = 'cm'; $qdata->options->unitsleft = 0; $qdata->options->synchronize = 0; $qdata->options->answers = [ 13 => new \qtype_calculated\qtype_calculated_answer(13, '{a} * {b}', 1.0, 'Positive.', FORMAT_HTML, 0.001), 14 => new \qtype_calculated\qtype_calculated_answer(14, '-{a} * {b}', 0.0, 'Negative.', FORMAT_HTML, 0.001), ]; foreach ($qdata->options->answers as $answer) { $answer->correctanswerlength = 2; $answer->correctanswerformat = 1; } $qdata->options->units = []; return $qdata; } /** * Makes a calculated question about multiplying two numbers. * * @return qtype_calculated_question * @throws coding_exception */ public function get_calculated_question_form_data_mult() { question_bank::load_question_definition_classes('calculated'); $fromform = new stdClass(); $fromform->name = 'Simple multiplication'; $fromform->questiontext = 'What is {a} * {b}?'; $fromform->defaultmark = 1.0; $fromform->generalfeedback = 'Generalfeedback: {={a} * {b}} is the right answer.'; $fromform->unitrole = '0'; $fromform->unitpenalty = 0.1; $fromform->unitgradingtypes = '1'; $fromform->unitsleft = '0'; $fromform->nounits = 1; $fromform->multiplier = []; $fromform->multiplier[0] = '1.0'; $fromform->synchronize = 0; $fromform->answernumbering = 0; $fromform->shuffleanswers = 0; $fromform->noanswers = 6; $fromform->answer = []; $fromform->answer[0] = '{a} * {b}'; $fromform->answer[1] = '-{a} * {b}'; $fromform->fraction = []; $fromform->fraction[0] = '1.0'; $fromform->fraction[1] = '0.0'; $fromform->tolerance = []; $fromform->tolerance[0] = 0.001; $fromform->tolerance[1] = 0.001; $fromform->tolerancetype[0] = 1; $fromform->tolerancetype[1] = 1; $fromform->correctanswerlength[0] = 2; $fromform->correctanswerlength[1] = 2; $fromform->correctanswerformat[0] = 1; $fromform->correctanswerformat[1] = 1; $fromform->feedback = []; $fromform->feedback[0] = []; $fromform->feedback[0]['format'] = FORMAT_HTML; $fromform->feedback[0]['text'] = 'Positive.'; $fromform->feedback[1] = []; $fromform->feedback[1]['format'] = FORMAT_HTML; $fromform->feedback[1]['text'] = 'Negative.'; $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } } /** * Test implementation of {@link qtype_calculated_dataset_loader}. Gets the values * from an array passed to the constructor, rather than querying the database. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_test_dataset_loader extends qtype_calculated_dataset_loader { protected $valuesets; protected $aresynchronised = []; public function __construct($questionid, array $valuesets) { parent::__construct($questionid); $this->valuesets = $valuesets; } public function get_number_of_items() { return count($this->valuesets); } public function load_values($itemnumber) { return $this->valuesets[$itemnumber - 1]; } public function datasets_are_synchronised($category) { return !empty($this->aresynchronised[$category]); } /** * Allows the test to mock the return value of {@link datasets_are_synchronised()}. * @param int $category * @param bool $aresychronised */ public function set_are_synchronised($category, $aresychronised) { $this->aresynchronised[$category] = $aresychronised; } } PK\IXX2type/calculated/tests/variablesubstituter_test.phpnu[. namespace qtype_calculated; use qtype_calculated_variable_substituter; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/type/calculated/question.php'); require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); /** * Unit tests for {@link qtype_calculated_variable_substituter}. * * @package qtype_calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class variablesubstituter_test extends \advanced_testcase { public function test_simple_expression(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->assertEquals(3, $vs->calculate('{a} + {b}')); } public function test_simple_expression_negatives(): void { $vs = new qtype_calculated_variable_substituter(array('a' => -1, 'b' => -2), '.'); $this->assertEquals(1, $vs->calculate('{a}-{b}')); } public function test_cannot_use_nonnumbers(): void { $this->expectException(\moodle_exception::class); $vs = new qtype_calculated_variable_substituter(array('a' => 'frog', 'b' => -2), '.'); } public function test_invalid_expression(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->expectException(\moodle_exception::class); $vs->calculate('{a} + {b}?'); } public function test_tricky_invalid_expression(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->expectException(\moodle_exception::class); $vs->calculate('{a}{b}'); // Have to make sure this does not just evaluate to 12. } public function test_division_by_zero_expression(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 0), '.'); $this->expectException(\moodle_exception::class); $vs->calculate('{a} / {b}'); } public function test_replace_expressions_in_text_simple_var(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->assertEquals('1 + 2', $vs->replace_expressions_in_text('{a} + {b}')); } public function test_replace_expressions_in_confusing_text(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->assertEquals("(1) 1\n(2) 2", $vs->replace_expressions_in_text("(1) {a}\n(2) {b}")); } public function test_replace_expressions_in_text_formula(): void { $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $this->assertEquals('= 3', $vs->replace_expressions_in_text('= {={a} + {b}}')); } public function test_expression_has_unmapped_placeholder(): void { $this->expectException('moodle_exception'); $this->expectExceptionMessage(get_string('illegalformulasyntax', 'qtype_calculated', '{c}')); $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.'); $vs->calculate('{c} - {a} + {b}'); } public function test_replace_expressions_in_text_negative(): void { $vs = new qtype_calculated_variable_substituter(array('a' => -1, 'b' => 2), '.'); $this->assertEquals('temperatures -1 and 2', $vs->replace_expressions_in_text('temperatures {a} and {b}')); } public function test_replace_expressions_in_text_commas_for_decimals(): void { $vs = new qtype_calculated_variable_substituter( array('phi' => 1.61803399, 'pi' => 3.14159265), ','); $this->assertEquals('phi (1,61803399) + pi (3,14159265) = 4,75962664', $vs->replace_expressions_in_text('phi ({phi}) + pi ({pi}) = {={phi} + {pi}}')); } public function test_format_float_dot(): void { $vs = new qtype_calculated_variable_substituter(array('a' => -1, 'b' => 2), '.'); $this->assertSame('0.12345', $vs->format_float(0.12345)); $this->assertSame('0', $vs->format_float(0.12345, 0, 1)); $this->assertSame('0.12', $vs->format_float(0.12345, 2, 1)); $this->assertSame('0.1235', $vs->format_float(0.12345, 4, 1)); $this->assertSame('0.12', $vs->format_float(0.12345, 2, 2)); $this->assertSame('0.0012', $vs->format_float(0.0012345, 4, 1)); } public function test_format_float_comma(): void { $vs = new qtype_calculated_variable_substituter(array('a' => -1, 'b' => 2), ','); $this->assertSame('0,12345', $vs->format_float(0.12345)); $this->assertSame('0', $vs->format_float(0.12345, 0, 1)); $this->assertSame('0,12', $vs->format_float(0.12345, 2, 1)); $this->assertSame('0,1235', $vs->format_float(0.12345, 4, 1)); $this->assertSame('0,12', $vs->format_float(0.12345, 2, 2)); $this->assertSame('0,0012', $vs->format_float(0.0012345, 4, 1)); } public function test_format_float_nan_inf(): void { $vs = new qtype_calculated_variable_substituter([ ], '.'); $this->assertSame('NAN', $vs->format_float(NAN)); $this->assertSame('INF', $vs->format_float(INF)); $this->assertSame('-INF', $vs->format_float(-INF)); } } PK\04cc7type/calculated/tests/upgrade_old_attempt_data_test.phpnu[. namespace qtype_calculated; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); /** * Testing the upgrade of calculated question attempts. * * @package qtype_calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class upgrade_old_attempt_data_test extends \question_attempt_upgrader_test_base { public function test_calculated_adaptive_qsession97(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '13', 'uniqueid' => '13', 'quiz' => '4', 'userid' => '4', 'attempt' => '1', 'sumgrades' => '0.00000', 'timestart' => '1305830650', 'timefinish' => '1305830656', 'timemodified' => '1305830656', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '18', 'category' => '2', 'parent' => '0', 'name' => 'Calculated', 'questiontext' => '

    What is {a} m + {b} m?

    _______________

    Remember to type a unit.

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculated', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184316+ELvZeg', 'version' => 'tjh238.vledev2.open.ac.uk+110519184317+exx1Bm', 'hidden' => '0', 'timecreated' => '1305830596', 'timemodified' => '1305830596', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '2', 'question' => '18', 'synchronize' => '0', 'single' => '0', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '0', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '0', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '0', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 28 => (object) array( 'id' => '28', 'question' => '18', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( 0 => (object) array( 'id' => '9', 'question' => '18', 'multiplier' => 1, 'unit' => 'm', ), ), 'unitgradingtype' => '1', 'unitpenalty' => '0.5000000', 'showunits' => '0', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '97', 'attemptid' => '13', 'questionid' => '18', 'newest' => '258', 'newgraded' => '258', 'sumpenalty' => '0.1000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 255 => (object) array( 'id' => '255', 'attempt' => '13', 'question' => '18', 'seq_number' => '0', 'answer' => 'dataset10-|||||', 'timestamp' => '1305830650', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 258 => (object) array( 'id' => '258', 'attempt' => '13', 'question' => '18', 'seq_number' => '1', 'answer' => 'dataset10-|||||', 'timestamp' => '1305830650', 'event' => '6', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 10, array('a' => '7.5', 'b' => '4.9')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 18, 'variant' => 10, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 7.5 m + 4.9 m? _______________ Remember to type a unit.', 'rightanswer' => '12.4 m', 'responsesummary' => '', 'timemodified' => 1305830650, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('_separators' => '.$,', '_var_a' => '7.5', '_var_b' => '4.9'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'gradedwrong', 'fraction' => null, 'timecreated' => 1305830650, 'userid' => 4, 'data' => array('answer' => '', '-finish' => 1, '-_try' => 1, '-_rawfraction' => 0), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculated_adaptive_qsession100(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '14', 'uniqueid' => '14', 'quiz' => '4', 'userid' => '4', 'attempt' => '2', 'sumgrades' => '2.80000', 'timestart' => '1305830661', 'timefinish' => '1305830729', 'timemodified' => '1305830729', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '18', 'category' => '2', 'parent' => '0', 'name' => 'Calculated', 'questiontext' => '

    What is {a} m + {b} m?

    _______________

    Remember to type a unit.

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculated', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184316+ELvZeg', 'version' => 'tjh238.vledev2.open.ac.uk+110519184317+exx1Bm', 'hidden' => '0', 'timecreated' => '1305830596', 'timemodified' => '1305830596', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '2', 'question' => '18', 'synchronize' => '0', 'single' => '0', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '0', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '0', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '0', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 28 => (object) array( 'id' => '28', 'question' => '18', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( 0 => (object) array( 'id' => '9', 'question' => '18', 'multiplier' => 1, 'unit' => 'm', ), ), 'unitgradingtype' => '1', 'unitpenalty' => '0.5000000', 'showunits' => '0', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '100', 'attemptid' => '14', 'questionid' => '18', 'newest' => '269', 'newgraded' => '269', 'sumpenalty' => '0.3000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 261 => (object) array( 'id' => '261', 'attempt' => '14', 'question' => '18', 'seq_number' => '0', 'answer' => 'dataset11-|||||', 'timestamp' => '1305830661', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 265 => (object) array( 'id' => '265', 'attempt' => '14', 'question' => '18', 'seq_number' => '1', 'answer' => 'dataset11-9.6|||||', 'timestamp' => '1305830714', 'event' => '3', 'grade' => '0.5000000', 'raw_grade' => '0.5000000', 'penalty' => '0.1000000', ), 266 => (object) array( 'id' => '266', 'attempt' => '14', 'question' => '18', 'seq_number' => '2', 'answer' => 'dataset11-9.6|||||m', 'timestamp' => '1305830722', 'event' => '3', 'grade' => '0.9000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), 269 => (object) array( 'id' => '269', 'attempt' => '14', 'question' => '18', 'seq_number' => '3', 'answer' => 'dataset11-9.6|||||m', 'timestamp' => '1305830722', 'event' => '6', 'grade' => '0.9000000', 'raw_grade' => '1.0000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 11, array('a' => '5.1', 'b' => '4.5')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 18, 'variant' => 11, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 5.1 m + 4.5 m? _______________ Remember to type a unit.', 'rightanswer' => '9.6 m', 'responsesummary' => '9.6 m', 'timemodified' => 1305830722, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830661, 'userid' => 4, 'data' => array('_separators' => '.$,', '_var_a' => '5.1', '_var_b' => '4.5'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'todo', 'fraction' => 0.5, 'timecreated' => 1305830714, 'userid' => 4, 'data' => array('answer' => 9.6, '-_try' => 1, '-_rawfraction' => 0.5, '-submit' => 1), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'todo', 'fraction' => 0.9, 'timecreated' => 1305830722, 'userid' => 4, 'data' => array('answer' => '9.6 m', '-_try' => 2, '-_rawfraction' => 1, '-submit' => 1), ), 3 => (object) array( 'sequencenumber' => 3, 'state' => 'gradedright', 'fraction' => 0.9, 'timecreated' => 1305830722, 'userid' => 4, 'data' => array('answer' => '9.6 m', '-_try' => 2, '-_rawfraction' => 1, '-finish' => 1), ), ), ); $this->compare_qas($expectedqa, $qa); } public function test_calculated_adaptive_qsession103(): void { $quiz = (object) array( 'id' => '4', 'course' => '2', 'name' => 'Calculated quiz', 'intro' => '', 'introformat' => '1', 'timeopen' => '0', 'timeclose' => '0', 'attempts' => '0', 'attemptonlast' => '0', 'grademethod' => '1', 'decimalpoints' => '2', 'questiondecimalpoints' => '-1', 'questionsperpage' => '1', 'shufflequestions' => '0', 'shuffleanswers' => '1', 'sumgrades' => '3.00000', 'grade' => '10.00000', 'timecreated' => '0', 'timemodified' => '1305648351', 'timelimit' => '0', 'password' => '', 'subnet' => '', 'popup' => '0', 'delay1' => '0', 'delay2' => '0', 'showuserpicture' => '0', 'showblocks' => '0', 'preferredbehaviour' => 'adaptive', 'reviewattempt' => '69888', 'reviewcorrectness' => '69888', 'reviewmarks' => '69888', 'reviewspecificfeedback' => '69888', 'reviewgeneralfeedback' => '69888', 'reviewrightanswer' => '69888', 'reviewoverallfeedback' => '4352', ); $attempt = (object) array( 'id' => '15', 'uniqueid' => '15', 'quiz' => '4', 'userid' => '3', 'attempt' => '1', 'sumgrades' => '0.70000', 'timestart' => '1305830744', 'timefinish' => '0', 'timemodified' => '1305830792', 'layout' => '16,0,17,0,18,0', 'preview' => '0', ); $question = (object) array( 'id' => '18', 'category' => '2', 'parent' => '0', 'name' => 'Calculated', 'questiontext' => '

    What is {a} m + {b} m?

    _______________

    Remember to type a unit.

    ', 'questiontextformat' => '1', 'generalfeedback' => '', 'generalfeedbackformat' => '1', 'defaultmark' => '1.0000000', 'penalty' => '0.1', 'qtype' => 'calculated', 'length' => '1', 'stamp' => 'tjh238.vledev2.open.ac.uk+110519184316+ELvZeg', 'version' => 'tjh238.vledev2.open.ac.uk+110519184317+exx1Bm', 'hidden' => '0', 'timecreated' => '1305830596', 'timemodified' => '1305830596', 'createdby' => '2', 'modifiedby' => '2', 'maxmark' => '1.0000000', 'options' => (object) array( 'id' => '2', 'question' => '18', 'synchronize' => '0', 'single' => '0', 'shuffleanswers' => '1', 'correctfeedback' => '', 'correctfeedbackformat' => '0', 'partiallycorrectfeedback' => '', 'partiallycorrectfeedbackformat' => '0', 'incorrectfeedback' => '', 'incorrectfeedbackformat' => '0', 'answernumbering' => 'abc', 'shownumcorrect' => '0', 'answers' => array( 28 => (object) array( 'id' => '28', 'question' => '18', 'answer' => '{a} + {b}', 'answerformat' => '0', 'fraction' => '1.0000000', 'feedback' => '', 'feedbackformat' => '1', 'tolerance' => '0.01', 'tolerancetype' => '1', 'correctanswerlength' => '2', 'correctanswerformat' => '1', ), ), 'units' => array( 0 => (object) array( 'id' => '9', 'question' => '18', 'multiplier' => 1, 'unit' => 'm', ), ), 'unitgradingtype' => '1', 'unitpenalty' => '0.5000000', 'showunits' => '0', 'unitsleft' => '0', ), 'hints' => array( ), ); $qsession = (object) array( 'id' => '103', 'attemptid' => '15', 'questionid' => '18', 'newest' => '280', 'newgraded' => '279', 'sumpenalty' => '0.1000000', 'manualcomment' => '', 'manualcommentformat' => '1', 'flagged' => '0', ); $qstates = array( 272 => (object) array( 'id' => '272', 'attempt' => '15', 'question' => '18', 'seq_number' => '0', 'answer' => 'dataset1-|||||', 'timestamp' => '1305830744', 'event' => '0', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.0000000', ), 279 => (object) array( 'id' => '279', 'attempt' => '15', 'question' => '18', 'seq_number' => '1', 'answer' => 'dataset1-123|||||cm', 'timestamp' => '1305830775', 'event' => '3', 'grade' => '0.0000000', 'raw_grade' => '0.0000000', 'penalty' => '0.1000000', ), 280 => (object) array( 'id' => '280', 'attempt' => '15', 'question' => '18', 'seq_number' => '2', 'answer' => 'dataset1-12.4|||||', 'timestamp' => '1305830787', 'event' => '2', 'grade' => '0.0000000', 'raw_grade' => '0.5000000', 'penalty' => '0.1000000', ), ); $this->loader->put_dataset_in_cache($question->id, 1, array('a' => '9.9', 'b' => '2.5')); $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); $expectedqa = (object) array( 'behaviour' => 'adaptive', 'questionid' => 18, 'variant' => 1, 'maxmark' => 1.0000000, 'minfraction' => 0, 'maxfraction' => 1, 'flagged' => 0, 'questionsummary' => 'What is 9.9 m + 2.5 m? _______________ Remember to type a unit.', 'rightanswer' => '12.4 m', 'responsesummary' => '12.4', 'timemodified' => 1305830787, 'steps' => array( 0 => (object) array( 'sequencenumber' => 0, 'state' => 'todo', 'fraction' => null, 'timecreated' => 1305830744, 'userid' => 3, 'data' => array('_separators' => '.$,', '_var_a' => '9.9', '_var_b' => '2.5'), ), 1 => (object) array( 'sequencenumber' => 1, 'state' => 'todo', 'fraction' => 0, 'timecreated' => 1305830775, 'userid' => 3, 'data' => array('answer' => '123 cm', '-_try' => 1, '-_rawfraction' => 0, '-submit' => 1), ), 2 => (object) array( 'sequencenumber' => 2, 'state' => 'complete', 'fraction' => 0, 'timecreated' => 1305830787, 'userid' => 3, 'data' => array('answer' => '12.4'), ), ), ); $this->compare_qas($expectedqa, $qa); } } PK\Ζ]] type/calculated/questiontype.phpnu[. /** * Question type class for the calculated question type. * * @package qtype * @subpackage calculated * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questiontypebase.php'); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/numerical/question.php'); /** * The calculated question type. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated extends question_type { /** * @var string a placeholder is a letter, followed by zero or more alphanum chars (as well as space, - and _ for readability). */ const PLACEHOLDER_REGEX_PART = '[[:alpha:]][[:alpha:][:digit:]\-_\s]*'; /** * @var string REGEXP for a placeholder, wrapped in its {...} delimiters, with capturing brackets around the name. */ const PLACEHODLER_REGEX = '~\{(' . self::PLACEHOLDER_REGEX_PART . ')\}~'; /** * @var string Regular expression that finds the formulas in content, with capturing brackets to get the forumlas. */ const FORMULAS_IN_TEXT_REGEX = '~\{=([^{}]*(?:\{' . self::PLACEHOLDER_REGEX_PART . '\}[^{}]*)*)\}~'; const MAX_DATASET_ITEMS = 100; public $wizardpagesnumber = 3; public function get_question_options($question) { // First get the datasets and default options. // The code is used for calculated, calculatedsimple and calculatedmulti qtypes. global $CFG, $DB, $OUTPUT; parent::get_question_options($question); if (!$question->options = $DB->get_record('question_calculated_options', ['question' => $question->id])) { $question->options = new stdClass(); $question->options->synchronize = 0; $question->options->single = 0; $question->options->answernumbering = 'abc'; $question->options->shuffleanswers = 0; $question->options->correctfeedback = ''; $question->options->partiallycorrectfeedback = ''; $question->options->incorrectfeedback = ''; $question->options->correctfeedbackformat = 0; $question->options->partiallycorrectfeedbackformat = 0; $question->options->incorrectfeedbackformat = 0; } if (!$question->options->answers = $DB->get_records_sql(" SELECT a.*, c.tolerance, c.tolerancetype, c.correctanswerlength, c.correctanswerformat FROM {question_answers} a, {question_calculated} c WHERE a.question = ? AND a.id = c.answer ORDER BY a.id ASC", [$question->id])) { return false; } if ($this->get_virtual_qtype()->name() == 'numerical') { $this->get_virtual_qtype()->get_numerical_units($question); $this->get_virtual_qtype()->get_numerical_options($question); } $question->hints = $DB->get_records('question_hints', ['questionid' => $question->id], 'id ASC'); if (isset($question->export_process)&&$question->export_process) { $question->options->datasets = $this->get_datasets_for_export($question); } return true; } public function get_datasets_for_export($question) { global $DB, $CFG; $datasetdefs = []; if (!empty($question->id)) { $sql = "SELECT i.* FROM {question_datasets} d, {question_dataset_definitions} i WHERE d.question = ? AND d.datasetdefinition = i.id"; if ($records = $DB->get_records_sql($sql, [$question->id])) { foreach ($records as $r) { $def = $r; if ($def->category == '0') { $def->status = 'private'; } else { $def->status = 'shared'; } $def->type = 'calculated'; list($distribution, $min, $max, $dec) = explode(':', $def->options, 4); $def->distribution = $distribution; $def->minimum = $min; $def->maximum = $max; $def->decimals = $dec; if ($def->itemcount > 0) { // Get the datasetitems. $def->items = []; if ($items = $this->get_database_dataset_items($def->id)) { $n = 0; foreach ($items as $ii) { $n++; $def->items[$n] = new stdClass(); $def->items[$n]->itemnumber = $ii->itemnumber; $def->items[$n]->value = $ii->value; } $def->number_of_items = $n; } } $datasetdefs["1-{$r->category}-{$r->name}"] = $def; } } } return $datasetdefs; } public function save_question_options($question) { global $CFG, $DB; // Make it impossible to save bad formulas anywhere. $this->validate_question_data($question); // The code is used for calculated, calculatedsimple and calculatedmulti qtypes. $context = $question->context; // Calculated options. $update = true; $options = $DB->get_record('question_calculated_options', ['question' => $question->id]); if (!$options) { $update = false; $options = new stdClass(); $options->question = $question->id; } // As used only by calculated. if (isset($question->synchronize)) { $options->synchronize = $question->synchronize; } else { $options->synchronize = 0; } $options->single = 0; $options->answernumbering = $question->answernumbering; $options->shuffleanswers = $question->shuffleanswers; foreach (['correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'] as $feedbackname) { $options->$feedbackname = ''; $feedbackformat = $feedbackname . 'format'; $options->$feedbackformat = 0; } if ($update) { $DB->update_record('question_calculated_options', $options); } else { $DB->insert_record('question_calculated_options', $options); } // Get old versions of the objects. $oldanswers = $DB->get_records('question_answers', ['question' => $question->id], 'id ASC'); $oldoptions = $DB->get_records('question_calculated', ['question' => $question->id], 'answer ASC'); // Save the units. $virtualqtype = $this->get_virtual_qtype(); $result = $virtualqtype->save_units($question); if (isset($result->error)) { return $result; } else { $units = $result->units; } foreach ($question->answer as $key => $answerdata) { if (trim($answerdata) == '') { continue; } // Update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->id = $DB->insert_record('question_answers', $answer); } $answer->answer = trim($answerdata); $answer->fraction = $question->fraction[$key]; $answer->feedback = $this->import_or_save_files($question->feedback[$key], $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedback[$key]['format']; $DB->update_record("question_answers", $answer); // Set up the options object. if (!$options = array_shift($oldoptions)) { $options = new stdClass(); } $options->question = $question->id; $options->answer = $answer->id; $options->tolerance = trim($question->tolerance[$key]); $options->tolerancetype = trim($question->tolerancetype[$key]); $options->correctanswerlength = trim($question->correctanswerlength[$key]); $options->correctanswerformat = trim($question->correctanswerformat[$key]); // Save options. if (isset($options->id)) { // Reusing existing record. $DB->update_record('question_calculated', $options); } else { // New options. $DB->insert_record('question_calculated', $options); } } // Delete old answer records. if (!empty($oldanswers)) { foreach ($oldanswers as $oa) { $DB->delete_records('question_answers', ['id' => $oa->id]); } } // Delete old answer records. if (!empty($oldoptions)) { foreach ($oldoptions as $oo) { $DB->delete_records('question_calculated', ['id' => $oo->id]); } } $result = $virtualqtype->save_unit_options($question); if (isset($result->error)) { return $result; } $this->save_hints($question); if (isset($question->import_process)&&$question->import_process) { $this->import_datasets($question); } // Report any problems. if (!empty($result->notice)) { return $result; } return true; } public function import_datasets($question) { global $DB; $n = count($question->dataset); foreach ($question->dataset as $dataset) { // Name, type, option. $datasetdef = new stdClass(); $datasetdef->name = $dataset->name; $datasetdef->type = 1; $datasetdef->options = $dataset->distribution . ':' . $dataset->min . ':' . $dataset->max . ':' . $dataset->length; $datasetdef->itemcount = $dataset->itemcount; if ($dataset->status == 'private') { $datasetdef->category = 0; $todo = 'create'; } else if ($dataset->status == 'shared') { if ($sharedatasetdefs = $DB->get_records_select( 'question_dataset_definitions', "type = '1' AND " . $DB->sql_equal('name', '?') . " AND category = ? ORDER BY id DESC ", [$dataset->name, $question->category] )) { // So there is at least one. $sharedatasetdef = array_shift($sharedatasetdefs); if ($sharedatasetdef->options == $datasetdef->options) {// Identical so use it. $todo = 'useit'; $datasetdef = $sharedatasetdef; } else { // Different so create a private one. $datasetdef->category = 0; $todo = 'create'; } } else { // No so create one. $datasetdef->category = $question->category; $todo = 'create'; } } if ($todo == 'create') { $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); } // Create relation to the dataset. $questiondataset = new stdClass(); $questiondataset->question = $question->id; $questiondataset->datasetdefinition = $datasetdef->id; $DB->insert_record('question_datasets', $questiondataset); if ($todo == 'create') { // Add the items. foreach ($dataset->datasetitem as $dataitem) { $datasetitem = new stdClass(); $datasetitem->definition = $datasetdef->id; $datasetitem->itemnumber = $dataitem->itemnumber; $datasetitem->value = $dataitem->value; $DB->insert_record('question_dataset_items', $datasetitem); } } } } /** * Initializes calculated answers for a given question. * * @param question_definition $question The question definition object. * @param stdClass $questiondata The question data object. */ protected function initialise_calculated_answers(question_definition $question, stdClass $questiondata) { $question->answers = []; if (empty($questiondata->options->answers)) { return; } foreach ($questiondata->options->answers as $a) { $question->answers[$a->id] = new \qtype_calculated\qtype_calculated_answer($a->id, $a->answer, $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance); } } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $this->initialise_calculated_answers($question, $questiondata); foreach ($questiondata->options->answers as $a) { $question->answers[$a->id]->tolerancetype = $a->tolerancetype; $question->answers[$a->id]->correctanswerlength = $a->correctanswerlength; $question->answers[$a->id]->correctanswerformat = $a->correctanswerformat; } $question->synchronised = $questiondata->options->synchronize; $question->unitdisplay = $questiondata->options->showunits; $question->unitgradingtype = $questiondata->options->unitgradingtype; $question->unitpenalty = $questiondata->options->unitpenalty; $question->ap = question_bank::get_qtype( 'numerical')->make_answer_processor( $questiondata->options->units, $questiondata->options->unitsleft); $question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id); } public function finished_edit_wizard($form) { return isset($form->savechanges); } public function wizardpagesnumber() { return 3; } // This gets called by editquestion.php after the standard question is saved. public function print_next_wizard_page($question, $form, $course) { global $CFG, $SESSION, $COURSE; // Catch invalid navigation & reloads. if (empty($question->id) && empty($SESSION->calculated)) { redirect('edit.php?courseid='.$COURSE->id, 'The page you are loading has expired.', 3); } // See where we're coming from. switch($form->wizardpage) { case 'question': require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions.php"); break; case 'datasetdefinitions': case 'datasetitems': require("{$CFG->dirroot}/question/type/calculated/datasetitems.php"); break; default: throw new \moodle_exception('invalidwizardpage', 'question'); break; } } // This gets called by question2.php after the standard question is saved. public function &next_wizard_form($submiturl, $question, $wizardnow) { global $CFG, $SESSION, $COURSE; // Catch invalid navigation & reloads. if (empty($question->id) && empty($SESSION->calculated)) { redirect('edit.php?courseid=' . $COURSE->id, 'The page you are loading has expired. Cannot get next wizard form.', 3); } if (empty($question->id)) { $question = $SESSION->calculated->questionform; } // See where we're coming from. switch($wizardnow) { case 'datasetdefinitions': require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions_form.php"); $mform = new question_dataset_dependent_definitions_form( "{$submiturl}?wizardnow=datasetdefinitions", $question); break; case 'datasetitems': require("{$CFG->dirroot}/question/type/calculated/datasetitems_form.php"); $regenerate = optional_param('forceregeneration', false, PARAM_BOOL); $mform = new question_dataset_dependent_items_form( "{$submiturl}?wizardnow=datasetitems", $question, $regenerate); break; default: throw new \moodle_exception('invalidwizardpage', 'question'); break; } return $mform; } /** * This method should be overriden if you want to include a special heading or some other * html on a question editing page besides the question editing form. * * @param question_edit_form $mform a child of question_edit_form * @param object $question * @param string $wizardnow is '' for first page. */ public function display_question_editing_page($mform, $question, $wizardnow) { global $OUTPUT; switch ($wizardnow) { case '': // On the first page, the default display is fine. parent::display_question_editing_page($mform, $question, $wizardnow); return; case 'datasetdefinitions': echo $OUTPUT->heading_with_help( get_string('choosedatasetproperties', 'qtype_calculated'), 'questiondatasets', 'qtype_calculated'); break; case 'datasetitems': echo $OUTPUT->heading_with_help(get_string('editdatasets', 'qtype_calculated'), 'questiondatasets', 'qtype_calculated'); break; } $mform->display(); } /** * Verify that the equations in part of the question are OK. * We throw an exception here because this should have already been validated * by the form. This is just a last line of defence to prevent a question * being stored in the database if it has bad formulas. This saves us from, * for example, malicious imports. * @param string $text containing equations. */ protected function validate_text($text) { $error = qtype_calculated_find_formula_errors_in_text($text); if ($error) { throw new coding_exception($error); } } /** * Verify that an answer is OK. * We throw an exception here because this should have already been validated * by the form. This is just a last line of defence to prevent a question * being stored in the database if it has bad formulas. This saves us from, * for example, malicious imports. * @param string $text containing equations. */ protected function validate_answer($answer) { $error = qtype_calculated_find_formula_errors($answer); if ($error) { throw new coding_exception($error); } } /** * Validate data before save. * @param stdClass $question data from the form / import file. */ protected function validate_question_data($question) { $this->validate_text($question->questiontext); // Yes, really no ['text']. if (isset($question->generalfeedback['text'])) { $this->validate_text($question->generalfeedback['text']); } else if (isset($question->generalfeedback)) { $this->validate_text($question->generalfeedback); // Because question import is weird. } foreach ($question->answer as $key => $answer) { $this->validate_answer($answer); $this->validate_text($question->feedback[$key]['text']); } } /** * Remove prefix #{..}# if exists. * @param $name a question name, * @return string the cleaned up question name. */ public function clean_technical_prefix_from_question_name($name) { return preg_replace('~#\{([^[:space:]]*)#~', '', $name); } /** * This method prepare the $datasets in a format similar to dadatesetdefinitions_form.php * so that they can be saved * using the function save_dataset_definitions($form) * when creating a new calculated question or * when editing an already existing calculated question * or by function save_as_new_dataset_definitions($form, $initialid) * when saving as new an already existing calculated question. * * @param object $form * @param int $questionfromid default = '0' */ public function preparedatasets($form, $questionfromid = '0') { // The dataset names present in the edit_question_form and edit_calculated_form // are retrieved. $possibledatasets = $this->find_dataset_names($form->questiontext); $mandatorydatasets = []; foreach ($form->answer as $key => $answer) { $mandatorydatasets += $this->find_dataset_names($answer); } // If there are identical datasetdefs already saved in the original question // either when editing a question or saving as new, // they are retrieved using $questionfromid. if ($questionfromid != '0') { $form->id = $questionfromid; } $datasets = []; $key = 0; // Always prepare the mandatorydatasets present in the answers. // The $options are not used here. foreach ($mandatorydatasets as $datasetname) { if (!isset($datasets[$datasetname])) { list($options, $selected) = $this->dataset_options($form, $datasetname); $datasets[$datasetname] = ''; $form->dataset[$key] = $selected; $key++; } } // Do not prepare possibledatasets when creating a question. // They will defined and stored with datasetdefinitions_form.php. // The $options are not used here. if ($questionfromid != '0') { foreach ($possibledatasets as $datasetname) { if (!isset($datasets[$datasetname])) { list($options, $selected) = $this->dataset_options($form, $datasetname, false); $datasets[$datasetname] = ''; $form->dataset[$key] = $selected; $key++; } } } return $datasets; } public function addnamecategory(&$question) { global $DB; $categorydatasetdefs = $DB->get_records_sql( "SELECT a.* FROM {question_datasets} b, {question_dataset_definitions} a WHERE a.id = b.datasetdefinition AND a.type = '1' AND a.category != 0 AND b.question = ? ORDER BY a.name ", [$question->id]); $questionname = $this->clean_technical_prefix_from_question_name($question->name); if (!empty($categorydatasetdefs)) { // There is at least one with the same name. $questionname = '#' . $questionname; foreach ($categorydatasetdefs as $def) { if (strlen($def->name) + strlen($questionname) < 250) { $questionname = '{' . $def->name . '}' . $questionname; } } $questionname = '#' . $questionname; } $DB->set_field('question', 'name', $questionname, ['id' => $question->id]); } /** * this version save the available data at the different steps of the question editing process * without using global $SESSION as storage between steps * at the first step $wizardnow = 'question' * when creating a new question * when modifying a question * when copying as a new question * the general parameters and answers are saved using parent::save_question * then the datasets are prepared and saved * at the second step $wizardnow = 'datasetdefinitions' * the datadefs final type are defined as private, category or not a datadef * at the third step $wizardnow = 'datasetitems' * the datadefs parameters and the data items are created or defined * * @param object question * @param object $form * @param int $course * @param PARAM_ALPHA $wizardnow should be added as we are coming from question2.php */ public function save_question($question, $form) { global $DB; if ($this->wizardpagesnumber() == 1 || $question->qtype == 'calculatedsimple') { $question = parent::save_question($question, $form); return $question; } $wizardnow = optional_param('wizardnow', '', PARAM_ALPHA); $id = optional_param('id', 0, PARAM_INT); // Question id. // In case 'question': // For a new question $form->id is empty // when saving as new question. // The $question->id = 0, $form is $data from question2.php // and $data->makecopy is defined as $data->id is the initial question id. // Edit case. If it is a new question we don't necessarily need to // return a valid question object. // See where we're coming from. switch($wizardnow) { case '' : case 'question': // Coming from the first page, creating the second. if (empty($form->id)) { // Or a new question $form->id is empty. $question = parent::save_question($question, $form); // Prepare the datasets using default $questionfromid. $this->preparedatasets($form); $form->id = $question->id; $this->save_dataset_definitions($form); if (isset($form->synchronize) && $form->synchronize == 2) { $this->addnamecategory($question); } } else { $questionfromid = $form->id; $question = parent::save_question($question, $form); // Prepare the datasets. $this->preparedatasets($form, $questionfromid); $form->id = $question->id; $this->save_as_new_dataset_definitions($form, $questionfromid); if (isset($form->synchronize) && $form->synchronize == 2) { $this->addnamecategory($question); } } break; case 'datasetdefinitions': // Calculated options. // It cannot go here without having done the first page, // so the question_calculated_options should exist. // We only need to update the synchronize field. if (isset($form->synchronize)) { $optionssynchronize = $form->synchronize; } else { $optionssynchronize = 0; } $DB->set_field('question_calculated_options', 'synchronize', $optionssynchronize, ['question' => $question->id]); if (isset($form->synchronize) && $form->synchronize == 2) { $this->addnamecategory($question); } $this->save_dataset_definitions($form); break; case 'datasetitems': $this->save_dataset_items($question, $form); $this->save_question_calculated($question, $form); break; default: throw new \moodle_exception('invalidwizardpage', 'question'); break; } return $question; } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('question_calculated', ['question' => $questionid]); $DB->delete_records('question_calculated_options', ['question' => $questionid]); $DB->delete_records('question_numerical_units', ['question' => $questionid]); if ($datasets = $DB->get_records('question_datasets', ['question' => $questionid])) { foreach ($datasets as $dataset) { if (!$DB->get_records_select('question_datasets', "question != ? AND datasetdefinition = ? ", [$questionid, $dataset->datasetdefinition])) { $DB->delete_records('question_dataset_definitions', ['id' => $dataset->datasetdefinition]); $DB->delete_records('question_dataset_items', ['definition' => $dataset->datasetdefinition]); } } } $DB->delete_records('question_datasets', ['question' => $questionid]); parent::delete_question($questionid, $contextid); } public function get_random_guess_score($questiondata) { foreach ($questiondata->options->answers as $aid => $answer) { if ('*' == trim($answer->answer)) { return max($answer->fraction - $questiondata->options->unitpenalty, 0); } } return 0; } public function supports_dataset_item_generation() { // Calculated support generation of randomly distributed number data. return true; } public function custom_generator_tools_part($mform, $idx, $j) { $minmaxgrp = []; $minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]", get_string('calcmin', 'qtype_calculated')); $minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]", get_string('calcmax', 'qtype_calculated')); $mform->addGroup($minmaxgrp, 'minmaxgrp', get_string('minmax', 'qtype_calculated'), ' - ', false); $precisionoptions = range(0, 10); $mform->addElement('select', "calclength[{$idx}]", get_string('calclength', 'qtype_calculated'), $precisionoptions); $distriboptions = ['uniform' => get_string('uniform', 'qtype_calculated'), 'loguniform' => get_string('loguniform', 'qtype_calculated')]; $mform->addElement('select', "calcdistribution[{$idx}]", get_string('calcdistribution', 'qtype_calculated'), $distriboptions); } public function custom_generator_set_data($datasetdefs, $formdata) { $idx = 1; foreach ($datasetdefs as $datasetdef) { if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', $datasetdef->options, $regs)) { $formdata["calcdistribution[{$idx}]"] = $regs[1]; $formdata["calcmin[{$idx}]"] = $regs[2]; $formdata["calcmax[{$idx}]"] = $regs[3]; $formdata["calclength[{$idx}]"] = $regs[4]; } $idx++; } return $formdata; } public function custom_generator_tools($datasetdef) { global $OUTPUT; if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', $datasetdef->options, $regs)) { $defid = "{$datasetdef->type}-{$datasetdef->category}-{$datasetdef->name}"; for ($i = 0; $i < 10; ++$i) { $lengthoptions[$i] = get_string(($regs[1] == 'uniform' ? 'decimals' : 'significantfigures'), 'qtype_calculated', $i); } $menu1 = html_writer::label(get_string('lengthoption', 'qtype_calculated'), 'menucalclength', false, ['class' => 'accesshide']); $menu1 .= html_writer::select($lengthoptions, 'calclength[]', $regs[4], null, ['class' => 'form-select']); $options = ['uniform' => get_string('uniformbit', 'qtype_calculated'), 'loguniform' => get_string('loguniformbit', 'qtype_calculated')]; $menu2 = html_writer::label(get_string('distributionoption', 'qtype_calculated'), 'menucalcdistribution', false, ['class' => 'accesshide']); $menu2 .= html_writer::select($options, 'calcdistribution[]', $regs[1], null, ['class' => 'form-select']); return '
    ' . ' & ' . $menu1 . '
    ' . $menu2; } else { return ''; } } public function update_dataset_options($datasetdefs, $form) { global $OUTPUT; // Do we have information about new options ? if (empty($form->definition) || empty($form->calcmin) ||empty($form->calcmax) || empty($form->calclength) || empty($form->calcdistribution)) { // I guess not. } else { // Looks like we just could have some new information here. $uniquedefs = array_values(array_unique($form->definition)); foreach ($uniquedefs as $key => $defid) { if (isset($datasetdefs[$defid]) && is_numeric($form->calcmin[$key + 1]) && is_numeric($form->calcmax[$key + 1]) && is_numeric($form->calclength[$key + 1])) { switch ($form->calcdistribution[$key + 1]) { case 'uniform': case 'loguniform': $datasetdefs[$defid]->options = $form->calcdistribution[$key + 1] . ':' . $form->calcmin[$key + 1] . ':' . $form->calcmax[$key + 1] . ':' . $form->calclength[$key + 1]; break; default: echo $OUTPUT->notification( "Unexpected distribution ".$form->calcdistribution[$key + 1]); } } } } // Look for empty options, on which we set default values. foreach ($datasetdefs as $defid => $def) { if (empty($def->options)) { $datasetdefs[$defid]->options = 'uniform:1.0:10.0:1'; } } return $datasetdefs; } public function save_question_calculated($question, $fromform) { global $DB; foreach ($question->options->answers as $key => $answer) { if ($options = $DB->get_record('question_calculated', ['answer' => $key])) { $options->tolerance = trim($fromform->tolerance[$key]); $options->tolerancetype = trim($fromform->tolerancetype[$key]); $options->correctanswerlength = trim($fromform->correctanswerlength[$key]); $options->correctanswerformat = trim($fromform->correctanswerformat[$key]); $DB->update_record('question_calculated', $options); } } } /** * This function get the dataset items using id as unique parameter and return an * array with itemnumber as index sorted ascendant * If the multiple records with the same itemnumber exist, only the newest one * i.e with the greatest id is used, the others are ignored but not deleted. * MDL-19210 */ public function get_database_dataset_items($definition) { global $CFG, $DB; $databasedataitems = $DB->get_records_sql( // Hint: Use the number as a key. " SELECT id , itemnumber, definition, value FROM {question_dataset_items} WHERE definition = $definition order by id DESC ", [$definition]); $dataitems = []; foreach ($databasedataitems as $id => $dataitem) { if (!isset($dataitems[$dataitem->itemnumber])) { $dataitems[$dataitem->itemnumber] = $dataitem; } } ksort($dataitems); return $dataitems; } public function save_dataset_items($question, $fromform) { global $CFG, $DB; $synchronize = false; if (isset($fromform->nextpageparam['forceregeneration'])) { $regenerate = $fromform->nextpageparam['forceregeneration']; } else { $regenerate = 0; } if (empty($question->options)) { $this->get_question_options($question); } if (!empty($question->options->synchronize)) { $synchronize = true; } // Get the old datasets for this question. $datasetdefs = $this->get_dataset_definitions($question->id, []); // Handle generator options... $olddatasetdefs = fullclone($datasetdefs); $datasetdefs = $this->update_dataset_options($datasetdefs, $fromform); $maxnumber = -1; foreach ($datasetdefs as $defid => $datasetdef) { if (isset($datasetdef->id) && $datasetdef->options != $olddatasetdefs[$defid]->options) { // Save the new value for options. $DB->update_record('question_dataset_definitions', $datasetdef); } // Get maxnumber. if ($maxnumber == -1 || $datasetdef->itemcount < $maxnumber) { $maxnumber = $datasetdef->itemcount; } } // Handle adding and removing of dataset items. $i = 1; if ($maxnumber > self::MAX_DATASET_ITEMS) { $maxnumber = self::MAX_DATASET_ITEMS; } ksort($fromform->definition); foreach ($fromform->definition as $key => $defid) { // If the delete button has not been pressed then skip the datasetitems // in the 'add item' part of the form. if ($i > count($datasetdefs) * $maxnumber) { break; } $addeditem = new stdClass(); $addeditem->definition = $datasetdefs[$defid]->id; $addeditem->value = $fromform->number[$i]; $addeditem->itemnumber = ceil($i / count($datasetdefs)); if ($fromform->itemid[$i]) { // Reuse any previously used record. $addeditem->id = $fromform->itemid[$i]; $DB->update_record('question_dataset_items', $addeditem); } else { $DB->insert_record('question_dataset_items', $addeditem); } $i++; } if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber && $addeditem->itemnumber < self::MAX_DATASET_ITEMS) { $maxnumber = $addeditem->itemnumber; foreach ($datasetdefs as $key => $newdef) { if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { $newdef->itemcount = $maxnumber; // Save the new value for options. $DB->update_record('question_dataset_definitions', $newdef); } } } // Adding supplementary items. $numbertoadd = 0; if (isset($fromform->addbutton) && $fromform->selectadd > 0 && $maxnumber < self::MAX_DATASET_ITEMS) { $numbertoadd = $fromform->selectadd; if (self::MAX_DATASET_ITEMS - $maxnumber < $numbertoadd) { $numbertoadd = self::MAX_DATASET_ITEMS - $maxnumber; } // Add the other items. // Generate a new dataset item (or reuse an old one). foreach ($datasetdefs as $defid => $datasetdef) { // In case that for category datasets some new items has been added, // get actual values. // Fix regenerate for this datadefs. $defregenerate = 0; if ($synchronize && !empty ($fromform->nextpageparam["datasetregenerate[{$datasetdef->name}"])) { $defregenerate = 1; } else if (!$synchronize && (($regenerate == 1 && $datasetdef->category == 0) ||$regenerate == 2)) { $defregenerate = 1; } if (isset($datasetdef->id)) { $datasetdefs[$defid]->items = $this->get_database_dataset_items($datasetdef->id); } for ($numberadded = $maxnumber + 1; $numberadded <= $maxnumber + $numbertoadd; $numberadded++) { if (isset($datasetdefs[$defid]->items[$numberadded])) { // In case of regenerate it modifies the already existing record. if ($defregenerate) { $datasetitem = new stdClass(); $datasetitem->id = $datasetdefs[$defid]->items[$numberadded]->id; $datasetitem->definition = $datasetdef->id; $datasetitem->itemnumber = $numberadded; $datasetitem->value = $this->generate_dataset_item($datasetdef->options); $DB->update_record('question_dataset_items', $datasetitem); } // If not regenerate do nothing as there is already a record. } else { $datasetitem = new stdClass(); $datasetitem->definition = $datasetdef->id; $datasetitem->itemnumber = $numberadded; if ($this->supports_dataset_item_generation()) { $datasetitem->value = $this->generate_dataset_item($datasetdef->options); } else { $datasetitem->value = ''; } $DB->insert_record('question_dataset_items', $datasetitem); } }// For number added. }// Datasetsdefs end. $maxnumber += $numbertoadd; foreach ($datasetdefs as $key => $newdef) { if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { $newdef->itemcount = $maxnumber; // Save the new value for options. $DB->update_record('question_dataset_definitions', $newdef); } } } if (isset($fromform->deletebutton)) { if (isset($fromform->selectdelete)) { $newmaxnumber = $maxnumber - $fromform->selectdelete; } else { $newmaxnumber = $maxnumber - 1; } if ($newmaxnumber < 0) { $newmaxnumber = 0; } foreach ($datasetdefs as $datasetdef) { if ($datasetdef->itemcount == $maxnumber) { $datasetdef->itemcount = $newmaxnumber; $DB->update_record('question_dataset_definitions', $datasetdef); } } } } public function generate_dataset_item($options) { if (!preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', $options, $regs)) { // Unknown options... return false; } if ($regs[1] == 'uniform') { $nbr = $regs[2] + ($regs[3] - $regs[2]) * mt_rand() / mt_getrandmax(); return sprintf("%.".$regs[4].'f', $nbr); } else if ($regs[1] == 'loguniform') { $log0 = log(abs($regs[2])); // It would have worked the other way to. $nbr = exp($log0 + (log(abs($regs[3])) - $log0) * mt_rand() / mt_getrandmax()); return sprintf("%.".$regs[4].'f', $nbr); } else { throw new \moodle_exception('disterror', 'question', '', $regs[1]); } return ''; } public function comment_header($question) { $strheader = ''; $delimiter = ''; $answers = $question->options->answers; foreach ($answers as $key => $answer) { $ans = shorten_text($answer->answer, 17, true); $strheader .= $delimiter.$ans; $delimiter = '


    '; } return $strheader; } public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext, $answers, $data, $number) { global $DB; $comment = new stdClass(); $comment->stranswers = []; $comment->outsidelimit = false; $comment->answers = []; // Find a default unit. $unit = ''; if (!empty($questionid)) { $units = $DB->get_records('question_numerical_units', ['question' => $questionid, 'multiplier' => 1.0], 'id ASC', '*', 0, 1); if ($units) { $unit = reset($units); $unit = $unit->unit; } } $answers = fullclone($answers); $delimiter = ': '; $virtualqtype = $qtypeobj->get_virtual_qtype(); foreach ($answers as $key => $answer) { $error = qtype_calculated_find_formula_errors($answer->answer); if ($error) { $comment->stranswers[$key] = $error; continue; } $formula = $this->substitute_variables($answer->answer, $data); $formattedanswer = qtype_calculated_calculate_answer( $answer->answer, $data, $answer->tolerance, $answer->tolerancetype, $answer->correctanswerlength, $answer->correctanswerformat, $unit); if ($formula === '*') { $answer->min = ' '; $formattedanswer->answer = $answer->answer; } else { eval('$ansvalue = '.$formula.';'); $ans = new qtype_numerical_answer(0, $ansvalue, 0, '', 0, $answer->tolerance); $ans->tolerancetype = $answer->tolerancetype; list($answer->min, $answer->max) = $ans->get_tolerance_interval($answer); } if ($answer->min === '') { // This should mean that something is wrong. $comment->stranswers[$key] = " {$formattedanswer->answer}".'

    '; } else if ($formula === '*') { $comment->stranswers[$key] = $formula . ' = ' . get_string('anyvalue', 'qtype_calculated') . '


    '; } else { $formula = shorten_text($formula, 57, true); $comment->stranswers[$key] = $formula . ' = ' . $formattedanswer->answer . '
    '; $correcttrue = new stdClass(); $correcttrue->correct = $formattedanswer->answer; $correcttrue->true = ''; if ((float) $formattedanswer->answer < $answer->min || (float) $formattedanswer->answer > $answer->max) { $comment->outsidelimit = true; $comment->answers[$key] = $key; $comment->stranswers[$key] .= get_string('trueansweroutsidelimits', 'qtype_calculated', $correcttrue); } else { $comment->stranswers[$key] .= get_string('trueanswerinsidelimits', 'qtype_calculated', $correcttrue); } $comment->stranswers[$key] .= '
    '; $comment->stranswers[$key] .= get_string('min', 'qtype_calculated') . $delimiter . $answer->min . ' --- '; $comment->stranswers[$key] .= get_string('max', 'qtype_calculated') . $delimiter . $answer->max; } } return fullclone($comment); } public function tolerance_types() { return [ '1' => get_string('relative', 'qtype_numerical'), '2' => get_string('nominal', 'qtype_numerical'), '3' => get_string('geometric', 'qtype_numerical'), ]; } public function dataset_options($form, $name, $mandatory = true, $renameabledatasets = false) { // Takes datasets from the parent implementation but // filters options that are currently not accepted by calculated. // It also determines a default selection. // Param $renameabledatasets not implemented anywhere. list($options, $selected) = $this->dataset_options_from_database( $form, $name, '', 'qtype_calculated'); foreach ($options as $key => $whatever) { if (!preg_match('~^1-~', $key) && $key != '0') { unset($options[$key]); } } if (!$selected) { if ($mandatory) { $selected = "1-0-{$name}"; // Default. } else { $selected = '0'; // Default. } } return [$options, $selected]; } public function construct_dataset_menus($form, $mandatorydatasets, $optionaldatasets) { global $OUTPUT; $datasetmenus = []; foreach ($mandatorydatasets as $datasetname) { if (!isset($datasetmenus[$datasetname])) { list($options, $selected) = $this->dataset_options($form, $datasetname); unset($options['0']); // Mandatory... $datasetmenus[$datasetname] = html_writer::select( $options, 'dataset[]', $selected, null); } } foreach ($optionaldatasets as $datasetname) { if (!isset($datasetmenus[$datasetname])) { list($options, $selected) = $this->dataset_options($form, $datasetname); $datasetmenus[$datasetname] = html_writer::select( $options, 'dataset[]', $selected, null); } } return $datasetmenus; } public function substitute_variables($str, $dataset) { global $OUTPUT; // Testing for wrong numerical values. // All calculations used this function so testing here should be OK. foreach ($dataset as $name => $value) { $val = $value; if (! is_numeric($val)) { $a = new stdClass(); $a->name = '{'.$name.'}'; $a->value = $value; echo $OUTPUT->notification(get_string('notvalidnumber', 'qtype_calculated', $a)); $val = 1.0; } if ($val <= 0) { // MDL-36025 Use parentheses for "-0" . $str = str_replace('{'.$name.'}', '('.$val.')', $str); } else { $str = str_replace('{'.$name.'}', $val, $str); } } return $str; } public function evaluate_equations($str, $dataset) { $formula = $this->substitute_variables($str, $dataset); if ($error = qtype_calculated_find_formula_errors($formula)) { return $error; } return $str; } public function substitute_variables_and_eval($str, $dataset) { $formula = $this->substitute_variables($str, $dataset); if ($error = qtype_calculated_find_formula_errors($formula)) { return $error; } // Calculate the correct answer. if (empty($formula)) { $str = ''; } else if ($formula === '*') { $str = '*'; } else { $str = null; eval('$str = '.$formula.';'); } return $str; } public function get_dataset_definitions($questionid, $newdatasets) { global $DB; // Get the existing datasets for this question. $datasetdefs = []; if (!empty($questionid)) { global $CFG; $sql = "SELECT i.* FROM {question_datasets} d, {question_dataset_definitions} i WHERE d.question = ? AND d.datasetdefinition = i.id ORDER BY i.id"; if ($records = $DB->get_records_sql($sql, [$questionid])) { foreach ($records as $r) { $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; } } } foreach ($newdatasets as $dataset) { if (!$dataset) { continue; // The no dataset case... } if (!isset($datasetdefs[$dataset])) { // Make new datasetdef. list($type, $category, $name) = explode('-', $dataset, 3); $datasetdef = new stdClass(); $datasetdef->type = $type; $datasetdef->name = $name; $datasetdef->category = $category; $datasetdef->itemcount = 0; $datasetdef->options = 'uniform:1.0:10.0:1'; $datasetdefs[$dataset] = clone($datasetdef); } } return $datasetdefs; } public function save_dataset_definitions($form) { global $DB; // Save synchronize. if (empty($form->dataset)) { $form->dataset = []; } // Save datasets. $datasetdefinitions = $this->get_dataset_definitions($form->id, $form->dataset); $tmpdatasets = array_flip($form->dataset); $defids = array_keys($datasetdefinitions); foreach ($defids as $defid) { $datasetdef = &$datasetdefinitions[$defid]; if (isset($datasetdef->id)) { if (!isset($tmpdatasets[$defid])) { // This dataset is not used any more, delete it. $DB->delete_records('question_datasets', ['question' => $form->id, 'datasetdefinition' => $datasetdef->id]); if ($datasetdef->category == 0) { // Question local dataset. $DB->delete_records('question_dataset_definitions', ['id' => $datasetdef->id]); $DB->delete_records('question_dataset_items', ['definition' => $datasetdef->id]); } } // This has already been saved or just got deleted. unset($datasetdefinitions[$defid]); continue; } $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); if (0 != $datasetdef->category) { // We need to look for already existing datasets in the category. // First creating the datasetdefinition above // then we can manage to automatically take care of some possible realtime concurrence. if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', 'type = ? AND name = ? AND category = ? AND id < ? ORDER BY id DESC', [$datasetdef->type, $datasetdef->name, $datasetdef->category, $datasetdef->id])) { while ($olderdatasetdef = array_shift($olderdatasetdefs)) { $DB->delete_records('question_dataset_definitions', ['id' => $datasetdef->id]); $datasetdef = $olderdatasetdef; } } } // Create relation to this dataset. $questiondataset = new stdClass(); $questiondataset->question = $form->id; $questiondataset->datasetdefinition = $datasetdef->id; $DB->insert_record('question_datasets', $questiondataset); unset($datasetdefinitions[$defid]); } // Remove local obsolete datasets as well as relations // to datasets in other categories. if (!empty($datasetdefinitions)) { foreach ($datasetdefinitions as $def) { $DB->delete_records('question_datasets', ['question' => $form->id, 'datasetdefinition' => $def->id]); if ($def->category == 0) { // Question local dataset. $DB->delete_records('question_dataset_definitions', ['id' => $def->id]); $DB->delete_records('question_dataset_items', ['definition' => $def->id]); } } } } /** This function create a copy of the datasets (definition and dataitems) * from the preceding question if they remain in the new question * otherwise its create the datasets that have been added as in the * save_dataset_definitions() */ public function save_as_new_dataset_definitions($form, $initialid) { global $CFG, $DB; // Get the datasets from the intial question. $datasetdefinitions = $this->get_dataset_definitions($initialid, $form->dataset); // Param $tmpdatasets contains those of the new question. $tmpdatasets = array_flip($form->dataset); $defids = array_keys($datasetdefinitions);// New datasets. foreach ($defids as $defid) { $datasetdef = &$datasetdefinitions[$defid]; if (isset($datasetdef->id)) { // This dataset exist in the initial question. if (!isset($tmpdatasets[$defid])) { // Do not exist in the new question so ignore. unset($datasetdefinitions[$defid]); continue; } // Create a copy but not for category one. if (0 == $datasetdef->category) { $olddatasetid = $datasetdef->id; $olditemcount = $datasetdef->itemcount; $datasetdef->itemcount = 0; $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); // Copy the dataitems. $olditems = $this->get_database_dataset_items($olddatasetid); if (count($olditems) > 0) { $itemcount = 0; foreach ($olditems as $item) { $item->definition = $datasetdef->id; $DB->insert_record('question_dataset_items', $item); $itemcount++; } // Update item count to olditemcount if // at least this number of items has been recover from the database. if ($olditemcount <= $itemcount) { $datasetdef->itemcount = $olditemcount; } else { $datasetdef->itemcount = $itemcount; } $DB->update_record('question_dataset_definitions', $datasetdef); } // End of copy the dataitems. }// End of copy the datasetdef. // Create relation to the new question with this // copy as new datasetdef from the initial question. $questiondataset = new stdClass(); $questiondataset->question = $form->id; $questiondataset->datasetdefinition = $datasetdef->id; $DB->insert_record('question_datasets', $questiondataset); unset($datasetdefinitions[$defid]); continue; }// End of datasetdefs from the initial question. // Really new one code similar to save_dataset_definitions(). $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); if (0 != $datasetdef->category) { // We need to look for already existing // datasets in the category. // By first creating the datasetdefinition above we // can manage to automatically take care of // some possible realtime concurrence. if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', "type = ? AND " . $DB->sql_equal('name', '?') . " AND category = ? AND id < ? ORDER BY id DESC", [$datasetdef->type, $datasetdef->name, $datasetdef->category, $datasetdef->id])) { while ($olderdatasetdef = array_shift($olderdatasetdefs)) { $DB->delete_records('question_dataset_definitions', ['id' => $datasetdef->id]); $datasetdef = $olderdatasetdef; } } } // Create relation to this dataset. $questiondataset = new stdClass(); $questiondataset->question = $form->id; $questiondataset->datasetdefinition = $datasetdef->id; $DB->insert_record('question_datasets', $questiondataset); unset($datasetdefinitions[$defid]); } // Remove local obsolete datasets as well as relations // to datasets in other categories. if (!empty($datasetdefinitions)) { foreach ($datasetdefinitions as $def) { $DB->delete_records('question_datasets', ['question' => $form->id, 'datasetdefinition' => $def->id]); if ($def->category == 0) { // Question local dataset. $DB->delete_records('question_dataset_definitions', ['id' => $def->id]); $DB->delete_records('question_dataset_items', ['definition' => $def->id]); } } } } // Dataset functionality. public function pick_question_dataset($question, $datasetitem) { // Select a dataset in the following format: // an array indexed by the variable names (d.name) pointing to the value // to be substituted. global $CFG, $DB; if (!$dataitems = $DB->get_records_sql( "SELECT i.id, d.name, i.value FROM {question_dataset_definitions} d, {question_dataset_items} i, {question_datasets} q WHERE q.question = ? AND q.datasetdefinition = d.id AND d.id = i.definition AND i.itemnumber = ? ORDER BY i.id DESC ", [$question->id, $datasetitem])) { $a = new stdClass(); $a->id = $question->id; $a->item = $datasetitem; throw new \moodle_exception('cannotgetdsfordependent', 'question', '', $a); } $dataset = []; foreach ($dataitems as $id => $dataitem) { if (!isset($dataset[$dataitem->name])) { $dataset[$dataitem->name] = $dataitem->value; } } return $dataset; } public function dataset_options_from_database($form, $name, $prefix = '', $langfile = 'qtype_calculated') { global $CFG, $DB; $type = 1; // Only type = 1 (i.e. old 'LITERAL') has ever been used. // First options - it is not a dataset... $options['0'] = get_string($prefix.'nodataset', $langfile); // New question no local. if (!isset($form->id) || $form->id == 0) { $key = "{$type}-0-{$name}"; $options[$key] = get_string($prefix."newlocal{$type}", $langfile); $currentdatasetdef = new stdClass(); $currentdatasetdef->type = '0'; } else { // Construct question local options. $sql = "SELECT a.* FROM {question_dataset_definitions} a, {question_datasets} b WHERE a.id = b.datasetdefinition AND a.type = '1' AND b.question = ? AND " . $DB->sql_equal('a.name', '?'); $currentdatasetdef = $DB->get_record_sql($sql, [$form->id, $name]); if (!$currentdatasetdef) { $currentdatasetdef = new stdClass(); $currentdatasetdef->type = '0'; } $key = "{$type}-0-{$name}"; if ($currentdatasetdef->type == $type && $currentdatasetdef->category == 0) { $options[$key] = get_string($prefix."keptlocal{$type}", $langfile); } else { $options[$key] = get_string($prefix."newlocal{$type}", $langfile); } } // Construct question category options. $categorydatasetdefs = $DB->get_records_sql( "SELECT b.question, a.* FROM {question_datasets} b, {question_dataset_definitions} a WHERE a.id = b.datasetdefinition AND a.type = '1' AND a.category = ? AND " . $DB->sql_equal('a.name', '?'), [$form->category, $name]); $type = 1; $key = "{$type}-{$form->category}-{$name}"; if (!empty($categorydatasetdefs)) { // There is at least one with the same name. if (isset($form->id) && isset($categorydatasetdefs[$form->id])) { // It is already used by this question. $options[$key] = get_string($prefix."keptcategory{$type}", $langfile); } else { $options[$key] = get_string($prefix."existingcategory{$type}", $langfile); } } else { $options[$key] = get_string($prefix."newcategory{$type}", $langfile); } // All done! return [$options, $currentdatasetdef->type ? "{$currentdatasetdef->type}-{$currentdatasetdef->category}-{$name}" : '']; } /** * Find the names of all datasets mentioned in a piece of question content like the question text. * @param $text the text to analyse. * @return array with dataset name for both key and value. */ public function find_dataset_names($text) { preg_match_all(self::PLACEHODLER_REGEX, $text, $matches); return array_combine($matches[1], $matches[1]); } /** * Find all the formulas in a bit of text. * * For example, called with "What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)" this * returns ['{a}*{b}']. * * @param $text text to analyse. * @return array where they keys an values are the formulas. */ public function find_formulas($text) { preg_match_all(self::FORMULAS_IN_TEXT_REGEX, $text, $matches); return array_combine($matches[1], $matches[1]); } /** * This function retrieve the item count of the available category shareable * wild cards that is added as a comment displayed when a wild card with * the same name is displayed in datasetdefinitions_form.php */ public function get_dataset_definitions_category($form) { global $CFG, $DB; $datasetdefs = []; $lnamemax = 30; if (!empty($form->category)) { $sql = "SELECT i.*, d.* FROM {question_datasets} d, {question_dataset_definitions} i WHERE i.id = d.datasetdefinition AND i.category = ?"; if ($records = $DB->get_records_sql($sql, [$form->category])) { foreach ($records as $r) { if (!isset ($datasetdefs["{$r->name}"])) { $datasetdefs["{$r->name}"] = $r->itemcount; } } } } return $datasetdefs; } /** * This function build a table showing the available category shareable * wild cards, their name, their definition (Min, Max, Decimal) , the item count * and the name of the question where they are used. * This table is intended to be add before the question text to help the user use * these wild cards */ public function print_dataset_definitions_category($form) { global $CFG, $DB; $datasetdefs = []; $lnamemax = 22; $namestr = get_string('name'); $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); $itemscountstr = get_string('itemscount', 'qtype_calculated'); $text = ''; if (!empty($form->category)) { list($category) = explode(',', $form->category); $sql = "SELECT i.*, d.* FROM {question_datasets} d, {question_dataset_definitions} i WHERE i.id = d.datasetdefinition AND i.category = ?"; if ($records = $DB->get_records_sql($sql, [$category])) { foreach ($records as $r) { $sql1 = "SELECT q.* FROM {question} q WHERE q.id = ?"; if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"])) { $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; } if ($questionb = $DB->get_records_sql($sql1, [$r->question])) { if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question])) { $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question] = new stdClass(); } $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question]->name = $questionb[$r->question]->name; } } } } if (!empty ($datasetdefs)) { $text = ""; foreach ($datasetdefs as $datasetdef) { list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); $text .= ""; } $text .= "
    {$namestr} {$rangeofvaluestr} {$itemscountstr} {$questionusingstr}
    {$datasetdef->name} {$min} - $max {$datasetdef->itemcount}   "; foreach ($datasetdef->questions as $qu) { // Limit the name length displayed. $questionname = $this->get_short_question_name($qu->name, $lnamemax); $text .= "    {$questionname}
    "; } $text .= "
    "; } else { $text .= get_string('nosharedwildcard', 'qtype_calculated'); } return $text; } /** * This function shortens a question name if it exceeds the character limit. * * @param string $stringtoshorten the string to be shortened. * @param int $characterlimit the character limit. * @return string */ public function get_short_question_name($stringtoshorten, $characterlimit) { if (!empty($stringtoshorten)) { $returnstring = format_string($stringtoshorten); if (strlen($returnstring) > $characterlimit) { $returnstring = shorten_text($returnstring, $characterlimit, true); } return $returnstring; } else { return ''; } } /** * This function build a table showing the available category shareable * wild cards, their name, their definition (Min, Max, Decimal) , the item count * and the name of the question where they are used. * This table is intended to be add before the question text to help the user use * these wild cards */ public function print_dataset_definitions_category_shared($question, $datasetdefsq) { global $CFG, $DB; $datasetdefs = []; $lnamemax = 22; $namestr = get_string('name', 'quiz'); $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); $itemscountstr = get_string('itemscount', 'qtype_calculated'); $text = ''; if (!empty($question->category)) { list($category) = explode(',', $question->category); $sql = "SELECT i.*, d.* FROM {question_datasets} d, {question_dataset_definitions} i WHERE i.id = d.datasetdefinition AND i.category = ?"; if ($records = $DB->get_records_sql($sql, [$category])) { foreach ($records as $r) { $key = "{$r->type}-{$r->category}-{$r->name}"; $sql1 = "SELECT q.* FROM {question} q WHERE q.id = ?"; if (!isset($datasetdefs[$key])) { $datasetdefs[$key] = $r; } if ($questionb = $DB->get_records_sql($sql1, [$r->question])) { $datasetdefs[$key]->questions[$r->question] = new stdClass(); $datasetdefs[$key]->questions[$r->question]->name = $questionb[$r->question]->name; $datasetdefs[$key]->questions[$r->question]->id = $questionb[$r->question]->id; } } } } if (!empty ($datasetdefs)) { $text = ""; $text .= ""; $text .= ""; $text .= ""; $text .= ""; foreach ($datasetdefs as $datasetdef) { list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); $count = count($datasetdef->questions); $text .= ""; $line = 0; foreach ($datasetdef->questions as $qu) { // Limit the name length displayed. $questionname = $this->get_short_question_name($qu->name, $lnamemax); if ($line) { $text .= ""; } $line++; $text .= ""; // TODO MDL-43779 should not have quiz-specific code here. $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_bank_usage_sql() . ') questioncount'; $nbofquiz = $DB->count_records_sql($sql, [$qu->id, 'mod_quiz', 'slot']); $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_attempt_usage_sql() . ') attemptcount'; $nbofattempts = $DB->count_records_sql($sql, [$qu->id]); if ($nbofquiz > 0) { $text .= ""; $text .= ""; $text .= ""; } } $text .= "
    {$namestr}{$itemscountstr}  {$questionusingstr}   QuizAttempts
    {$datasetdef->name} {$datasetdef->itemcount}
    {$questionname}{$nbofquiz}{$nbofattempts}"; } else { $text .= "0
    "; } $text .= "
    "; } else { $text .= get_string('nosharedwildcard', 'qtype_calculated'); } return $text; } public function get_virtual_qtype() { return question_bank::get_qtype('numerical'); } public function get_possible_responses($questiondata) { $responses = []; $virtualqtype = $this->get_virtual_qtype(); $unit = $virtualqtype->get_default_numerical_unit($questiondata); $tolerancetypes = $this->tolerance_types(); $starfound = false; foreach ($questiondata->options->answers as $aid => $answer) { $responseclass = $answer->answer; if ($responseclass === '*') { $starfound = true; } else { $a = new stdClass(); $a->answer = $virtualqtype->add_unit($questiondata, $responseclass, $unit); $a->tolerance = $answer->tolerance; $a->tolerancetype = $tolerancetypes[$answer->tolerancetype]; $responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a); } $responses[$aid] = new question_possible_response($responseclass, $answer->fraction); } if (!$starfound) { $responses[0] = new question_possible_response( get_string('didnotmatchanyanswer', 'question'), 0); } $responses[null] = question_possible_response::no_response(); return [$questiondata->id => $responses]; } public function move_files($questionid, $oldcontextid, $newcontextid) { $fs = get_file_storage(); parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } protected function delete_files($questionid, $contextid) { $fs = get_file_storage(); parent::delete_files($questionid, $contextid); $this->delete_files_in_answers($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } } function qtype_calculated_calculate_answer($formula, $individualdata, $tolerance, $tolerancetype, $answerlength, $answerformat = '1', $unit = '') { // The return value has these properties: . // ->answer the correct answer // ->min the lower bound for an acceptable response // ->max the upper bound for an accetpable response. $calculated = new stdClass(); // Exchange formula variables with the correct values... $answer = question_bank::get_qtype('calculated')->substitute_variables_and_eval( $formula, $individualdata); if (!is_numeric($answer)) { // Something went wrong, so just return NaN. $calculated->answer = NAN; return $calculated; } else if (is_nan($answer) || is_infinite($answer)) { $calculated->answer = $answer; return $calculated; } if ('1' == $answerformat) { // Answer is to have $answerlength decimals. // Decimal places. $calculated->answer = sprintf('%.' . $answerlength . 'F', $answer); } else if ($answer) { // Significant figures does only apply if the result is non-zero. // Convert to positive answer... if ($answer < 0) { $answer = -$answer; $sign = '-'; } else { $sign = ''; } // Determine the format 0.[1-9][0-9]* for the answer... $p10 = 0; while ($answer < 1) { --$p10; $answer *= 10; } while ($answer >= 1) { ++$p10; $answer /= 10; } // ... and have the answer rounded of to the correct length. $answer = round($answer, $answerlength); // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. if ($answer >= 1) { ++$p10; $answer /= 10; } // Have the answer written on a suitable format: // either scientific or plain numeric. if (-2 > $p10 || 4 < $p10) { // Use scientific format. $exponent = 'e'.--$p10; $answer *= 10; if (1 == $answerlength) { $calculated->answer = $sign.$answer.$exponent; } else { // Attach additional zeros at the end of $answer. $answer .= (1 == strlen($answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $calculated->answer = $sign .substr($answer, 0, $answerlength + 1).$exponent; } } else { // Stick to plain numeric format. $answer *= "1e{$p10}"; if (0.1 <= $answer / "1e{$answerlength}") { $calculated->answer = $sign.$answer; } else { // Could be an idea to add some zeros here. $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $oklen = $answerlength + ($p10 < 1 ? 2 - $p10 : 1); $calculated->answer = $sign.substr($answer, 0, $oklen); } } } else { $calculated->answer = 0.0; } if ($unit != '') { $calculated->answer = $calculated->answer . ' ' . $unit; } // Return the result. return $calculated; } /** * Validate a forumula. * @param string $formula the formula to validate. * @return string|boolean false if there are no problems. Otherwise a string error message. */ function qtype_calculated_find_formula_errors($formula) { foreach (['//', '/*', '#', ''] as $commentstart) { if (strpos($formula, $commentstart) !== false) { return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart); } } // Validates the formula submitted from the question edit page. // Returns false if everything is alright // otherwise it constructs an error message. // Strip away dataset names. Use 1.0 to remove valid names, so illegal names can be identified later. $formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula); // Strip away empty space and lowercase it. $formula = strtolower(str_replace(' ', '', $formula)); // Only mathematical operators are supported. Bitwise operators are not safe. // Note: In this context, ^ is a bitwise operator (exponents are represented by **). $safeoperatorchar = '-+/*%>:\~find_formulas($text); $errors = []; foreach ($formulas as $match) { $error = qtype_calculated_find_formula_errors($match); if ($error) { $errors[] = $error; } } if ($errors) { return implode(' ', $errors); } return false; } PK\wD\EE qengine.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript required by the question engine. * * @package moodlecore * @subpackage questionengine * @copyright 2008 The Open University * @deprecated since Moodle 4.0 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ M.core_scroll_manager = M.core_scroll_manager || {}; // TODO Remove the scroll manager and deprecation layer in 4.6 MDL-76685. /* eslint-disable */ var loadedPromise = new Promise(function(resolve) { require(['core/scroll_manager'], function(ScrollManager) { var transitionLayer = {}; var deprecatedNotice = function(functionName, newFunctionName) { window.console.error( "The " + functionName + " function has been deprecated. " + "Please use core/scroll_manager::" + newFunctionName + "() instead" ); }; transitionLayer.save_scroll_pos = function(Y, element) { deprecatedNotice('save_scroll_pos', 'saveScrollPos'); ScrollManager.saveScrollPos(element); }; transitionLayer.scroll_to_saved_pos = function() { deprecatedNotice('scroll_to_saved_pos', 'scrollToSavedPosition'); ScrollManager.scrollToSavedPosition(); }; M.core_scroll_manager = transitionLayer; resolve(transitionLayer); }); }); var callPromisedFunction = function(functionName, args) { loadedPromise.then(function(transitionLayer) { transitionLayer[functionName].apply(null, args); }); }; if (!M.core_scroll_manager.save_scroll_pos) { // Note: This object is short lived. // It only lives until the new scroll manager is loaded, at which point it is replaced. /** * In the form that contains the element, set the value of the form field with * name scrollpos to the current scroll position. If there is no element with * that name, it creates a hidden form field with that name within the form. * @deprecated since Moodle 4.0 * @see core/scroll_manager * @param element the element in the form. Should be something that can be * passed to Y.one. */ M.core_scroll_manager.save_scroll_pos = function(Y, element) { callPromisedFunction(M.core_scroll_manager.save_scroll_pos, [Y, element]); }; /** * Event handler that can be used on a link. Assumes that the link already * contains at least one URL parameter. * @deprecated since Moodle 4.0 * @see core/scroll_manager */ M.core_scroll_manager.save_scroll_action = function() { Y.log("The scroll_to_saved_pos function has been deprecated. " + "Please use initLinksScrollPos in core/scroll_manager instead.", 'moodle-core-notification', 'warn'); }; } /* eslint-enable */ M.core_question_engine = M.core_question_engine || {}; /** * Initialise a question submit button. This saves the scroll position and * sets the fragment on the form submit URL so the page reloads in the right place. * @deprecated since Moodle 4.0 * @see core_question/question_engine * @param button the id of the button in the HTML. */ M.core_question_engine.init_submit_button = function(Y, button) { Y.log("The core_question_engine.init_submit_button function has been deprecated. " + "Please use initSubmitButton in core_question/question_engine instead.", 'moodle-core-notification', 'warn'); require(['core_form/submit'], function(submit) { submit.init(button); }); var totalQuestionsInPage = document.querySelectorAll('div.que').length; var buttonel = document.getElementById(button); var outeruniqueid = buttonel.closest('.que').id; Y.on('click', function(e) { M.core_scroll_manager.save_scroll_pos(Y, button); if (totalQuestionsInPage > 1) { // Only change the form action if the page have more than one question. buttonel.form.action = buttonel.form.action + '#' + outeruniqueid; } }, buttonel); } PK\ZJr . .amd/src/filter.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Question bank filter management. * * @module core_question/filter * @copyright 2021 Tomo Tsuyuki * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import CoreFilter from 'core/datafilter'; import Notification from 'core/notification'; import Selectors from 'core/datafilter/selectors'; import Templates from 'core/templates'; import Fragment from 'core/fragment'; import {getString} from 'core/str'; import {addIconToContainerRemoveOnCompletion} from 'core/loadingicon'; /** * Initialise the question bank filter on the element with the given id. * * @param {String} filterRegionId ID of the HTML element containing the filters. * @param {String} defaultcourseid Course ID for the default course to pass back to the view. * @param {String} defaultcategoryid Question bank category ID for the default course to pass back to the view. * @param {Number} perpage The number of questions to display per page. * @param {Number} bankContextId Context ID of the question bank being filtered. * @param {Number} quizCmId Course module ID of the quiz as the viewing context. * @param {string} component Frankenstyle name of the component for the fragment API callback (e.g. core_question) * @param {string} callback Name of the callback for the fragment API (e.g question_data) * @param {string} view The class name of the question bank view class used for this page. * @param {Number} cmid If we are in an activitiy, the course module ID. * @param {Object} pagevars JSON-encoded parameters from passed from the view, including filters and jointype. * @param {Object} extraparams JSON-encoded additional parameters specific to this view class, used for re-rendering the view. */ export const init = async( filterRegionId, defaultcourseid, defaultcategoryid, perpage, bankContextId, quizCmId, component, callback, view, cmid, pagevars, extraparams, ) => { const SELECTORS = { QUESTION_CONTAINER_ID: '#questionscontainer', QUESTION_TABLE: '#questionscontainer table', SORT_LINK: '#questionscontainer div.sorters a', PAGINATION_LINK: '#questionscontainer a[href].page-link', LASTCHANGED_FIELD: '#questionsubmit input[name=lastchanged]', BULK_ACTIONS: '#bulkactionsui-container input', MENU_ACTIONS: '.menu-action', EDIT_SWITCH: '.editmode-switch-form input[name=setmode]', EDIT_SWITCH_URL: '.editmode-switch-form input[name=pageurl]', SHOW_ALL_LINK: '[data-filteraction="showall"]', }; const filterSet = document.querySelector(`#${filterRegionId}`); const viewData = { extraparams: JSON.stringify(extraparams), cmid, view, cat: defaultcategoryid, courseid: defaultcourseid, filter: {}, jointype: 0, qpage: 0, qperpage: perpage, sortdata: {}, lastchanged: document.querySelector(SELECTORS.LASTCHANGED_FIELD)?.value ?? null, }; let sortData = {}; const defaultSort = document.querySelector(SELECTORS.QUESTION_TABLE)?.dataset?.defaultsort; if (defaultSort) { sortData = JSON.parse(defaultSort); } const [ showAllText, showPerPageText, ] = await Promise.all([ getString('showall', 'core', ''), getString('showperpage', 'core', extraparams.defaultqperpage), ]); /** * Retrieve table data. * * @param {Object} filterdata data * @param {Promise} pendingPromise pending promise */ const applyFilter = (filterdata, pendingPromise) => { // Reload the questions based on the specified filters. If no filters are provided, // use the default category filter condition. if (filterdata) { // Main join types. viewData.jointype = parseInt(filterSet.dataset.filterverb, 10); delete filterdata.jointype; // Retrieve filter info. viewData.filter = filterdata; if (Object.keys(filterdata).length !== 0) { if (!isNaN(viewData.jointype)) { filterdata.jointype = viewData.jointype; } } } // Load questions for first page. viewData.filter = JSON.stringify(filterdata); viewData.sortdata = JSON.stringify(sortData); viewData.quizcmid = quizCmId; const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID); // Clear the contents of the element, then append the loading icon. questionscontainer.innerHTML = ''; addIconToContainerRemoveOnCompletion(questionscontainer, pendingPromise); Fragment.loadFragment(component, callback, bankContextId, viewData) // Render questions for first page and pagination. .then((questionhtml, jsfooter) => { updateUrlParams(filterdata); if (questionhtml === undefined) { questionhtml = ''; } if (jsfooter === undefined) { jsfooter = ''; } Templates.replaceNode(questionscontainer, questionhtml, jsfooter); // Resolve filter promise. if (pendingPromise) { pendingPromise.resolve(); } return {questionhtml, jsfooter}; }) .catch(Notification.exception); }; // Init core filter processor with apply callback. const coreFilter = new CoreFilter(filterSet, applyFilter); coreFilter.activeFilters = {}; // Unset useless courseid filter. coreFilter.init(); /** * Update URL Param based upon the current filter. * * @param {Object} filters Active filters. */ const updateUrlParams = (filters) => { const url = new URL(location.href); const filterQuery = JSON.stringify(filters); url.searchParams.set('filter', filterQuery); history.pushState(filters, '', url); const editSwitch = document.querySelector(SELECTORS.EDIT_SWITCH); if (editSwitch) { const editSwitchUrlInput = document.querySelector(SELECTORS.EDIT_SWITCH_URL); const editSwitchUrl = new URL(editSwitchUrlInput.value); editSwitchUrl.searchParams.set('filter', filterQuery); editSwitchUrlInput.value = editSwitchUrl; editSwitch.dataset.pageurl = editSwitchUrl; } }; /** * Cleans URL parameters. */ const cleanUrlParams = () => { const queryString = location.search; const urlParams = new URLSearchParams(queryString); if (urlParams.has('cmid')) { const cleanedUrl = new URL(location.href.replace(location.search, '')); cleanedUrl.searchParams.set('cmid', urlParams.get('cmid')); history.pushState({}, '', cleanedUrl); } if (urlParams.has('courseid')) { const cleanedUrl = new URL(location.href.replace(location.search, '')); cleanedUrl.searchParams.set('courseid', urlParams.get('courseid')); history.pushState({}, '', cleanedUrl); } }; // Add listeners for the sorting, paging and clear actions. document.querySelector('.questionbankwindow').addEventListener('click', e => { const sortableLink = e.target.closest(SELECTORS.SORT_LINK); const paginationLink = e.target.closest(SELECTORS.PAGINATION_LINK); const clearLink = e.target.closest(Selectors.filterset.actions.resetFilters); const showallLink = e.target.closest(SELECTORS.SHOW_ALL_LINK); if (sortableLink) { e.preventDefault(); const oldSort = sortData; sortData = {}; sortData[sortableLink.dataset.sortname] = sortableLink.dataset.sortorder; for (const sortname in oldSort) { if (sortname !== sortableLink.dataset.sortname) { sortData[sortname] = oldSort[sortname]; } } viewData.qpage = 0; coreFilter.updateTableFromFilter(false); } if (paginationLink) { e.preventDefault(); const paginationURL = new URL(paginationLink.getAttribute("href")); const qpage = paginationURL.searchParams.get('qpage'); if (paginationURL.search !== null) { viewData.qpage = qpage; coreFilter.updateTableFromFilter(false); } } if (clearLink) { cleanUrlParams(); } if (showallLink) { e.preventDefault(); // Toggle between showing all and going back to the original qperpage. if (Number(showallLink.dataset.status) === 0) { viewData.qperpage = extraparams.maxqperpage; showallLink.dataset.status = 1; showallLink.innerText = showPerPageText; } else { viewData.qperpage = extraparams.defaultqperpage; showallLink.dataset.status = 0; showallLink.innerText = showAllText; } viewData.qpage = 0; coreFilter.updateTableFromFilter(); } }); // Run apply filter at page load. let initialFilters; let jointype = null; if (pagevars.filter) { // Load initial filter based on page vars. initialFilters = pagevars.filter; if (pagevars.jointype) { jointype = pagevars.jointype; } } if (Object.entries(initialFilters).length !== 0) { // Remove the default empty filter row. const emptyFilterRow = filterSet.querySelector(Selectors.filterset.regions.emptyFilterRow); if (emptyFilterRow) { emptyFilterRow.remove(); } // Add filters. let rowcount = 0; for (const urlFilter in initialFilters) { if (urlFilter === 'jointype') { jointype = initialFilters[urlFilter]; continue; } // Add each filter row. rowcount += 1; const filterdata = { filtertype: urlFilter, values: initialFilters[urlFilter].values, jointype: initialFilters[urlFilter].jointype, filteroptions: initialFilters[urlFilter].filteroptions, rownum: rowcount }; coreFilter.addFilterRow(filterdata); } coreFilter.filterSet.dataset.filterverb = jointype; // Since we must filter by category, it does not make sense to allow the top-level "match any" or "match none" conditions, // as this would exclude the category. Remove those options and disable the select. const join = coreFilter.filterSet.querySelector(Selectors.filterset.fields.join); join.querySelectorAll(`option:not([value="${jointype}"])`).forEach((option) => option.remove()); join.disabled = true; } }; PK\x2 amd/src/question_engine.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript required by the question engine. * * @module core_question/question_engine * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as scrollManager from 'core/scroll_manager'; import * as formSubmit from 'core_form/submit'; /** * Initialise a question submit button. This saves the scroll position and * sets the fragment on the form submit URL so the page reloads in the right place. * * @param {string} button the id of the button in the HTML. */ export const initSubmitButton = button => { formSubmit.init(button); scrollManager.watchScrollButtonSaves(); }; /** * Initialise a form that contains questions printed using print_question. * This has the effect of: * 1. Turning off browser autocomlete. * 2. Stopping enter from submitting the form (or toggling the next flag) unless * keyboard focus is on the submit button or the flag. * 3. Removes any '.questionflagsavebutton's, since we have JavaScript to toggle * the flags using ajax. * 4. Scroll to the position indicated by scrollpos= in the URL, if it is there. * 5. Prevent the user from repeatedly submitting the form. * * @param {string} formSelector Selector to identify the form. */ export const initForm = (formSelector) => { const form = document.querySelector(formSelector); form.setAttribute('autocomplete', 'off'); form.addEventListener('submit', preventRepeatSubmission); form.addEventListener('key', (event) => { if (event.keyCode !== 13) { return; } if (event.target.matches('a')) { return; } if (event.target.matches('input[type="submit"]')) { return; } if (event.target.matches('input[type=img]')) { return; } if (event.target.matches('textarea') || event.target.matches('[contenteditable=true]')) { return; } event.preventDefault(); }); const questionFlagSaveButtons = form.querySelectorAll('.questionflagsavebutton'); [...questionFlagSaveButtons].forEach((node) => node.remove()); // Note: The scrollToSavedPosition function tries to wait until the content has loaded before firing. scrollManager.scrollToSavedPosition(); }; /** * Event handler to stop a question form being submitted more than once. * * @param {object} event the form submit event. */ export const preventRepeatSubmission = (event) => { const form = event.target.closest('form'); if (form.dataset.formSubmitted === '1') { event.preventDefault(); return; } setTimeout(() => { [...form.querySelectorAll('input[type=submit]')].forEach((input) => input.setAttribute('disabled', true)); }); form.dataset.formSubmitted = '1'; }; PK\V?  amd/src/refresh_ui.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Question bank UI refresh utility * * @module core_question/refresh_ui * @copyright 2023 Catalyst IT Europe Ltd. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Fragment from 'core/fragment'; import Templates from 'core/templates'; export default { /** * Reload the question bank UI, retaining the current filters and sort data. * * @param {Element} uiRoot The root element of the UI to be refreshed. Must contain "component", "callback" and "contextid" in * its data attributes, to be passed to the Fragment API. * @param {URL} returnUrl The url of the current page, containing filter and sort parameters. * @return {Promise} Resolved when the refresh is complete. */ refresh: (uiRoot, returnUrl) => { return new Promise((resolve, reject) => { const fragmentData = uiRoot.dataset; const viewData = {}; const sortData = {}; if (returnUrl) { returnUrl.searchParams.forEach((value, key) => { // Match keys like 'sortdata[fieldname]' and convert them to an array, // because the fragment API doesn't like non-alphanum argument keys. const sortItem = key.match(/sortdata\[([^\]]+)\]/); if (sortItem) { // The item returned by sortItem.pop() is the contents of the matching group, the field name. sortData[sortItem.pop()] = value; } else { viewData[key] = value; } }); } viewData.sortdata = JSON.stringify(sortData); // We have to use then() there, as loadFragment doesn't appear to work with await. Fragment.loadFragment(fragmentData.component, fragmentData.callback, fragmentData.contextid, viewData) .then((html, js) => { Templates.replaceNode(uiRoot, html, js); resolve(); return html; }) .catch(reject); }); } }; PK\r amd/src/repository.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * A javascript module to handle core_question ajax actions. * * @module core_question/repository * @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 */ import Ajax from 'core/ajax'; /** * @param {integer} newContextId target bank context id * @param {integer} newCategoryId target question category id * @param {string} questionIds questionIds comma separated list of question ids to move. * @param {string} returnUrl optional url to add/update the filter param with the new category id * @return {*} */ export const moveQuestions = ( newContextId, newCategoryId, questionIds, returnUrl = '', ) => Ajax.call([{ methodname: 'core_question_move_questions', args: { newcontextid: newContextId, newcategoryid: newCategoryId, questionids: questionIds, returnurl: returnUrl, }, }])[0]; PK\aWu$amd/src/question_banks_datasource.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Autocomplete data source for shared question banks. * * @module core_question/question_banks_datasource * @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 */ import {call as fetchMany} from 'core/ajax'; import Notification from 'core/notification'; export default { transport: function(selector, query, callback) { const element = document.querySelector(selector); const contextId = element.dataset.contextid; let requiredcapabilities = ['use']; if (element.dataset.requiredcapabilities) { requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities); } if (!contextId) { throw new Error('The attribute data-contextid is required on ' + selector); } fetchMany([{ methodname: 'core_question_search_shared_banks', args: { contextid: contextId, search: query, requiredcapabilities: requiredcapabilities, }, }])[0] .then(callback) .catch(Notification.exception); }, processResults: (selector, results) => { return results.sharedbanks; }, }; PK\"mamd/build/repository.min.jsnu[define("core_question/repository",["exports","core/ajax"],(function(_exports,_ajax){var obj; /** * A javascript module to handle core_question ajax actions. * * @module core_question/repository * @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 */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.moveQuestions=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.moveQuestions=function(newContextId,newCategoryId,questionIds){let returnUrl=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"";return _ajax.default.call([{methodname:"core_question_move_questions",args:{newcontextid:newContextId,newcategoryid:newCategoryId,questionids:questionIds,returnurl:returnUrl}}])[0]}})); //# sourceMappingURL=repository.min.js.mapPK\m3J J amd/build/question_engine.min.jsnu[define("core_question/question_engine",["exports","core/scroll_manager","core_form/submit"],(function(_exports,scrollManager,formSubmit){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} /** * JavaScript required by the question engine. * * @module core_question/question_engine * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.preventRepeatSubmission=_exports.initSubmitButton=_exports.initForm=void 0,scrollManager=_interopRequireWildcard(scrollManager),formSubmit=_interopRequireWildcard(formSubmit);_exports.initSubmitButton=button=>{formSubmit.init(button),scrollManager.watchScrollButtonSaves()};_exports.initForm=formSelector=>{const form=document.querySelector(formSelector);form.setAttribute("autocomplete","off"),form.addEventListener("submit",preventRepeatSubmission),form.addEventListener("key",(event=>{13===event.keyCode&&(event.target.matches("a")||event.target.matches('input[type="submit"]')||event.target.matches("input[type=img]")||event.target.matches("textarea")||event.target.matches("[contenteditable=true]")||event.preventDefault())}));[...form.querySelectorAll(".questionflagsavebutton")].forEach((node=>node.remove())),scrollManager.scrollToSavedPosition()};const preventRepeatSubmission=event=>{const form=event.target.closest("form");"1"!==form.dataset.formSubmitted?(setTimeout((()=>{[...form.querySelectorAll("input[type=submit]")].forEach((input=>input.setAttribute("disabled",!0)))})),form.dataset.formSubmitted="1"):event.preventDefault()};_exports.preventRepeatSubmission=preventRepeatSubmission})); //# sourceMappingURL=question_engine.min.js.mapPK\J+ + .amd/build/question_banks_datasource.min.js.mapnu[{"version":3,"file":"question_banks_datasource.min.js","sources":["../src/question_banks_datasource.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Autocomplete data source for shared question banks.\n *\n * @module core_question/question_banks_datasource\n * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\nexport default {\n\n transport: function(selector, query, callback) {\n const element = document.querySelector(selector);\n const contextId = element.dataset.contextid;\n let requiredcapabilities = ['use'];\n if (element.dataset.requiredcapabilities) {\n requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities);\n }\n\n if (!contextId) {\n throw new Error('The attribute data-contextid is required on ' + selector);\n }\n\n fetchMany([{\n methodname: 'core_question_search_shared_banks',\n args: {\n contextid: contextId,\n search: query,\n requiredcapabilities: requiredcapabilities,\n },\n }])[0]\n .then(callback)\n .catch(Notification.exception);\n },\n\n processResults: (selector, results) => {\n return results.sharedbanks;\n },\n};\n"],"names":["transport","selector","query","callback","element","document","querySelector","contextId","dataset","contextid","requiredcapabilities","JSON","parse","Error","methodname","args","search","then","catch","Notification","exception","processResults","results","sharedbanks"],"mappings":";;;;;;;;sKA2Be,CAEXA,UAAW,SAASC,SAAUC,MAAOC,gBAC3BC,QAAUC,SAASC,cAAcL,UACjCM,UAAYH,QAAQI,QAAQC,cAC9BC,qBAAuB,CAAC,UACxBN,QAAQI,QAAQE,uBAChBA,qBAAuBC,KAAKC,MAAMR,QAAQI,QAAQE,wBAGjDH,gBACK,IAAIM,MAAM,+CAAiDZ,yBAG3D,CAAC,CACPa,WAAY,oCACZC,KAAM,CACFN,UAAWF,UACXS,OAAQd,MACRQ,qBAAsBA,yBAE1B,GACHO,KAAKd,UACLe,MAAMC,sBAAaC,YAGxBC,eAAgB,CAACpB,SAAUqB,UAChBA,QAAQC"}PK\amd/build/refresh_ui.min.jsnu[define("core_question/refresh_ui",["exports","core/fragment","core/templates"],(function(_exports,_fragment,_templates){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Question bank UI refresh utility * * @module core_question/refresh_ui * @copyright 2023 Catalyst IT Europe Ltd. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_fragment=_interopRequireDefault(_fragment),_templates=_interopRequireDefault(_templates);var _default={refresh:(uiRoot,returnUrl)=>new Promise(((resolve,reject)=>{const fragmentData=uiRoot.dataset,viewData={},sortData={};returnUrl&&returnUrl.searchParams.forEach(((value,key)=>{const sortItem=key.match(/sortdata\[([^\]]+)\]/);sortItem?sortData[sortItem.pop()]=value:viewData[key]=value})),viewData.sortdata=JSON.stringify(sortData),_fragment.default.loadFragment(fragmentData.component,fragmentData.callback,fragmentData.contextid,viewData).then(((html,js)=>(_templates.default.replaceNode(uiRoot,html,js),resolve(),html))).catch(reject)}))};return _exports.default=_default,_exports.default})); //# sourceMappingURL=refresh_ui.min.js.mapPK\t>mmamd/build/repository.min.js.mapnu[{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle core_question ajax actions.\n *\n * @module core_question/repository\n * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Simon Adams \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * @param {integer} newContextId target bank context id\n * @param {integer} newCategoryId target question category id\n * @param {string} questionIds questionIds comma separated list of question ids to move.\n * @param {string} returnUrl optional url to add/update the filter param with the new category id\n * @return {*}\n */\nexport const moveQuestions = (\n newContextId,\n newCategoryId,\n questionIds,\n returnUrl = '',\n) => Ajax.call([{\n methodname: 'core_question_move_questions',\n args: {\n newcontextid: newContextId,\n newcategoryid: newCategoryId,\n questionids: questionIds,\n returnurl: returnUrl,\n },\n}])[0];\n"],"names":["newContextId","newCategoryId","questionIds","returnUrl","Ajax","call","methodname","args","newcontextid","newcategoryid","questionids","returnurl"],"mappings":";;;;;;;;sKAiC6B,SACzBA,aACAC,cACAC,iBACAC,iEAAY,UACXC,cAAKC,KAAK,CAAC,CACZC,WAAY,+BACZC,KAAM,CACFC,aAAcR,aACdS,cAAeR,cACfS,YAAaR,YACbS,UAAWR,cAEf"}PK\~aamd/build/refresh_ui.min.js.mapnu[{"version":3,"file":"refresh_ui.min.js","sources":["../src/refresh_ui.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Question bank UI refresh utility\n *\n * @module core_question/refresh_ui\n * @copyright 2023 Catalyst IT Europe Ltd.\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Fragment from 'core/fragment';\nimport Templates from 'core/templates';\n\nexport default {\n /**\n * Reload the question bank UI, retaining the current filters and sort data.\n *\n * @param {Element} uiRoot The root element of the UI to be refreshed. Must contain \"component\", \"callback\" and \"contextid\" in\n * its data attributes, to be passed to the Fragment API.\n * @param {URL} returnUrl The url of the current page, containing filter and sort parameters.\n * @return {Promise} Resolved when the refresh is complete.\n */\n refresh: (uiRoot, returnUrl) => {\n return new Promise((resolve, reject) => {\n const fragmentData = uiRoot.dataset;\n const viewData = {};\n const sortData = {};\n if (returnUrl) {\n returnUrl.searchParams.forEach((value, key) => {\n // Match keys like 'sortdata[fieldname]' and convert them to an array,\n // because the fragment API doesn't like non-alphanum argument keys.\n const sortItem = key.match(/sortdata\\[([^\\]]+)\\]/);\n if (sortItem) {\n // The item returned by sortItem.pop() is the contents of the matching group, the field name.\n sortData[sortItem.pop()] = value;\n } else {\n viewData[key] = value;\n }\n });\n }\n viewData.sortdata = JSON.stringify(sortData);\n // We have to use then() there, as loadFragment doesn't appear to work with await.\n Fragment.loadFragment(fragmentData.component, fragmentData.callback, fragmentData.contextid, viewData)\n .then((html, js) => {\n Templates.replaceNode(uiRoot, html, js);\n resolve();\n return html;\n })\n .catch(reject);\n });\n }\n};\n"],"names":["refresh","uiRoot","returnUrl","Promise","resolve","reject","fragmentData","dataset","viewData","sortData","searchParams","forEach","value","key","sortItem","match","pop","sortdata","JSON","stringify","loadFragment","component","callback","contextid","then","html","js","replaceNode","catch"],"mappings":";;;;;;;4LA0Be,CASXA,QAAS,CAACC,OAAQC,YACP,IAAIC,SAAQ,CAACC,QAASC,gBACnBC,aAAeL,OAAOM,QACtBC,SAAW,GACXC,SAAW,GACbP,WACAA,UAAUQ,aAAaC,SAAQ,CAACC,MAAOC,aAG7BC,SAAWD,IAAIE,MAAM,wBACvBD,SAEAL,SAASK,SAASE,OAASJ,MAE3BJ,SAASK,KAAOD,SAI5BJ,SAASS,SAAWC,KAAKC,UAAUV,4BAE1BW,aAAad,aAAae,UAAWf,aAAagB,SAAUhB,aAAaiB,UAAWf,UACxFgB,MAAK,CAACC,KAAMC,yBACCC,YAAY1B,OAAQwB,KAAMC,IACpCtB,UACOqB,QAEVG,MAAMvB"}PK\" amd/build/filter.min.jsnu[define("core_question/filter",["exports","core/datafilter","core/notification","core/datafilter/selectors","core/templates","core/fragment","core/str","core/loadingicon"],(function(_exports,_datafilter,_notification,_selectors,_templates,_fragment,_str,_loadingicon){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Question bank filter management. * * @module core_question/filter * @copyright 2021 Tomo Tsuyuki * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_datafilter=_interopRequireDefault(_datafilter),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_templates=_interopRequireDefault(_templates),_fragment=_interopRequireDefault(_fragment);_exports.init=async(filterRegionId,defaultcourseid,defaultcategoryid,perpage,bankContextId,quizCmId,component,callback,view,cmid,pagevars,extraparams)=>{var _document$querySelect,_document$querySelect2,_document$querySelect3,_document$querySelect4;const SELECTORS_QUESTION_CONTAINER_ID="#questionscontainer",SELECTORS_QUESTION_TABLE="#questionscontainer table",SELECTORS_SORT_LINK="#questionscontainer div.sorters a",SELECTORS_PAGINATION_LINK="#questionscontainer a[href].page-link",SELECTORS_LASTCHANGED_FIELD="#questionsubmit input[name=lastchanged]",SELECTORS_EDIT_SWITCH=".editmode-switch-form input[name=setmode]",SELECTORS_EDIT_SWITCH_URL=".editmode-switch-form input[name=pageurl]",SELECTORS_SHOW_ALL_LINK='[data-filteraction="showall"]',filterSet=document.querySelector("#".concat(filterRegionId)),viewData={extraparams:JSON.stringify(extraparams),cmid:cmid,view:view,cat:defaultcategoryid,courseid:defaultcourseid,filter:{},jointype:0,qpage:0,qperpage:perpage,sortdata:{},lastchanged:null!==(_document$querySelect=null===(_document$querySelect2=document.querySelector(SELECTORS_LASTCHANGED_FIELD))||void 0===_document$querySelect2?void 0:_document$querySelect2.value)&&void 0!==_document$querySelect?_document$querySelect:null};let sortData={};const defaultSort=null===(_document$querySelect3=document.querySelector(SELECTORS_QUESTION_TABLE))||void 0===_document$querySelect3||null===(_document$querySelect4=_document$querySelect3.dataset)||void 0===_document$querySelect4?void 0:_document$querySelect4.defaultsort;defaultSort&&(sortData=JSON.parse(defaultSort));const[showAllText,showPerPageText]=await Promise.all([(0,_str.getString)("showall","core",""),(0,_str.getString)("showperpage","core",extraparams.defaultqperpage)]),coreFilter=new _datafilter.default(filterSet,((filterdata,pendingPromise)=>{filterdata&&(viewData.jointype=parseInt(filterSet.dataset.filterverb,10),delete filterdata.jointype,viewData.filter=filterdata,0!==Object.keys(filterdata).length&&(isNaN(viewData.jointype)||(filterdata.jointype=viewData.jointype))),viewData.filter=JSON.stringify(filterdata),viewData.sortdata=JSON.stringify(sortData),viewData.quizcmid=quizCmId;const questionscontainer=document.querySelector(SELECTORS_QUESTION_CONTAINER_ID);questionscontainer.innerHTML="",(0,_loadingicon.addIconToContainerRemoveOnCompletion)(questionscontainer,pendingPromise),_fragment.default.loadFragment(component,callback,bankContextId,viewData).then(((questionhtml,jsfooter)=>(updateUrlParams(filterdata),void 0===questionhtml&&(questionhtml=""),void 0===jsfooter&&(jsfooter=""),_templates.default.replaceNode(questionscontainer,questionhtml,jsfooter),pendingPromise&&pendingPromise.resolve(),{questionhtml:questionhtml,jsfooter:jsfooter}))).catch(_notification.default.exception)}));coreFilter.activeFilters={},coreFilter.init();const updateUrlParams=filters=>{const url=new URL(location.href),filterQuery=JSON.stringify(filters);url.searchParams.set("filter",filterQuery),history.pushState(filters,"",url);const editSwitch=document.querySelector(SELECTORS_EDIT_SWITCH);if(editSwitch){const editSwitchUrlInput=document.querySelector(SELECTORS_EDIT_SWITCH_URL),editSwitchUrl=new URL(editSwitchUrlInput.value);editSwitchUrl.searchParams.set("filter",filterQuery),editSwitchUrlInput.value=editSwitchUrl,editSwitch.dataset.pageurl=editSwitchUrl}};let initialFilters;document.querySelector(".questionbankwindow").addEventListener("click",(e=>{const sortableLink=e.target.closest(SELECTORS_SORT_LINK),paginationLink=e.target.closest(SELECTORS_PAGINATION_LINK),clearLink=e.target.closest(_selectors.default.filterset.actions.resetFilters),showallLink=e.target.closest(SELECTORS_SHOW_ALL_LINK);if(sortableLink){e.preventDefault();const oldSort=sortData;sortData={},sortData[sortableLink.dataset.sortname]=sortableLink.dataset.sortorder;for(const sortname in oldSort)sortname!==sortableLink.dataset.sortname&&(sortData[sortname]=oldSort[sortname]);viewData.qpage=0,coreFilter.updateTableFromFilter(!1)}if(paginationLink){e.preventDefault();const paginationURL=new URL(paginationLink.getAttribute("href")),qpage=paginationURL.searchParams.get("qpage");null!==paginationURL.search&&(viewData.qpage=qpage,coreFilter.updateTableFromFilter(!1))}clearLink&&(()=>{const queryString=location.search,urlParams=new URLSearchParams(queryString);if(urlParams.has("cmid")){const cleanedUrl=new URL(location.href.replace(location.search,""));cleanedUrl.searchParams.set("cmid",urlParams.get("cmid")),history.pushState({},"",cleanedUrl)}if(urlParams.has("courseid")){const cleanedUrl=new URL(location.href.replace(location.search,""));cleanedUrl.searchParams.set("courseid",urlParams.get("courseid")),history.pushState({},"",cleanedUrl)}})(),showallLink&&(e.preventDefault(),0===Number(showallLink.dataset.status)?(viewData.qperpage=extraparams.maxqperpage,showallLink.dataset.status=1,showallLink.innerText=showPerPageText):(viewData.qperpage=extraparams.defaultqperpage,showallLink.dataset.status=0,showallLink.innerText=showAllText),viewData.qpage=0,coreFilter.updateTableFromFilter())}));let jointype=null;if(pagevars.filter&&(initialFilters=pagevars.filter,pagevars.jointype&&(jointype=pagevars.jointype)),0!==Object.entries(initialFilters).length){const emptyFilterRow=filterSet.querySelector(_selectors.default.filterset.regions.emptyFilterRow);emptyFilterRow&&emptyFilterRow.remove();let rowcount=0;for(const urlFilter in initialFilters){if("jointype"===urlFilter){jointype=initialFilters[urlFilter];continue}rowcount+=1;const filterdata={filtertype:urlFilter,values:initialFilters[urlFilter].values,jointype:initialFilters[urlFilter].jointype,filteroptions:initialFilters[urlFilter].filteroptions,rownum:rowcount};coreFilter.addFilterRow(filterdata)}coreFilter.filterSet.dataset.filterverb=jointype;const join=coreFilter.filterSet.querySelector(_selectors.default.filterset.fields.join);join.querySelectorAll('option:not([value="'.concat(jointype,'"])')).forEach((option=>option.remove())),join.disabled=!0}}})); //# sourceMappingURL=filter.min.js.mapPK\I BBamd/build/filter.min.js.mapnu[{"version":3,"file":"filter.min.js","sources":["../src/filter.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Question bank filter management.\n *\n * @module core_question/filter\n * @copyright 2021 Tomo Tsuyuki \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport CoreFilter from 'core/datafilter';\nimport Notification from 'core/notification';\nimport Selectors from 'core/datafilter/selectors';\nimport Templates from 'core/templates';\nimport Fragment from 'core/fragment';\nimport {getString} from 'core/str';\nimport {addIconToContainerRemoveOnCompletion} from 'core/loadingicon';\n\n/**\n * Initialise the question bank filter on the element with the given id.\n *\n * @param {String} filterRegionId ID of the HTML element containing the filters.\n * @param {String} defaultcourseid Course ID for the default course to pass back to the view.\n * @param {String} defaultcategoryid Question bank category ID for the default course to pass back to the view.\n * @param {Number} perpage The number of questions to display per page.\n * @param {Number} bankContextId Context ID of the question bank being filtered.\n * @param {Number} quizCmId Course module ID of the quiz as the viewing context.\n * @param {string} component Frankenstyle name of the component for the fragment API callback (e.g. core_question)\n * @param {string} callback Name of the callback for the fragment API (e.g question_data)\n * @param {string} view The class name of the question bank view class used for this page.\n * @param {Number} cmid If we are in an activitiy, the course module ID.\n * @param {Object} pagevars JSON-encoded parameters from passed from the view, including filters and jointype.\n * @param {Object} extraparams JSON-encoded additional parameters specific to this view class, used for re-rendering the view.\n */\nexport const init = async(\n filterRegionId,\n defaultcourseid,\n defaultcategoryid,\n perpage,\n bankContextId,\n quizCmId,\n component,\n callback,\n view,\n cmid,\n pagevars,\n extraparams,\n) => {\n\n const SELECTORS = {\n QUESTION_CONTAINER_ID: '#questionscontainer',\n QUESTION_TABLE: '#questionscontainer table',\n SORT_LINK: '#questionscontainer div.sorters a',\n PAGINATION_LINK: '#questionscontainer a[href].page-link',\n LASTCHANGED_FIELD: '#questionsubmit input[name=lastchanged]',\n BULK_ACTIONS: '#bulkactionsui-container input',\n MENU_ACTIONS: '.menu-action',\n EDIT_SWITCH: '.editmode-switch-form input[name=setmode]',\n EDIT_SWITCH_URL: '.editmode-switch-form input[name=pageurl]',\n SHOW_ALL_LINK: '[data-filteraction=\"showall\"]',\n };\n\n const filterSet = document.querySelector(`#${filterRegionId}`);\n\n const viewData = {\n extraparams: JSON.stringify(extraparams),\n cmid,\n view,\n cat: defaultcategoryid,\n courseid: defaultcourseid,\n filter: {},\n jointype: 0,\n qpage: 0,\n qperpage: perpage,\n sortdata: {},\n lastchanged: document.querySelector(SELECTORS.LASTCHANGED_FIELD)?.value ?? null,\n };\n\n let sortData = {};\n const defaultSort = document.querySelector(SELECTORS.QUESTION_TABLE)?.dataset?.defaultsort;\n if (defaultSort) {\n sortData = JSON.parse(defaultSort);\n }\n\n const [\n showAllText,\n showPerPageText,\n ] = await Promise.all([\n getString('showall', 'core', ''),\n getString('showperpage', 'core', extraparams.defaultqperpage),\n ]);\n\n /**\n * Retrieve table data.\n *\n * @param {Object} filterdata data\n * @param {Promise} pendingPromise pending promise\n */\n const applyFilter = (filterdata, pendingPromise) => {\n // Reload the questions based on the specified filters. If no filters are provided,\n // use the default category filter condition.\n if (filterdata) {\n // Main join types.\n viewData.jointype = parseInt(filterSet.dataset.filterverb, 10);\n delete filterdata.jointype;\n // Retrieve filter info.\n viewData.filter = filterdata;\n if (Object.keys(filterdata).length !== 0) {\n if (!isNaN(viewData.jointype)) {\n filterdata.jointype = viewData.jointype;\n }\n }\n }\n // Load questions for first page.\n viewData.filter = JSON.stringify(filterdata);\n viewData.sortdata = JSON.stringify(sortData);\n viewData.quizcmid = quizCmId;\n\n const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID);\n // Clear the contents of the element, then append the loading icon.\n questionscontainer.innerHTML = '';\n addIconToContainerRemoveOnCompletion(questionscontainer, pendingPromise);\n\n Fragment.loadFragment(component, callback, bankContextId, viewData)\n // Render questions for first page and pagination.\n .then((questionhtml, jsfooter) => {\n updateUrlParams(filterdata);\n if (questionhtml === undefined) {\n questionhtml = '';\n }\n if (jsfooter === undefined) {\n jsfooter = '';\n }\n Templates.replaceNode(questionscontainer, questionhtml, jsfooter);\n // Resolve filter promise.\n if (pendingPromise) {\n pendingPromise.resolve();\n }\n return {questionhtml, jsfooter};\n })\n .catch(Notification.exception);\n };\n\n // Init core filter processor with apply callback.\n const coreFilter = new CoreFilter(filterSet, applyFilter);\n coreFilter.activeFilters = {}; // Unset useless courseid filter.\n coreFilter.init();\n\n /**\n * Update URL Param based upon the current filter.\n *\n * @param {Object} filters Active filters.\n */\n const updateUrlParams = (filters) => {\n const url = new URL(location.href);\n const filterQuery = JSON.stringify(filters);\n url.searchParams.set('filter', filterQuery);\n history.pushState(filters, '', url);\n const editSwitch = document.querySelector(SELECTORS.EDIT_SWITCH);\n if (editSwitch) {\n const editSwitchUrlInput = document.querySelector(SELECTORS.EDIT_SWITCH_URL);\n const editSwitchUrl = new URL(editSwitchUrlInput.value);\n editSwitchUrl.searchParams.set('filter', filterQuery);\n editSwitchUrlInput.value = editSwitchUrl;\n editSwitch.dataset.pageurl = editSwitchUrl;\n }\n };\n\n /**\n * Cleans URL parameters.\n */\n const cleanUrlParams = () => {\n const queryString = location.search;\n const urlParams = new URLSearchParams(queryString);\n if (urlParams.has('cmid')) {\n const cleanedUrl = new URL(location.href.replace(location.search, ''));\n cleanedUrl.searchParams.set('cmid', urlParams.get('cmid'));\n history.pushState({}, '', cleanedUrl);\n }\n\n if (urlParams.has('courseid')) {\n const cleanedUrl = new URL(location.href.replace(location.search, ''));\n cleanedUrl.searchParams.set('courseid', urlParams.get('courseid'));\n history.pushState({}, '', cleanedUrl);\n }\n };\n\n // Add listeners for the sorting, paging and clear actions.\n document.querySelector('.questionbankwindow').addEventListener('click', e => {\n const sortableLink = e.target.closest(SELECTORS.SORT_LINK);\n const paginationLink = e.target.closest(SELECTORS.PAGINATION_LINK);\n const clearLink = e.target.closest(Selectors.filterset.actions.resetFilters);\n const showallLink = e.target.closest(SELECTORS.SHOW_ALL_LINK);\n if (sortableLink) {\n e.preventDefault();\n const oldSort = sortData;\n sortData = {};\n sortData[sortableLink.dataset.sortname] = sortableLink.dataset.sortorder;\n for (const sortname in oldSort) {\n if (sortname !== sortableLink.dataset.sortname) {\n sortData[sortname] = oldSort[sortname];\n }\n }\n viewData.qpage = 0;\n coreFilter.updateTableFromFilter(false);\n }\n if (paginationLink) {\n e.preventDefault();\n const paginationURL = new URL(paginationLink.getAttribute(\"href\"));\n const qpage = paginationURL.searchParams.get('qpage');\n if (paginationURL.search !== null) {\n viewData.qpage = qpage;\n coreFilter.updateTableFromFilter(false);\n }\n }\n if (clearLink) {\n cleanUrlParams();\n }\n if (showallLink) {\n\n e.preventDefault();\n\n // Toggle between showing all and going back to the original qperpage.\n if (Number(showallLink.dataset.status) === 0) {\n viewData.qperpage = extraparams.maxqperpage;\n showallLink.dataset.status = 1;\n showallLink.innerText = showPerPageText;\n } else {\n viewData.qperpage = extraparams.defaultqperpage;\n showallLink.dataset.status = 0;\n showallLink.innerText = showAllText;\n }\n viewData.qpage = 0;\n coreFilter.updateTableFromFilter();\n }\n });\n\n // Run apply filter at page load.\n let initialFilters;\n let jointype = null;\n if (pagevars.filter) {\n // Load initial filter based on page vars.\n initialFilters = pagevars.filter;\n if (pagevars.jointype) {\n jointype = pagevars.jointype;\n }\n }\n\n if (Object.entries(initialFilters).length !== 0) {\n // Remove the default empty filter row.\n const emptyFilterRow = filterSet.querySelector(Selectors.filterset.regions.emptyFilterRow);\n if (emptyFilterRow) {\n emptyFilterRow.remove();\n }\n\n // Add filters.\n let rowcount = 0;\n for (const urlFilter in initialFilters) {\n if (urlFilter === 'jointype') {\n jointype = initialFilters[urlFilter];\n continue;\n }\n // Add each filter row.\n rowcount += 1;\n const filterdata = {\n filtertype: urlFilter,\n values: initialFilters[urlFilter].values,\n jointype: initialFilters[urlFilter].jointype,\n filteroptions: initialFilters[urlFilter].filteroptions,\n rownum: rowcount\n };\n coreFilter.addFilterRow(filterdata);\n }\n coreFilter.filterSet.dataset.filterverb = jointype;\n\n // Since we must filter by category, it does not make sense to allow the top-level \"match any\" or \"match none\" conditions,\n // as this would exclude the category. Remove those options and disable the select.\n const join = coreFilter.filterSet.querySelector(Selectors.filterset.fields.join);\n join.querySelectorAll(`option:not([value=\"${jointype}\"])`).forEach((option) => option.remove());\n join.disabled = true;\n }\n};\n"],"names":["async","filterRegionId","defaultcourseid","defaultcategoryid","perpage","bankContextId","quizCmId","component","callback","view","cmid","pagevars","extraparams","SELECTORS","filterSet","document","querySelector","viewData","JSON","stringify","cat","courseid","filter","jointype","qpage","qperpage","sortdata","lastchanged","_document$querySelect2","value","sortData","defaultSort","_document$querySelect3","dataset","_document$querySelect4","defaultsort","parse","showAllText","showPerPageText","Promise","all","defaultqperpage","coreFilter","CoreFilter","filterdata","pendingPromise","parseInt","filterverb","Object","keys","length","isNaN","quizcmid","questionscontainer","innerHTML","loadFragment","then","questionhtml","jsfooter","updateUrlParams","undefined","replaceNode","resolve","catch","Notification","exception","activeFilters","init","filters","url","URL","location","href","filterQuery","searchParams","set","history","pushState","editSwitch","editSwitchUrlInput","editSwitchUrl","pageurl","initialFilters","addEventListener","e","sortableLink","target","closest","paginationLink","clearLink","Selectors","filterset","actions","resetFilters","showallLink","preventDefault","oldSort","sortname","sortorder","updateTableFromFilter","paginationURL","getAttribute","get","search","queryString","urlParams","URLSearchParams","has","cleanedUrl","replace","cleanUrlParams","Number","status","maxqperpage","innerText","entries","emptyFilterRow","regions","remove","rowcount","urlFilter","filtertype","values","filteroptions","rownum","addFilterRow","join","fields","querySelectorAll","forEach","option","disabled"],"mappings":"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"}PK\梏??$amd/build/question_engine.min.js.mapnu[{"version":3,"file":"question_engine.min.js","sources":["../src/question_engine.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * JavaScript required by the question engine.\n *\n * @module core_question/question_engine\n * @copyright 2021 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as scrollManager from 'core/scroll_manager';\nimport * as formSubmit from 'core_form/submit';\n\n/**\n * Initialise a question submit button. This saves the scroll position and\n * sets the fragment on the form submit URL so the page reloads in the right place.\n *\n * @param {string} button the id of the button in the HTML.\n */\nexport const initSubmitButton = button => {\n formSubmit.init(button);\n scrollManager.watchScrollButtonSaves();\n};\n\n/**\n * Initialise a form that contains questions printed using print_question.\n * This has the effect of:\n * 1. Turning off browser autocomlete.\n * 2. Stopping enter from submitting the form (or toggling the next flag) unless\n * keyboard focus is on the submit button or the flag.\n * 3. Removes any '.questionflagsavebutton's, since we have JavaScript to toggle\n * the flags using ajax.\n * 4. Scroll to the position indicated by scrollpos= in the URL, if it is there.\n * 5. Prevent the user from repeatedly submitting the form.\n *\n * @param {string} formSelector Selector to identify the form.\n */\nexport const initForm = (formSelector) => {\n const form = document.querySelector(formSelector);\n form.setAttribute('autocomplete', 'off');\n\n form.addEventListener('submit', preventRepeatSubmission);\n\n form.addEventListener('key', (event) => {\n if (event.keyCode !== 13) {\n return;\n }\n\n if (event.target.matches('a')) {\n return;\n }\n\n if (event.target.matches('input[type=\"submit\"]')) {\n return;\n }\n\n if (event.target.matches('input[type=img]')) {\n return;\n }\n\n if (event.target.matches('textarea') || event.target.matches('[contenteditable=true]')) {\n return;\n }\n\n event.preventDefault();\n });\n\n const questionFlagSaveButtons = form.querySelectorAll('.questionflagsavebutton');\n [...questionFlagSaveButtons].forEach((node) => node.remove());\n\n // Note: The scrollToSavedPosition function tries to wait until the content has loaded before firing.\n scrollManager.scrollToSavedPosition();\n};\n\n/**\n * Event handler to stop a question form being submitted more than once.\n *\n * @param {object} event the form submit event.\n */\nexport const preventRepeatSubmission = (event) => {\n const form = event.target.closest('form');\n if (form.dataset.formSubmitted === '1') {\n event.preventDefault();\n return;\n }\n\n setTimeout(() => {\n [...form.querySelectorAll('input[type=submit]')].forEach((input) => input.setAttribute('disabled', true));\n });\n form.dataset.formSubmitted = '1';\n};\n"],"names":["button","formSubmit","init","scrollManager","watchScrollButtonSaves","formSelector","form","document","querySelector","setAttribute","addEventListener","preventRepeatSubmission","event","keyCode","target","matches","preventDefault","querySelectorAll","forEach","node","remove","scrollToSavedPosition","closest","dataset","formSubmitted","setTimeout","input"],"mappings":";;;;;;;+QAgCgCA,SAC5BC,WAAWC,KAAKF,QAChBG,cAAcC,4CAgBOC,qBACfC,KAAOC,SAASC,cAAcH,cACpCC,KAAKG,aAAa,eAAgB,OAElCH,KAAKI,iBAAiB,SAAUC,yBAEhCL,KAAKI,iBAAiB,OAAQE,QACJ,KAAlBA,MAAMC,UAIND,MAAME,OAAOC,QAAQ,MAIrBH,MAAME,OAAOC,QAAQ,yBAIrBH,MAAME,OAAOC,QAAQ,oBAIrBH,MAAME,OAAOC,QAAQ,aAAeH,MAAME,OAAOC,QAAQ,2BAI7DH,MAAMI,yBAGsBV,KAAKW,iBAAiB,4BACzBC,SAASC,MAASA,KAAKC,WAGpDjB,cAAckB,+BAQLV,wBAA2BC,cAC9BN,KAAOM,MAAME,OAAOQ,QAAQ,QACC,MAA/BhB,KAAKiB,QAAQC,eAKjBC,YAAW,SACHnB,KAAKW,iBAAiB,uBAAuBC,SAASQ,OAAUA,MAAMjB,aAAa,YAAY,QAEvGH,KAAKiB,QAAQC,cAAgB,KAPzBZ,MAAMI"}PK\΍q]]*amd/build/question_banks_datasource.min.jsnu[define("core_question/question_banks_datasource",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){var obj; /** * Autocomplete data source for shared question banks. * * @module core_question/question_banks_datasource * @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 */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};var _default={transport:function(selector,query,callback){const element=document.querySelector(selector),contextId=element.dataset.contextid;let requiredcapabilities=["use"];if(element.dataset.requiredcapabilities&&(requiredcapabilities=JSON.parse(element.dataset.requiredcapabilities)),!contextId)throw new Error("The attribute data-contextid is required on "+selector);(0,_ajax.call)([{methodname:"core_question_search_shared_banks",args:{contextid:contextId,search:query,requiredcapabilities:requiredcapabilities}}])[0].then(callback).catch(_notification.default.exception)},processResults:(selector,results)=>results.sharedbanks};return _exports.default=_default,_exports.default})); //# sourceMappingURL=question_banks_datasource.min.js.mapPK\&& UPGRADING.mdnu[# core_question (subsystem) Upgrade notes ## 5.0.5 ### Fixed - In order to prevent re-use of question version numbers after a version is deleted, the `nextversion` column was added to `question_bank_entries`. This serves as a counter incremented each time a version is created. Do not query this field directly. Instead use `core_question\versions::get_next_version()` to read the value, which will initialise it based on the existing versions if it is not set yet. By default, it will increment the version number automatically, unless you pass `increment: false`. Because of this, it is advisable to call it inside a transaction, that is only committed after the version number is used in a `question_versions` record. For more information see [MDL-86798](https://tracker.moodle.org/browse/MDL-86798) ## 5.0.2 ### Added - The question backup API has been improved to only include questions that are actually used or owned by backed up activities. Any activities that use question references should be supported automatically. Activities that use *question set references* (for example, random quiz questions) need to add a call to `backup_question_set_reference_trait::annotate_set_reference_bank_entries()` alongside the call to `backup_question_set_reference_trait::add_question_set_references()` in their backup step. See `backup_quiz_activity_structure_step::define_structure()` for an example. For more information see [MDL-41924](https://tracker.moodle.org/browse/MDL-41924) ### Changed - `core_question_search_shared_banks` will now search all question banks, not just those outside the current course. This makes the service usable in cases outside of the current "Switch banks" UI, which require searching all banks on the site. It also makes the autocomplete in the "Switch banks" UI more consistent, as it was previously excluding some of the banks listed in the UI (Question banks in this course), but not others (Recently viewed question banks). This change has also adds a 'requiredcapabilties' parameter to the function, which accepts an list of abbreviated capabilities for checking access against question banks before they are returned. For more information see [MDL-85069](https://tracker.moodle.org/browse/MDL-85069) - `question_edit_contexts` now only considers the provided context when checking permissions, rather than all parent contexts as well. As questions now exist only at the activity module context level, permissions can be inherited or overridden as normal for each question bank. The previous pattern of checking for a permission in any parent context circumvented the override system, and no longer makes sense. For more information see [MDL-85754](https://tracker.moodle.org/browse/MDL-85754) ## 5.0.1 ### Fixed - The unit test repeated\_restore\_test::test\_restore\_course\_with\_same\_stamp\_questions was passing incorrectly on 5.x for question types that use answers. Maintainers of third-party question types may want to re-run the test with the fix in place, or if they have copied parts of this test as the basis of a test in their own plugin, review the changes and see if they should be reflected in their own test. For more information see [MDL-85556](https://tracker.moodle.org/browse/MDL-85556) ## 5.0 ### Added - The `get_bulk_actions()` method on the base `plugin_features_base` class has been changed to allow a qbank view object to be passed through. This is nullable and therefore optional for qbank plugins which don't need to do so. For more information see [MDL-79281](https://tracker.moodle.org/browse/MDL-79281) - Question bank Condition classes can now implement a function called "filter_invalid_values($filterconditions)" to remove anything from the filterconditions array which is invalid or should not be there. For more information see [MDL-83784](https://tracker.moodle.org/browse/MDL-83784) ### Changed - question_attempt_step's constructor now accepts the class constant TIMECREATED_ON_FIRST_RENDER as a value for the $timecreated parameter. Calling question_attempt::render for the first time will now set the first step's timecreated to the current time if it is set to this value. Note, null could not be used here as it is already used to indicate timecreated should be set to the current time. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) - The definition of the abstract `core_question\local\bank\condition` class has changed to make it clearer which methods are required in child classes. The `get_filter_class` method is no longer declared as abstract, and will return `null` by default to use the base `core/datafilter/filtertype` class. If you have defined this method to return `null` in your own class, it will continue to work, but it is no longer necessary. `build_query_from_filter` and `get_condition_key` are now declared as abstract, since all filter condition classes must define these (as well as existing abstract methods) to function. Again, exsiting child classes will continue to work if they did before, as they already needed these methods. For more information see [MDL-83859](https://tracker.moodle.org/browse/MDL-83859) ### Deprecated - question_type::generate_test No replacement, not used anywhere in core. For more information see [MDL-71378](https://tracker.moodle.org/browse/MDL-71378) - Deprecated method `mod_quiz\question\bank\qbank_helper::get_version_options` in favour of `core_question\local\bank\version_options::get_version_options` so that the method is in core rather than a module, and can safely be used from anywhere as required. For more information see [MDL-77713](https://tracker.moodle.org/browse/MDL-77713) - Behat steps `behat_qbank_comment::i_should_see_on_the_column` and `behat_qbank_comment::i_click_on_the_row_containing` have been deprecated in favour of the new component named selectors, `qbank_comment > Comment count link` and `qbank_comment > Comment count text` which can be used with the standard `should exist` and `I click on` steps to replace the custom steps. For more information see [MDL-79122](https://tracker.moodle.org/browse/MDL-79122) ### Removed - Final deprecation of: - core_question\local\bank\action_column_base::class - core_question\local\bank\menu_action_column_base::class - core_question\local\bank\menuable_action::interface - core_question\local\bank\view::print_choose_category_message() - core_question\local\bank\view::display_advanced_search_form() - core_question\local\bank\view::display_showtext_checkbox() - core_question\local\bank\view::init_search_conditions() - core_question\local\bank\view::get_current_category() - core_question\local\bank\view::display_options_form() - core_question\local\bank\view::start_table() - core_question\local\bank\view::end_table() - core_question\statistics\questions\all_calculated_for_qubaid_condition::TIME_TO_CACHE - core_question\statistics\responses\analyser::TIME_TO_CACHE - core_question_bank_renderer::render_category_condition_advanced() - core_question_bank_renderer::render_hidden_condition_advanced() - core_question_bank_renderer::render_category_condition() For more information see [MDL-78090](https://tracker.moodle.org/browse/MDL-78090) ### Fixed - Duplication or multiple restores of questions has been modified to avoid errors where a question with the same stamp already exists in the target category. To achieve this all data for the question is hashed, excluding any ID fields. The question data from the backup is first reformatted to match the questiondata structure returned by calling `get_question_options()` (see https://docs.moodle.org/dev/Question_data_structures#Representation_1:_%24questiondata). Common question elements will be handled automatically, but any elements that the qtype adds to the backup will need to be handled by overriding `restore_qtype_plugin::convert_backup_to_questiondata`. See `restore_qtype_match_plugin` as an example. If a qtype plugin calls any `$this->add_question_*()` methods in its `restore_qtype_*_plugin::define_question_plugin_structure()` method, the ID fields used in these records will be excluded automatically. If a qtype plugin defines its own tables with ID fields, it must define `restore_qtype_*_plugin::define_excluded_identity_hash_fields()` to return an array of paths to these fields within the question data. This should be all that is required for the majority of plugins. See the PHPDoc of `restore_qtype_plugin::define_excluded_identity_hash_fields()` for a full explanation of how these paths should be defined, and `restore_qtype_truefalse_plugin` for an example. If the data structure for a qtype returned by calling `get_question_options()` contains data other than ID fields that are not contained in the backup structure or vice-versa, it will need to override `restore_qtype_*_plugin::remove_excluded_question_data()` to remove the inconsistent data. See `restore_qtype_multianswer_plugin` as an example. For more information see [MDL-83541](https://tracker.moodle.org/browse/MDL-83541) ## 4.5 ### Added - A new utility function `\question_utils::format_question_fragment()` has been created so that question content can filter based on filters. For more information see [MDL-78662](https://tracker.moodle.org/browse/MDL-78662) ### Changed - `\core_question\local\bank\column_base::from_column_name()` method now accepts a `bool $ingoremissing` parameter, which can be used to ignore if the class does not exist, instead of throwing an exception. For more information see [MDL-81407](https://tracker.moodle.org/browse/MDL-81407) PK\ڡEiitests/versions_test.phpnu[. namespace core_question; use core\context\module; /** * Unit tests for versions * * @package core_question * @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 \core_question\versions */ final class versions_test extends \advanced_testcase { /** * Generate 3 questions - one with 3 versions, one with 2, and one with 1. * * @return array */ protected function create_question_versions(): array { $qbank = $this->getDataGenerator()->create_module('qbank', ['course' => SITEID]); $qbankcontext = module::instance($qbank->cmid); $category = question_get_default_category($qbankcontext->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $q1v1 = $questiongenerator->create_question('shortanswer', null, ['category' => $category->id]); $q1v2 = $questiongenerator->update_question($q1v1); $questiongenerator->update_question($q1v2); $q2v1 = $questiongenerator->create_question('shortanswer', null, ['category' => $category->id]); $questiongenerator->update_question($q2v1); $q3v1 = $questiongenerator->create_question('shortanswer', null, ['category' => $category->id]); return [ $q1v1->questionbankentryid, $q2v1->questionbankentryid, $q3v1->questionbankentryid, ]; } /** * We should get the correct next version for each question bank entry. */ public function test_get_next_version(): void { $this->resetAfterTest(); [$qbe1, $qbe2, $qbe3] = $this->create_question_versions(); $this->assertEquals(4, versions::get_next_version($qbe1)); $this->assertEquals(3, versions::get_next_version($qbe2)); $this->assertEquals(2, versions::get_next_version($qbe3)); } /** * Set the correct next version numbers for existing question bank entries. */ public function test_get_next_version_null(): void { global $DB; $this->resetAfterTest(); [$qbe1, $qbe2, $qbe3] = $this->create_question_versions(); // Null the nextversion values so they have to be recalculated. $DB->set_field('question_bank_entries', 'nextversion', null); $this->assertEquals(4, versions::get_next_version($qbe1)); $this->assertEquals(3, versions::get_next_version($qbe2)); $this->assertEquals(2, versions::get_next_version($qbe3)); } /** * The next version should be correctly incremented. */ public function test_increment_next_version(): void { $this->resetAfterTest(); global $DB; [$qbe1, $qbe2, $qbe3] = $this->create_question_versions(); $this->assertEquals(4, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe1])); $this->assertEquals(3, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe2])); $this->assertEquals(2, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe3])); $this->assertEquals(3, versions::get_next_version($qbe2)); // The specified question bank entry has had its nextversion incremented, the others are the same. $this->assertEquals(4, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe1])); $this->assertEquals(4, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe2])); $this->assertEquals(2, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe3])); } /** * We can get the next version without incrementing it. */ public function test_get_without_increment(): void { $this->resetAfterTest(); global $DB; [, $qbe2] = $this->create_question_versions(); $this->assertEquals(3, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe2])); $this->assertEquals(3, versions::get_next_version($qbe2, increment: false)); // The nextversion value has not changed. $this->assertEquals(3, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $qbe2])); } } PK\s mtests/importexport_test.phpnu[. /** * Unit tests for the question import and export system. * * @package core_question * @category test * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question; use qformat_default; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/question/format.php'); /** * Subclass to make it easier to test qformat_default. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_qformat extends qformat_default { public function assemble_category_path($names) { return parent::assemble_category_path($names); } public function split_category_path($names) { return parent::split_category_path($names); } } /** * Unit tests for the matching question definition class. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class importexport_test extends \advanced_testcase { public function test_assemble_category_path(): void { $format = new testable_qformat(); $pathsections = [ '$course$', "Tim's questions", "Tricky things like / // and so on", 'Category name ending in /', '/ and one that starts with one', 'Matematically Matematiskt (svenska)' ]; $this->assertEquals('$course$/Tim\'s questions/Tricky things like // //// and so on/Category name ending in // / // and one that starts with one/Matematically Matematiskt (svenska)', $format->assemble_category_path($pathsections)); } public function test_split_category_path(): void { $format = new testable_qformat(); $path = '$course$/Tim\'s questions/Tricky things like // //// and so on/Category name ending in // / // and one that starts with one/Matematically Matematiskt (svenska)'; $this->assertEquals([ '$course$', "Tim's questions", "Tricky things like / // and so on", 'Category name ending in /', '/ and one that starts with one', 'Matematically Matematiskt (svenska)' ], $format->split_category_path($path)); } public function test_split_category_path_cleans(): void { $format = new testable_qformat(); $path = 'Nasty thing'; $this->assertEquals(['Nasty thing'], $format->split_category_path($path)); } public function test_clean_question_name(): void { $format = new testable_qformat(); $name = 'Nice simple name'; $this->assertEquals($name, $format->clean_question_name($name)); $name = 'Question in EnglishFrench'; $this->assertEquals($name, $format->clean_question_name($name)); $name = 'Evil '; $this->assertEquals('Evil alert("You have been hacked!");', $format->clean_question_name($name)); $name = 'This is a very long question name. It goes on and on and on. ' . 'I wonder if it will ever stop. The quetsion name field in the database is only ' . 'two hundred and fifty five characters wide, so if the import file contains a ' . 'name longer than that, the code had better truncate it!'; $this->assertEquals(shorten_text($name, 251), $format->clean_question_name($name)); // The worst case scenario is a whole lot of single charaters in separate multilang tags. $name = 'A' . 'B' . 'C' . 'D' . 'E' . 'F' . 'G' . 'H' . 'I' . 'J'; $this->assertEquals(shorten_text($name, 1), $format->clean_question_name($name)); } public function test_create_default_question_name(): void { $format = new testable_qformat(); $text = 'Nice simple name'; $this->assertEquals($text, $format->create_default_question_name($text, 'Default')); $this->assertEquals('Default', $format->create_default_question_name('', 'Default')); $text = 'Question in EnglishFrench'; $this->assertEquals($text, $format->create_default_question_name($text, 'Default')); $text = 'Evil '; $this->assertEquals('Evil alert("You have been hacked!");', $format->create_default_question_name($text, 'Default')); $text = 'This is a very long question text. It goes on and on and on. ' . 'I wonder if it will ever stop. The question name field in the database is only ' . 'two hundred and fifty five characters wide, so if the import file contains a ' . 'name longer than that, the code had better truncate it!'; $this->assertEquals(shorten_text($text, 80), $format->create_default_question_name($text, 'Default')); // The worst case scenario is a whole lot of single charaters in separate multilang tags. $text = 'A' . 'B' . 'C' . 'D' . 'E' . 'F' . 'G' . 'H' . 'I' . 'J'; $this->assertEquals(shorten_text($text, 1), $format->create_default_question_name($text, 'Default')); } } PK\nbDD0tests/fixtures/testable_core_question_column.phpnu[. /** * Helper class to to test column_base class. * * @package core_question * @copyright 2018 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Helper class to to test column_base class. * * @package core_question * @copyright 2018 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_core_question_column extends \core_question\local\bank\column_base { /** @var array sortable columns. */ private $sortable = []; /** * Output the column header cell. */ public function is_sortable() { return $this->sortable; } /** * Set the sortable columns for testing. * * @param array $sortable */ public function set_sortable(array $sortable) { $this->sortable = $sortable; } protected function display_content($question, $rowclasses) { echo 'Test Column'; } public function get_name() { return 'test_column'; } public function get_title() { return 'Test Column'; } } PK\׷N.tests/fixtures/question_category_45_format.mbznu[=i6r:WvxěW#vb;8KEԐ;EPIԛ7nho~[˷8 ?tc9gc6.:*aVH "(*Mz Fz<GbLoNo:Ez yO/vD^okC+O z~&^Q*Jn;sbVuQ>PVp8PYd" 77qzۮ߇;8_;Sul8s0p0;<e' __[/G $_O^~5^1fd`K! (Ml  X%,b6ZQ pz(od|91!|t G0}t (GtFa2ck~m8\~4 O wa&_t^Bx̓G[K}^"}Tih(o<OM,戏ѿlMQKt P~o G<۴fH!VK(O2Xh_~ fcG@ PL,AAob_ YJll "(ЂP40Bp\"oD:H!,,j} ,MGn% m 4o|-d)T nSpGs5 (UL:U5\a2\Fۨ|[1sJ rMjJc֚ y)\Qf peQ^&p(3v/w̥0ҷ_J_*W lU(" B[;^Aq%|_bydU˙ؔ)\PB@w+&ɲA=G, J糱b̓E6=%vxw{.}ˬV0L0MCe!6DjPAMwl4r#.ۯ|(! /*^6I<y T$}'lLʊ!3^A5v=_~ \З&XbbkK b*k:˚H. --hj_sFdjEz:&S#ȉsTeB`voa<k&XF3R2qxw7CGOqO)GS܏Ox)S'SSz)zHtAi*- C#6 B?Ͳ/ɺ6`4|qƔ A 0cL% PaŁjmدEVD2ZbB ef X>I+ljεe冖YO/O(C0& $ rvx>MV"x ppfOeUH8MB -|e>ju&.%[vSL{}T4k$H-yaFA-SBk#ZZĈ^Ts!NAm cdz]5+A#Z8" rRרOh^E FtWt'GJ1٤IBVCUhCdZb(͐9.|𲗑vJ a 4WJ& 12Jr#dFoپnᅡ;}cWH'Ugߙړ{Ƕ[+0y_iPX5CGXΕF۞6g{q?T帶eM-K K,I,.GB [ܴԳCA 8ڸG'hU o*]fXf[T7e,ΘV AQӄf$oO ieFt9T$*9Q)1D%;؎M滍,ZZV)7^2ݱA,)S5`Ȳ/r?䯣5{dR%^&N5oΠ⎕D-d37.Nt$E@<~/75ϙ?.&m^܇B 8|Үz!cy G!q4:^c/q<[b 8x+̙wuc_tZ׍Rh "L;PxGVXaKji;Q'?0ߖfDNiO\گC&K1]߽ǧ;L@ ?zq?h+cn}Fqlo>pvS[JZ߇;OQ]rHƚ0>](H„mJ'`Woa>!A#z R1Rgn?-M U.+G <{%GAMf.ܒ0M;a76A7 1Ațڸ:0x|V`e[rxyAG& =B<0vdzq B02 L3, @F}Z\V""]Y&RHugY_.6Nc0rʵkysH0ێ?J{p#bmE'J.eY__wkXZh췸xڕ~}K^Gvٓfl,My=Nk$mD.&WTBUZ^G5H`zeVǬK.i4<pǮ~.KrE ?z5O׷G?Xח٘_cM uSKW |'cgqiPѐbRmW%3/am') 0".767X4طV˨<"aj=9`'?>/~}g~k/NgFv%D$4o*r8f{ jLiP:k/teT A@JKJKcX[!9u q] B1^41Zq/ ?g>0"E3ekt 3ElhGT d[{eީ[03_MvYڞM͝"X ƲneP,Jh64 YR ۯ|krvY3[`1.TpSz#o {7G]Z igVpL`4ݕ89'qZ?Zd8c?:\p2F8FJ1E4OJ2A3rϢPƢX||2s!t'L`ucELK˛dfܑr#)mҖwR[y&N7')u6u| XE5TϬ̈́GvbX?׿m\I?a'%_P;s$NjVH`c*'`P7x3}?OS7'R7YnNum M8hM4QNA cuh,׋&~[ECUsL]>LDY?)o_g?~4MCD#T8+*z8Z!dc& >(Z3<]eWglz),sR6ۦy uMhf0<9*.]?VWq{5o75V]S aJRKhoۿ}g ~:]$O꧞&WH!czϠj R{߿4PHY礐tPHϧ NFpOTHA!9A!5k7M`:>I]p{/?FOyoeCW[u_a>Ӝ^$)_L(LjO+ڿ2ސPxN=T"q~?vh4.Nt x<X9P߿`y{o>zuh;C|s=n ^ܱ?hg O/ ߋ;zO:dy7-HO=^jeZoQv%A^ $=S4wCWRBݦU](͢e?+eC}O\{6;SˊVq`Y!ס'ԼQT/h}4>"BuOI_?eeu=tӑJ#310ȶ#Ɋ(ѢeQ~LGg"JUUȃwtX4 yGW 2@/ii@2YV4qx/-k2ȏKhLq`+oiM,W*vҚ-3}D !18< jI4BD 'N Fs5Ri'O=n\:OȞ@{KW7,na5y|ssF\[WPK\tests/privacy_helper.phpnu[. /** * Helper for privacy tests. * * @package core_question * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); use \core_privacy\local\request\writer; /** * Helper for privacy tests. * * @package core_question * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait core_question_privacy_helper { /** * Assert that the question usage in the supplied slot matches the expected format * and usage for a question. * * @param \question_usage_by_activity $quba The Question Usage to test against. * @param int $slotno The slot number to compare * @param \question_display_options $options The display options used for formatting. * @param \stdClass $data The data to check. */ public function assert_question_slot_equals( \question_usage_by_activity $quba, $slotno, \question_display_options $options, $data ) { $attempt = $quba->get_question_attempt($slotno); $question = $attempt->get_question(false); // Check the question data exported. $this->assertEquals($data->name, $question->name); $this->assertEquals($data->question, $question->questiontext); // Check the answer exported. $this->assertEquals($attempt->get_response_summary(), $data->answer); if ($options->marks != \question_display_options::HIDDEN) { $this->assertEquals($attempt->get_mark(), $data->mark); } else { $this->assertFalse(isset($data->mark)); } if ($options->flags != \question_display_options::HIDDEN) { $this->assertEquals($attempt->is_flagged(), (int) $data->flagged); } else { $this->assertFalse(isset($data->flagged)); } if ($options->generalfeedback != \question_display_options::HIDDEN) { $this->assertEquals($question->format_generalfeedback($attempt), $data->generalfeedback); } else { $this->assertFalse(isset($data->generalfeedback)); } } /** * Assert that a question attempt was exported. * * @param \context $context The context which the attempt should be in * @param array $subcontext The base of the export * @param question_usage_by_activity $quba The question usage expected * @param \question_display_options $options The display options used for formatting. * @param \stdClass $user The user exported */ public function assert_question_attempt_exported(\context $context, array $subcontext, $quba, $options, $user) { $usagecontext = array_merge( $subcontext, [get_string('questions', 'core_question')] ); /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); foreach ($quba->get_slots() as $slotno) { $data = $writer->get_data(array_merge($usagecontext, [$slotno])); $this->assert_question_slot_equals($quba, $slotno, $options, $data); } } } PK\Ozz%tests/random_question_loader_test.phpnu[. namespace core_question; use core_question\local\bank\condition; use core_question\local\bank\question_version_status; use core_question\local\bank\random_question_loader; use core_question_generator; use mod_quiz\quiz_settings; use qubaid_list; use question_bank; use question_engine; use question_filter_test_helper; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Tests for the {@see \core_question\local\bank\random_question_loader} class. * * @package core_question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \random_question_loader */ final class random_question_loader_test extends \advanced_testcase { use \quiz_question_helper_test_trait; public function test_empty_category_gives_null(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); $filters = question_filter_test_helper::create_filters([$cat->id], 1); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_unknown_category_behaves_like_empty(): void { // It is up the caller to make sure the category id is valid. $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([-1], 1); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_descriptions_not_returned(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $info = $generator->create_question('description', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_hidden_questions_not_returned(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $DB->set_field('question_versions', 'status', \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question1->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_cloze_subquestions_not_returned(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('multianswer', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters)); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_random_questions_not_returned(): void { $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course]); $this->add_random_questions($quiz->id, 1, $cat->id, 1); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_draft_questions_not_returned(): void { $this->resetAfterTest(); $this->setAdminUser(); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question in draft state. $category = $questiongenerator->create_question_category(); $questiongenerator->create_question('shortanswer', null, ['category' => $category->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]); // Try to a random question from that category - should not be one. $filtercondition = [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$category->id], 'filteroptions' => ['includesubcategories' => false], ], ]; $loader = new random_question_loader(new qubaid_list([])); $this->assertNull($loader->get_next_filtered_question_id($filtercondition)); } public function test_questions_with_later_draft_version_is_returned(): void { $this->resetAfterTest(); $this->setAdminUser(); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question in draft state. $category = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('shortanswer', null, ['questiontext' => 'V1', 'category' => $category->id]); $questiongenerator->update_question($question, null, ['questiontext' => 'V2', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); // Try to a random question from that category - should get V1. $filtercondition = [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$category->id], 'filteroptions' => ['includesubcategories' => false], ], ]; $loader = new random_question_loader(new qubaid_list([])); $this->assertEquals($question->id, $loader->get_next_filtered_question_id($filtercondition)); } public function test_one_question_category_returns_that_q_then_null(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id], 1); $this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters)); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_two_question_category_returns_both_then_null(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $question2 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $questionids = []; $filters = question_filter_test_helper::create_filters([$cat->id]); $questionids[] = $loader->get_next_filtered_question_id($filters); $questionids[] = $loader->get_next_filtered_question_id($filters); sort($questionids); $this->assertEquals([$question1->id, $question2->id], $questionids); $filters = question_filter_test_helper::create_filters([$cat->id], 1); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_nested_categories(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat1 = $generator->create_question_category(); $cat2 = $generator->create_question_category(['parent' => $cat1->id]); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat1->id]); $question2 = $generator->create_question('shortanswer', null, ['category' => $cat2->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat2->id], 1); $this->assertEquals($question2->id, $loader->get_next_filtered_question_id($filters)); $filters = question_filter_test_helper::create_filters([$cat1->id], 1); $this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters)); $filters = question_filter_test_helper::create_filters([$cat1->id]); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_used_question_not_returned_until_later(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $question2 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([]), [$question2->id => 2]); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters)); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_previously_used_question_not_returned_until_later(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $question2 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $question = question_bank::load_question($question2->id); $quba->add_question($question); $quba->add_question($question); $quba->start_all_questions(); question_engine::save_questions_usage_by_activity($quba); $loader = new random_question_loader(new qubaid_list([$quba->get_id()])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters)); $this->assertEquals($question2->id, $loader->get_next_filtered_question_id($filters)); $this->assertNull($loader->get_next_filtered_question_id($filters)); } public function test_empty_category_does_not_have_question_available(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertFalse($loader->is_filtered_question_available($filters, 1)); $filters = question_filter_test_helper::create_filters([$cat->id], 1); $this->assertFalse($loader->is_filtered_question_available($filters, 1)); } public function test_descriptions_not_available(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $info = $generator->create_question('description', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertFalse($loader->is_filtered_question_available($filters, $info->id)); $filters = question_filter_test_helper::create_filters([$cat->id], 1); $this->assertFalse($loader->is_filtered_question_available($filters, $info->id)); } public function test_existing_question_is_available_but_then_marked_used(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$cat->id]); $this->assertTrue($loader->is_filtered_question_available($filters, $question1->id)); $this->assertFalse($loader->is_filtered_question_available($filters, $question1->id)); $this->assertFalse($loader->is_filtered_question_available($filters, -1)); } /** * Data provider for the get_questions test. * * @return array testcases. */ public static function get_questions_test_cases(): array { return [ 'empty category' => [ 'categoryindex' => 'emptycat', 'includesubcategories' => false, 'usetagnames' => [], 'expectedquestionindexes' => [] ], 'single category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => [], 'expectedquestionindexes' => ['cat1q1', 'cat1q2'] ], 'include sub category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => [], 'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2'] ], 'single category with tags' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => ['cat1'], 'expectedquestionindexes' => ['cat1q1'] ], 'include sub category with tag on parent' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1'], 'expectedquestionindexes' => ['cat1q1'] ], 'include sub category with tag on sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['subcat'], 'expectedquestionindexes' => ['subcatq1'] ], 'include sub category with same tag on parent and sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['foo'], 'expectedquestionindexes' => ['cat1q1', 'subcatq1'] ], 'include sub category with tag not matching' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1', 'cat2'], 'expectedquestionindexes' => [] ] ]; } /** * Test the get_questions function with various parameter combinations. * * This function creates a data set as follows: * Category: cat1 * Question: cat1q1 * Tags: 'cat1', 'foo' * Question: cat1q2 * Category: cat2 * Question: cat2q1 * Tags: 'cat2', 'foo' * Question: cat2q2 * Category: subcat * Question: subcatq1 * Tags: 'subcat', 'foo' * Question: subcatq2 * Parent: cat1 * Category: emptycat * * @dataProvider get_questions_test_cases * @param string $categoryindex The named index for the category to use * @param bool $includesubcategories If the search should include subcategories * @param string[] $usetagnames The tag names to include in the search * @param string[] $expectedquestionindexes The questions expected in the result */ public function test_get_questions_variations( $categoryindex, $includesubcategories, $usetagnames, $expectedquestionindexes ): void { $this->resetAfterTest(); $categories = []; $questions = []; $tagnames = [ 'cat1', 'cat2', 'subcat', 'foo' ]; $collid = \core_tag_collection::get_default(); $tags = \core_tag_tag::create_if_missing($collid, $tagnames); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); // First category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat1', 'foo']); $categories['cat1'] = $category; $questions['cat1q1'] = $categoryquestions[0]; $questions['cat1q2'] = $categoryquestions[1]; // Second category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat2', 'foo']); $categories['cat2'] = $category; $questions['cat2q1'] = $categoryquestions[0]; $questions['cat2q2'] = $categoryquestions[1]; // Sub category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']); $categories['subcat'] = $category; $questions['subcatq1'] = $categoryquestions[0]; $questions['subcatq2'] = $categoryquestions[1]; // Empty category. [$category, $categoryquestions] = $this->create_category_and_questions(0); $categories['emptycat'] = $category; // Generate the arguments for the get_questions function. $category = $categories[$categoryindex]; $tagids = array_map(function($tagname) use ($tags) { return $tags[$tagname]->id; }, $usetagnames); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids); $result = $loader->get_filtered_questions($filters); // Generate the expected question set. $expectedquestions = array_map(function($index) use ($questions) { return $questions[$index]; }, $expectedquestionindexes); // Ensure the result matches what was expected. $this->assertCount(count($expectedquestions), $result); foreach ($expectedquestions as $question) { $this->assertEquals($result[$question->id]->id, $question->id); $this->assertEquals($result[$question->id]->category, $question->category); } } /** * get_questions should allow limiting and offsetting of the result set. */ public function test_get_questions_with_limit_and_offset(): void { $this->resetAfterTest(); $numberofquestions = 5; $includesubcategories = false; $tagids = []; $limit = 1; $offset = 0; $loader = new random_question_loader(new qubaid_list([])); [$category, $questions] = $this->create_category_and_questions($numberofquestions); // Add questionid as key to find them easily later. $questionsbyid = []; array_walk($questions, function (&$value) use (&$questionsbyid) { $questionsbyid[$value->id] = $value; }); $filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids); for ($i = 0; $i < $numberofquestions; $i++) { $result = $loader->get_filtered_questions( $filters, $limit, $offset ); $this->assertCount($limit, $result); $actual = array_shift($result); $expected = $questionsbyid[$actual->id]; $this->assertEquals($expected->id, $actual->id); $offset++; } } /** * get_questions should allow retrieving questions with only a subset of * fields populated. */ public function test_get_questions_with_restricted_fields(): void { $this->resetAfterTest(); $includesubcategories = false; $tagids = []; $limit = 10; $offset = 0; $fields = ['id', 'name']; $loader = new random_question_loader(new qubaid_list([])); [$category, $questions] = $this->create_category_and_questions(1); $filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids); $result = $loader->get_filtered_questions( $filters, $limit, $offset, $fields ); $expectedquestion = array_shift($questions); $actualquestion = array_shift($result); $actualfields = get_object_vars($actualquestion); $actualfields = array_keys($actualfields); sort($actualfields); sort($fields); $this->assertEquals($fields, $actualfields); } /** * Data provider for the count_questions test. * * @return array testcases. */ public static function count_questions_test_cases(): array { return [ 'empty category' => [ 'categoryindex' => 'emptycat', 'includesubcategories' => false, 'usetagnames' => [], 'expectedcount' => 0 ], 'single category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => [], 'expectedcount' => 2 ], 'include sub category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => [], 'expectedcount' => 4 ], 'single category with tags' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => ['cat1'], 'expectedcount' => 1 ], 'include sub category with tag on parent' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1'], 'expectedcount' => 1 ], 'include sub category with tag on sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['subcat'], 'expectedcount' => 1 ], 'include sub category with same tag on parent and sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['foo'], 'expectedcount' => 2 ], 'include sub category with tag not matching' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1', 'cat2'], 'expectedcount' => 0 ] ]; } /** * Test the count_questions function with various parameter combinations. * * This function creates a data set as follows: * Category: cat1 * Question: cat1q1 * Tags: 'cat1', 'foo' * Question: cat1q2 * Category: cat2 * Question: cat2q1 * Tags: 'cat2', 'foo' * Question: cat2q2 * Category: subcat * Question: subcatq1 * Tags: 'subcat', 'foo' * Question: subcatq2 * Parent: cat1 * Category: emptycat * * @dataProvider count_questions_test_cases * @param string $categoryindex The named index for the category to use * @param bool $includesubcategories If the search should include subcategories * @param string[] $usetagnames The tag names to include in the search * @param int $expectedcount The number of questions expected in the result */ public function test_count_questions_variations( $categoryindex, $includesubcategories, $usetagnames, $expectedcount ): void { $this->resetAfterTest(); $categories = []; $questions = []; $tagnames = [ 'cat1', 'cat2', 'subcat', 'foo' ]; $collid = \core_tag_collection::get_default(); $tags = \core_tag_tag::create_if_missing($collid, $tagnames); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); // First category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat1', 'foo']); $categories['cat1'] = $category; $questions['cat1q1'] = $categoryquestions[0]; $questions['cat1q2'] = $categoryquestions[1]; // Second category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat2', 'foo']); $categories['cat2'] = $category; $questions['cat2q1'] = $categoryquestions[0]; $questions['cat2q2'] = $categoryquestions[1]; // Sub category and questions. [$category, $categoryquestions] = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']); $categories['subcat'] = $category; $questions['subcatq1'] = $categoryquestions[0]; $questions['subcatq2'] = $categoryquestions[1]; // Empty category. [$category, $categoryquestions] = $this->create_category_and_questions(0); $categories['emptycat'] = $category; // Generate the arguments for the get_questions function. $category = $categories[$categoryindex]; $tagids = array_map(function($tagname) use ($tags) { return $tags[$tagname]->id; }, $usetagnames); $filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids); $loader = new random_question_loader(new qubaid_list([])); $result = $loader->count_filtered_questions($filters); // Ensure the result matches what was expected. $this->assertEquals($expectedcount, $result); } /** * Create a question category and create questions in that category. Tag * the first question in each category with the given tags. * * @param int $questioncount How many questions to create. * @param string[] $tagnames The list of tags to use. * @param stdClass|null $parentcategory The category to set as the parent of the created category. * @return array The category and questions. */ protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null): array { $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); if ($parentcategory) { $catparams = ['parent' => $parentcategory->id]; } else { $catparams = []; } $category = $generator->create_question_category($catparams); $questions = []; for ($i = 0; $i < $questioncount; $i++) { $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]); } if (!empty($tagnames) && !empty($questions)) { $context = \context::instance_by_id($category->contextid); \core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames); } return [$category, $questions]; } /** * Test that the random question loader excludes questions with invalid types. * @return void * @throws \dml_exception */ public function test_invalid_questions_are_excluded(): void { global $DB; $this->resetAfterTest(); [$category, $questions] = $this->create_category_and_questions(4); $loader = new random_question_loader(new qubaid_list([])); $filters = question_filter_test_helper::create_filters([$category->id], false, []); // Update one of the questions to have an invalid type. $invalid = $questions[0]; $invalid->qtype = 'invalid'; $DB->update_record('question', $invalid); // Assert that we get 1 less result back because the invalid type is excluded. $result = $loader->get_filtered_questions($filters); $this->assertEquals(count($questions) - 1, count($result)); } } PK\+PP*tests/least_used_variant_strategy_test.phpnu[. namespace core_question; use core_question\engine\variants\least_used_strategy; use qubaid_list; use question_bank; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Tests for the {@link core_question\engine\variants\least_used_strategy} class. * * @package core_question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class least_used_variant_strategy_test extends \advanced_testcase { public function test_question_with_one_variant_always_picks_that(): void { $question = \test_question_maker::make_question('shortanswer'); $quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $slot = $quba->add_question($question); $quba->start_all_questions(new least_used_strategy( $quba, new qubaid_list([]))); $this->assertEquals(1, $quba->get_variant($slot)); } public function test_synchronised_question_should_use_the_same_dataset(): void { // Actually, we cheat here. We use the same question twice, not two different synchronised questions. $question = \test_question_maker::make_question('calculated'); $quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $slot1 = $quba->add_question($question); $slot2 = $quba->add_question($question); $quba->start_all_questions(new least_used_strategy( $quba, new qubaid_list([]))); $this->assertEquals($quba->get_variant($slot1), $quba->get_variant($slot2)); } public function test_second_attempt_uses_other_dataset(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(); $questiondata = $generator->create_question('calculated', null, ['category' => $cat->id]); // Create two dataset items. $adefinitionid = $DB->get_field_sql(" SELECT qdd.id FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qd.datasetdefinition = qdd.id WHERE qd.question = ? AND qdd.name = ?", [$questiondata->id, 'a']); $bdefinitionid = $DB->get_field_sql(" SELECT qdd.id FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qd.datasetdefinition = qdd.id WHERE qd.question = ? AND qdd.name = ?", [$questiondata->id, 'b']); $DB->set_field('question_dataset_definitions', 'itemcount', 2, ['id' => $adefinitionid]); $DB->set_field('question_dataset_definitions', 'itemcount', 2, ['id' => $bdefinitionid]); $DB->insert_record('question_dataset_items', ['definition' => $adefinitionid, 'itemnumber' => 1, 'value' => 3]); $DB->insert_record('question_dataset_items', ['definition' => $bdefinitionid, 'itemnumber' => 1, 'value' => 7]); $DB->insert_record('question_dataset_items', ['definition' => $adefinitionid, 'itemnumber' => 2, 'value' => 6]); $DB->insert_record('question_dataset_items', ['definition' => $bdefinitionid, 'itemnumber' => 2, 'value' => 4]); $question = question_bank::load_question($questiondata->id); $quba1 = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba1->set_preferred_behaviour('deferredfeedback'); $slot1 = $quba1->add_question($question); $quba1->start_all_questions(new least_used_strategy( $quba1, new qubaid_list([]))); question_engine::save_questions_usage_by_activity($quba1); $variant1 = $quba1->get_variant($slot1); // Second attempt should use the other variant. $quba2 = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba2->set_preferred_behaviour('deferredfeedback'); $slot2 = $quba2->add_question($question); $quba2->start_all_questions(new least_used_strategy( $quba1, new qubaid_list([$quba1->get_id()]))); question_engine::save_questions_usage_by_activity($quba2); $variant2 = $quba2->get_variant($slot2); $this->assertNotEquals($variant1, $variant2); // Third attempt uses either variant at random. $quba3 = question_engine::make_questions_usage_by_activity('test', \context_system::instance()); $quba3->set_preferred_behaviour('deferredfeedback'); $slot3 = $quba3->add_question($question); $quba3->start_all_questions(new least_used_strategy( $quba1, new qubaid_list([$quba1->get_id(), $quba2->get_id()]))); $variant3 = $quba3->get_variant($slot3); $this->assertTrue($variant3 == $variant1 || $variant3 == $variant2); } } PK\yn99tests/generator/lib.phpnu[. /** * Quiz module test data generator. * * @package core_question * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_question\local\bank\question_version_status; use core\exception\coding_exception; /** * Class core_question_generator for generating question data. * * @package core_question * @copyright 2013 The Open University * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_question_generator extends component_generator_base { /** * @var number of created instances */ protected $categorycount = 0; /** * Make the category count to zero. */ public function reset() { $this->categorycount = 0; } /** * Create a new question category. * * @param array|stdClass $record * @return stdClass question_categories record. */ public function create_question_category($record = null) { global $DB; $this->categorycount ++; $defaults = [ 'name' => 'Test question category ' . $this->categorycount, 'info' => '', 'infoformat' => FORMAT_HTML, 'stamp' => make_unique_id_code(), 'idnumber' => null, ]; $record = $this->datagenerator->combine_defaults_and_record($defaults, $record); if (!isset($record['contextid'])) { if (isset($record['parent'])) { $record['contextid'] = $DB->get_field('question_categories', 'contextid', ['id' => $record['parent']]); } else { $qbank = $this->datagenerator->create_module('qbank', ['course' => SITEID]); $record['contextid'] = context_module::instance($qbank->cmid)->id; } } else { // Any requests for a question category in a contextlevel that is no longer supported // will have a qbank instance created on the associated context and then the category // will be made for that context instead. $context = context::instance_by_id($record['contextid']); if ($context->contextlevel !== CONTEXT_MODULE) { $course = match ($context->contextlevel) { CONTEXT_COURSE => get_course($context->instanceid), CONTEXT_SYSTEM => get_site(), CONTEXT_COURSECAT => $this->datagenerator->create_course(['category' => $context->instanceid]), default => throw new \Exception('Invalid context to infer a question bank from.'), }; $qbank = \core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true); $bankcontext = context_module::instance($qbank->id); } else { $bankcontext = $context; } $record['contextid'] = $bankcontext->id; } if (!isset($record['parent'])) { $record['parent'] = question_get_top_category($record['contextid'], true)->id; } if (!isset($record['sortorder'])) { $manager = new \core_question\category_manager(); $record['sortorder'] = $manager->get_max_sortorder($record['parent']) + 1; } $record['id'] = $DB->insert_record('question_categories', $record); return (object) $record; } /** * Create a new question. The question is initialised using one of the * examples from the appropriate {@link question_test_helper} subclass. * Then, any files you want to change from the value in the base example you * can override using $overrides. * * @param string $qtype the question type to create an example of. * @param string $which as for the corresponding argument of * {@link question_test_helper::get_question_form_data}. null for the default one. * @param array|stdClass $overrides any fields that should be different from the base example. * @return stdClass the question data. */ public function create_question($qtype, $which = null, $overrides = null) { $question = new stdClass(); $question->qtype = $qtype; $question->createdby = 0; $question->idnumber = null; $question->status = question_version_status::QUESTION_STATUS_READY; return $this->update_question($question, $which, $overrides); } /** * Create a tag on a question. * * @param array $data with two elements ['questionid' => 123, 'tag' => 'mytag']. */ public function create_question_tag(array $data): void { $question = question_bank::load_question($data['questionid']); core_tag_tag::add_item_tag('core_question', 'question', $question->id, context::instance_by_id($question->contextid), $data['tag'], 0); } /** * Update an existing question. * * @param stdClass $question the question data to update. * @param string $which as for the corresponding argument of * {@link question_test_helper::get_question_form_data}. null for the default one. * @param array|stdClass $overrides any fields that should be different from the base example. * @return stdClass the question data, including version info and questionbankentryid */ public function update_question($question, $which = null, $overrides = null) { global $CFG, $DB; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); $question = clone($question); $qtype = $question->qtype; $fromform = test_question_maker::get_question_form_data($qtype, $which); $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $question, $fromform); $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $fromform, $overrides); $fromform->status = $fromform->status ?? $question->status; $question = question_bank::get_qtype($qtype)->save_question($question, $fromform); $validoverrides = ['createdby', 'modifiedby', 'timemodified']; if ($overrides && !empty(array_intersect($validoverrides, array_keys($overrides)))) { // Manually update the createdby, modifiedby and timemodified because questiontypebase forces // current user and time and some tests require a specific user or time. foreach ($validoverrides as $validoverride) { if (array_key_exists($validoverride, $overrides)) { $question->{$validoverride} = $overrides[$validoverride]; } } $DB->update_record('question', $question); } $questionversion = $DB->get_record('question_versions', ['questionid' => $question->id], '*', MUST_EXIST); $question->versionid = $questionversion->id; $question->questionbankentryid = $questionversion->questionbankentryid; $question->version = $questionversion->version; $question->status = $questionversion->status; return $question; } /** * Set up a course category, a course, a mod_qbank instance, a question category for the mod_qbank instance, * and 2 questions for testing. * * @return array of the data objects mentioned above */ public function setup_course_and_questions() { $datagenerator = $this->datagenerator; $category = $datagenerator->create_category(); $course = $datagenerator->create_course([ 'numsections' => 5, 'category' => $category->id ]); $qbank = $datagenerator->create_module('qbank', ['course' => $course->id]); $context = context_module::instance($qbank->cmid); $qcat = $this->create_question_category(['contextid' => $context->id]); $questions = [ $this->create_question('shortanswer', null, ['category' => $qcat->id]), $this->create_question('shortanswer', null, ['category' => $qcat->id]), ]; return [$category, $course, $qcat, $questions, $qbank]; } /** * This method can construct what the post data would be to simulate a user submitting * responses to a number of questions within a question usage. * * In the responses array, the array keys are the slot numbers for which a response will * be submitted. You can submit a response to any number of questions within the usage. * There is no need to do them all. The values are a string representation of the response. * The exact meaning of that depends on the particular question type. These strings * are passed to the un_summarise_response method of the question to decode. * * @param question_usage_by_activity $quba the question usage. * @param array $responses the responses to submit, in the format described above. * @param bool $checkbutton if simulate a click on the check button for each question, else simulate save. * This should only be used with behaviours that have a check button. * @return array that can be passed to methods like $quba->process_all_actions as simulated POST data. */ public function get_simulated_post_data_for_questions_in_usage( question_usage_by_activity $quba, array $responses, $checkbutton) { $postdata = []; foreach ($responses as $slot => $responsesummary) { $postdata += $this->get_simulated_post_data_for_question_attempt( $quba->get_question_attempt($slot), $responsesummary, $checkbutton); } return $postdata; } /** * This method can construct what the post data would be to simulate a user submitting * responses to one particular question attempt. * * The $responsesummary is a string representation of the response to be submitted. * The exact meaning of that depends on the particular question type. These strings * are passed to the un_summarise_response method of the question to decode. * * @param question_attempt $qa the question attempt for which we are generating POST data. * @param string $responsesummary a textual summary of the response, as described above. * @param bool $checkbutton if simulate a click on the check button, else simulate save. * This should only be used with behaviours that have a check button. * @return array the simulated post data that can be passed to $quba->process_all_actions. */ public function get_simulated_post_data_for_question_attempt( question_attempt $qa, $responsesummary, $checkbutton) { $question = $qa->get_question(); if (!$question instanceof question_with_responses) { return []; } $postdata = []; $postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count(); $postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged(); $response = $question->un_summarise_response($responsesummary); foreach ($response as $name => $value) { $postdata[$qa->get_qt_field_name($name)] = (string)$value; } // TODO handle behaviour variables better than this. if ($checkbutton) { $postdata[$qa->get_behaviour_field_name('submit')] = 1; } return $postdata; } /** * Given a context and a structure of categories and questions, generate that structure. * * The $structure parameter takes a multi-dimensional array of categories and questions, like this: * [ * 'categoryname' => [ * 'question1name' => 'questiontype', * 'question2name' => 'questiontype', * 'subcategoryname' => [ * 'subquestion1name' => 'questiontype', * ], * ], * ] * Arrays are treated as categories, strings are treated as questions. The key in each case is used for the name of the category * or question. For subcategories, the method is called recursively to create all descendants. * The 'questiontype' string is the type of question to be generated, and will be passed to create_question. * * @param context $context The context to create the structure in. * @param array $structure The array of categories and questions, see above. * @param ?int $parentid The category to create the category or question within. * @return array The input structure, with the generated questions in place of the question types. */ public function create_categories_and_questions(context $context, array $structure, ?int $parentid = null) { $createdcategories = []; foreach ($structure as $name => $item) { if (is_array($item)) { $categorydata = [ 'name' => $name, 'contextid' => $context->id, ]; if ($parentid) { $categorydata['parent'] = $parentid; } $category = $this->create_question_category($categorydata); $createdcategories[$name] = $this->create_categories_and_questions($context, $item, $category->id); } else if (is_string($item)) { if (!$parentid) { throw new coding_exception('You cannot create questions in a top-level category.'); } $createdcategories[$name] = $this->create_question($item, null, ['name' => $name, 'category' => $parentid]); } else { throw new coding_exception('Structure items must be arrays or strings, ' . gettype($item) . ' found.'); } } return $createdcategories; } } PK\ +1tests/generator/behat_core_question_generator.phpnu[. /** * Behat data generator for core_question. * * @package core_question * @category test * @copyright 2020 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Behat data generator for core_question. */ class behat_core_question_generator extends behat_generator_base { protected function get_creatable_entities(): array { // Note, for historical reasons, questions and question categories // are generated by behat_core_generator. return [ 'Tags' => [ 'singular' => 'Tag', 'datagenerator' => 'question_tag', 'required' => ['question', 'tag'], 'switchids' => ['question' => 'questionid'], ], 'updated questions' => [ 'singular' => 'question', 'datagenerator' => 'updated_question', 'required' => ['question', 'questioncategory'], 'switchids' => [ 'question' => 'id', 'questioncategory' => 'category', 'modifiedbyuser' => 'modifiedby', 'createdbyuser' => 'createdby', ], ], ]; } /** * Look up the id of a question from its name. * * @param string $questionname the question name, for example 'Question 1'. * @return int corresponding id. */ protected function get_question_id(string $questionname): int { global $DB; if (!$id = $DB->get_field('question', 'id', ['name' => $questionname])) { throw new Exception('There is no question with name "' . $questionname . '".'); } return $id; } /** * Look up the id of the createdby user from their username * * @param string $username The username for the createdby user. * @return int The user ID. */ protected function get_createdbyuser_id(string $username): int { return $this->get_user_id($username); } /** * Look up the id of the modifiedby user from their username * * @param string $username The username for the modifiedby user. * @return int The user ID. */ protected function get_modifiedbyuser_id(string $username): int { return $this->get_user_id($username); } /** * Update a question * * This will update a question matching the supplied name with the provided data, creating a new version in the process. * * @param array $data the row of data from the behat script. * @return void */ protected function process_updated_question(array $data): void { global $DB; $question = $DB->get_record('question', ['id' => $data['id']], '*', MUST_EXIST); $overrides = []; foreach ($data as $key => $value) { $overrides[$key] = $value; } $this->datagenerator->get_plugin_generator('core_question')->update_question($question, overrides: $overrides); } } PK\!lBB+tests/external/search_shared_banks_test.phpnu[. namespace core_question\external; use core\context\module; /** * Unit tests for core_question\external\search_shared_banks * * @package core_question * @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 \core_question\external\search_shared_banks */ final class search_shared_banks_test extends \advanced_testcase { /** * Create a set of question banks across 3 courses. * * One bank belongs to a quiz, which is not searchable as it is not shared. * One bank does not include the word "test" in its name, so will not match that search. * One bank is on a course where the user has different permissions. * One bank is on the same course as the quiz, so will not be returned unless banks on the same course are included. * * @return array */ protected function create_banks(): array { $generator = $this->getDataGenerator(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank'); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $course3 = $generator->create_course(); $teacher = $generator->create_user(); $generator->enrol_user($teacher->id, $course1->id, 'teacher'); $generator->enrol_user($teacher->id, $course2->id, 'teacher'); $generator->enrol_user($teacher->id, $course3->id, 'editingteacher'); $quiz = $quizgenerator->create_instance(['name' => 'Test quiz', 'course' => $course1->id]); $qbank1 = $qbankgenerator->create_instance(['name' => 'Test qbank 1', 'course' => $course2->id]); $qbank2 = $qbankgenerator->create_instance(['name' => 'Test qbank 2', 'course' => $course2->id]); $qbank3 = $qbankgenerator->create_instance(['name' => 'Different name', 'course' => $course2->id]); $qbank4 = $qbankgenerator->create_instance(['name' => 'Test qbank with different permissions', 'course' => $course3->id]); return [ $course1, $course2, $course3, $teacher, module::instance($quiz->cmid), $qbank1, $qbank2, $qbank3, $qbank4, ]; } /** * Call the function with no search string. All banks the user has permission to should be returned. */ public function test_empty_search(): void { $this->resetAfterTest(); [ , $course2, $course3, $teacher, $quizcontext, $qbank1, $qbank2, $qbank3, $qbank4, ] = $this->create_banks(); $this->setUser($teacher); $result = search_shared_banks::execute($quizcontext->id); $this->assertEquals( [ [ 'label' => "{$course2->shortname} - {$qbank1->name}", 'value' => $qbank1->cmid, ], [ 'label' => "{$course2->shortname} - {$qbank2->name}", 'value' => $qbank2->cmid, ], [ 'label' => "{$course2->shortname} - {$qbank3->name}", 'value' => $qbank3->cmid, ], [ 'label' => "{$course3->shortname} - {$qbank4->name}", 'value' => $qbank4->cmid, ], ], $result['sharedbanks'], ); } /** * Call the function with a search string matching a subset of available banks. Only those matching should be returned. */ public function test_search(): void { $this->resetAfterTest(); [ , $course2, $course3, $teacher, $quizcontext, $qbank1, $qbank2, , $qbank4, ] = $this->create_banks(); $this->setUser($teacher); $result = search_shared_banks::execute($quizcontext->id, 'Test'); $this->assertEquals( [ [ 'label' => "{$course2->shortname} - {$qbank1->name}", 'value' => $qbank1->cmid, ], [ 'label' => "{$course2->shortname} - {$qbank2->name}", 'value' => $qbank2->cmid, ], [ 'label' => "{$course3->shortname} - {$qbank4->name}", 'value' => $qbank4->cmid, ], ], $result['sharedbanks'], ); } /** * Call the function with a different capability. Only results where the user has that capability should be returned. */ public function test_search_different_capability(): void { $this->resetAfterTest(); [ , , $course3, $teacher, $quizcontext, , , , $qbank4, ] = $this->create_banks(); $this->setUser($teacher); $result = search_shared_banks::execute($quizcontext->id, 'Test', ['edit']); $this->assertEquals( [ [ 'label' => "{$course3->shortname} - {$qbank4->name}", 'value' => $qbank4->cmid, ], ], $result['sharedbanks'], ); } /** * If there are more than the max number of results, a placeholder is returned at the end. */ public function test_search_max_results(): void { $this->resetAfterTest(); [ , $course2, , $teacher, $quizcontext, ] = $this->create_banks(); $qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank'); for ($i = 1; $i <= search_shared_banks::MAX_RESULTS + 2; $i++) { $qbankgenerator->create_instance(['name' => "Extra qbank {$i}", 'course' => $course2->id]); } $this->setUser($teacher); $result = search_shared_banks::execute($quizcontext->id, 'Extra'); $this->assertCount(search_shared_banks::MAX_RESULTS + 1, $result['sharedbanks']); $lastresult = end($result['sharedbanks']); $this->assertEquals( [ 'label' => get_string('otherquestionbankstoomany', 'question', search_shared_banks::MAX_RESULTS), 'value' => 0, ], $lastresult ); } } PK\`՟kktests/coverage.phpnu[. defined('MOODLE_INTERNAL') || die(); /** * Coverage information for the core_question. * * @copyright 2022 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ return new class extends phpunit_coverage_info { /** @var array The list of files relative to the plugin root to include in coverage generation. */ protected $includelistfiles = [ 'category_class.php', 'category_form.php', 'editlib.php', 'export_form.php', 'format.php', 'import_form.php', 'lib.php', 'move_form.php', 'previewlib.php', 'renderer.php', 'upgrade.php', ]; }; PK\v,?]?]tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package core_question * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\deletion_criteria; use core_privacy\local\request\writer; use core_question\privacy\provider; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/xmlize.php'); require_once(__DIR__ . '/../privacy_helper.php'); require_once(__DIR__ . '/../../engine/tests/helpers.php'); /** * Privacy provider tests class. * * @package core_question * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { // Include the privacy helper which has assertions on it. use \core_question_privacy_helper; /** * Prepare a question attempt. * * @return question_usage_by_activity */ protected function prepare_question_attempt() { // Create a question with a usage from the current user. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $quba->add_question($question); $quba->start_all_questions(); \question_engine::save_questions_usage_by_activity($quba); return $quba; } /** * Test that calling export_question_usage on a usage belonging to a * different user does not export any data. */ public function test_export_question_usage_no_usage(): void { $this->resetAfterTest(); $quba = $this->prepare_question_attempt(); // Create a question with a usage from the current user. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $quba->add_question($question); $quba->start_all_questions(); \question_engine::save_questions_usage_by_activity($quba); // Set the user. $testuser = $this->getDataGenerator()->create_user(); $this->setUser($testuser); $context = $quba->get_owning_context(); $options = new \question_display_options(); provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, false); /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertFalse($writer->has_any_data_in_any_context()); } /** * Test that calling export_question_usage on a usage belonging to a * different user but ignoring the user match */ public function test_export_question_usage_with_usage(): void { $this->resetAfterTest(); $quba = $this->prepare_question_attempt(); // Create a question with a usage from the current user. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $questiondata = $questiongenerator->create_question('truefalse', 'true', ['category' => $cat->id]); $quba->add_question(\question_bank::load_question($questiondata->id)); $questiondata = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $quba->add_question(\question_bank::load_question($questiondata->id)); // Set the user and answer the questions. $testuser = $this->getDataGenerator()->create_user(); $this->setUser($testuser); $quba->start_all_questions(); $quba->process_action(1, ['answer' => 1]); $quba->process_action(2, ['answer' => 'cat']); $quba->finish_all_questions(); \question_engine::save_questions_usage_by_activity($quba); $context = $quba->get_owning_context(); // Export all questions for this attempt. $options = new \question_display_options(); provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true); /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data_in_any_context()); $this->assertTrue($writer->has_any_data()); $slots = $quba->get_slots(); $this->assertCount(2, $slots); foreach ($slots as $slotno) { $data = $writer->get_data([get_string('questions', 'core_question'), $slotno]); $this->assertNotNull($data); $this->assert_question_slot_equals($quba, $slotno, $options, $data); } $this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()])); // Disable some options and re-export. writer::reset(); $options = new \question_display_options(); $options->hide_all_feedback(); $options->flags = \question_display_options::HIDDEN; $options->marks = \question_display_options::HIDDEN; provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true); /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data_in_any_context()); $this->assertTrue($writer->has_any_data()); $slots = $quba->get_slots(); $this->assertCount(2, $slots); foreach ($slots as $slotno) { $data = $writer->get_data([get_string('questions', 'core_question'), $slotno]); $this->assertNotNull($data); $this->assert_question_slot_equals($quba, $slotno, $options, $data); } $this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()])); } /** * Test that questions owned by a user are exported and never deleted. */ public function test_question_owned_is_handled(): void { global $DB; $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create the two test users. $user = $this->getDataGenerator()->create_user(); $otheruser = $this->getDataGenerator()->create_user(); // Create one question as each user in diferent contexts. $this->setUser($user); $userdata = $questiongenerator->setup_course_and_questions(); $expectedcontext = \context_module::instance($userdata[4]->cmid); $this->setUser($otheruser); $otheruserdata = $questiongenerator->setup_course_and_questions(); $unexpectedcontext = \context_module::instance($otheruserdata[4]->cmid); // And create another one where we'll update a question as the test user. $moreotheruserdata = $questiongenerator->setup_course_and_questions(); $otherexpectedcontext = \context_module::instance($moreotheruserdata[4]->cmid); $morequestions = $moreotheruserdata[3]; // Update the third set of questions. $this->setUser($user); foreach ($morequestions as $question) { $questiongenerator->update_question($question); } // Run the get_contexts_for_userid as default user. $this->setUser(); // There should be two contexts returned - the first course, and the third. $contextlist = provider::get_contexts_for_userid($user->id); $this->assertCount(2, $contextlist); $expectedcontexts = [ $expectedcontext->id, $otherexpectedcontext->id, ]; $this->assertEqualsCanonicalizing( $expectedcontexts, array_values($contextlist->get_contextids()), 'Contexts not equal', ); // Run the export_user_Data as the test user. $this->setUser($user); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($user->id), 'core_question', $expectedcontexts ); provider::export_user_data($approvedcontextlist); // There should be data for the user's question context. $writer = writer::with_context($expectedcontext); $this->assertTrue($writer->has_any_data()); // And for the course we updated. $otherwriter = writer::with_context($otherexpectedcontext); $this->assertTrue($otherwriter->has_any_data()); // But not for the other user's course. $otherwriter = writer::with_context($unexpectedcontext); $this->assertFalse($otherwriter->has_any_data()); // The question data is exported as an XML export in custom files. $writer = writer::with_context($expectedcontext); $subcontext = [get_string('questionbank', 'core_question')]; $exportfile = $writer->get_custom_file($subcontext, 'questions.xml'); $this->assertNotEmpty($exportfile); $xmlized = xmlize($exportfile); $xmlquestions = $xmlized['quiz']['#']['question']; $this->assertCount(2, $xmlquestions); // Run the delete functions as default user. $this->setUser(); // Find out how many questions are in the question bank to start with. $questioncount = $DB->count_records('question'); // The delete functions should do nothing here. // Delete for all users in context. provider::delete_data_for_all_users_in_context($expectedcontext); $this->assertEquals($questioncount, $DB->count_records('question')); provider::delete_data_for_user($approvedcontextlist); $this->assertEquals($questioncount, $DB->count_records('question')); } /** * Deleting questions should only unset their created and modified user. */ public function test_question_delete_data_for_user_anonymised(): void { global $DB; $this->resetAfterTest(true); $user = \core_user::get_user_by_username('admin'); $otheruser = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $qbank1 = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank1context = \context_module::instance($qbank1->cmid); $qbank2 = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank2context = \context_module::instance($qbank2->cmid); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category([ 'contextid' => $qbank1context->id, ]); $othercat = $questiongenerator->create_question_category([ 'contextid' => $qbank2context->id, ]); // Create questions: // Q1 - Created by the UUT, Modified by UUT. // Q2 - Created by the UUT, Modified by the other user. // Q3 - Created by the other user, Modified by UUT // Q4 - Created by the other user, Modified by the other user. // Q5 - Created by the UUT, Modified by the UUT, but in a different context. $this->setUser($user); $q1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $q2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->setUser($otheruser); // When we update a question, a new question/version is created. $q2updated = $questiongenerator->update_question($q2); $q3 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $q4 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->setUser($user); // When we update a question, a new question/version is created. $q3updated = $questiongenerator->update_question($q3); $q5 = $questiongenerator->create_question('shortanswer', null, ['category' => $othercat->id]); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( $user, 'core_question', [$qbank1context->id] ); // Find out how many questions are in the question bank to start with. $questioncount = $DB->count_records('question'); // Delete the data and check it is removed. $this->setUser(); provider::delete_data_for_user($approvedcontextlist); $this->assertEquals($questioncount, $DB->count_records('question')); $qrecord = $DB->get_record('question', ['id' => $q1->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q2updated->id]); $this->assertEquals($otheruser->id, $qrecord->createdby); $this->assertEquals($otheruser->id, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q3updated->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q4->id]); $this->assertEquals($otheruser->id, $qrecord->createdby); $this->assertEquals($otheruser->id, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q5->id]); $this->assertEquals($user->id, $qrecord->createdby); $this->assertEquals($user->id, $qrecord->modifiedby); } /** * Deleting questions should only unset their created and modified user for all questions in a context. */ public function test_question_delete_data_for_all_users_in_context_anonymised(): void { global $DB; $this->resetAfterTest(true); $user = \core_user::get_user_by_username('admin'); $otheruser = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $qbank1 = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank1context = \context_module::instance($qbank1->cmid); $qbank2 = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank2context = \context_module::instance($qbank2->cmid); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category([ 'contextid' => $qbank1context->id, ]); $othercat = $questiongenerator->create_question_category([ 'contextid' => $qbank2context->id, ]); // Create questions: // Q1 - Created by the UUT, Modified by UUT. // Q2 - Created by the UUT, Modified by the other user. // Q3 - Created by the other user, Modified by UUT // Q4 - Created by the other user, Modified by the other user. // Q5 - Created by the UUT, Modified by the UUT, but in a different context. $this->setUser($user); $q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); $q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); $this->setUser($otheruser); $questiongenerator->update_question($q2); $q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); $q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); $this->setUser($user); $questiongenerator->update_question($q3); $q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id)); // Find out how many questions are in the question bank to start with. $questioncount = $DB->count_records('question'); // Delete the data and check it is removed. $this->setUser(); provider::delete_data_for_all_users_in_context($qbank1context); $this->assertEquals($questioncount, $DB->count_records('question')); $qrecord = $DB->get_record('question', ['id' => $q1->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q2->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q3->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q4->id]); $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q5->id]); $this->assertEquals($user->id, $qrecord->createdby); $this->assertEquals($user->id, $qrecord->modifiedby); } /** * Test for provider::get_users_in_context(). */ public function test_get_users_in_context(): void { $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create three test users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); // Create one question as each user in different contexts. $this->setUser($user1); $user1data = $questiongenerator->setup_course_and_questions(); $this->setUser($user2); $user2data = $questiongenerator->setup_course_and_questions(); $qbankcontext = \context_module::instance($user1data[4]->cmid); $questions = $user1data[3]; // Log in as user3 and update the questions in course1. $this->setUser($user3); foreach ($questions as $question) { $questiongenerator->update_question($question); } $userlist = new \core_privacy\local\request\userlist($qbankcontext, 'core_question'); provider::get_users_in_context($userlist); // User1 has created questions and user3 has edited them. $this->assertCount(2, $userlist); $this->assertEqualsCanonicalizing([$user1->id, $user3->id], $userlist->get_userids()); } /** * Test for provider::delete_data_for_users(). */ public function test_delete_data_for_users(): void { global $DB; $this->resetAfterTest(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create three test users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); // Create one question as each user in different contexts. $this->setUser($user1); $coursedata = $questiongenerator->setup_course_and_questions(); $qbank = $coursedata[4]; $course1qcat = $coursedata[2]; $questions = $coursedata[3]; $qbankcontext = \context_module::instance($qbank->cmid); // Log in as user2 and update the questions in course1. $this->setUser($user2); foreach ($questions as $question) { $questiongenerator->update_question($question); } // Add 2 more questions to course1 by user3. $this->setUser($user3); $questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]); $questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]); // Now, log in as user1 again, and then create a new course and add questions to that. $this->setUser($user1); $questiongenerator->setup_course_and_questions(); $approveduserlist = new \core_privacy\local\request\approved_userlist($qbankcontext, 'core_question', [$user1->id, $user2->id]); provider::delete_data_for_users($approveduserlist); // Now, there should be no question related to user1 or user2 in course1. $this->assertEquals(0, $DB->count_records_sql("SELECT COUNT(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)", [$qbankcontext->id, $user1->id, $user1->id, $user2->id, $user2->id]) ); // User3 data in course1 should not change. $this->assertEquals(2, $DB->count_records_sql("SELECT COUNT(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)", [$qbankcontext->id, $user3->id, $user3->id]) ); // User1 has authored 2 questions in another course. $this->assertEquals( 2, $DB->count_records_select('question', "createdby = ? OR modifiedby = ?", [$user1->id, $user1->id]) ); } } PK\Xwtests/backup_test.phpnu[. namespace core_question; use context_course; use mod_quiz\quiz_settings; use moodle_url; use question_bank; defined('MOODLE_INTERNAL') || die(); use backup; use core\context\module; use core_question\local\bank\question_bank_helper; use core_question\local\bank\question_version_status; use restore_controller; use restore_dbops; global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); /** * Class core_question_backup_testcase * * @package core_question * @category test * @copyright 2018 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \restore_qtype_plugin * @covers \restore_create_categories_and_questions * @covers \restore_move_module_questions_categories */ final class backup_test extends \advanced_testcase { /** * Makes a backup of the course. * * @param \stdClass $course The course object. * @return string Unique identifier for this backup. */ protected function backup_course($course) { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do backup with default settings. MODE_IMPORT means it will just // create the directory and not zip it. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); return $backupid; } /** * Makes a backup of a course module. * * @param int $modid The course_module id. * @return string Unique identifier for this backup. */ protected function backup_course_module(int $modid) { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; // Do backup with default settings. MODE_IMPORT means it will just // create the directory and not zip it. $bc = new \backup_controller(\backup::TYPE_1ACTIVITY, $modid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); return $backupid; } /** * Restores a backup that has been made earlier. * * @param string $backupid The unique identifier of the backup. * @param int $courseid Course id of where the restore is happening. * @param string[] $expectedprecheckwarning */ protected function restore_to_course(string $backupid, int $courseid, array $expectedprecheckwarning = []): void { global $CFG, $USER; // Turn off file logging, otherwise it can't delete the file (Windows). $CFG->backup_file_logger_level = \backup::LOG_NONE; $rc = new \restore_controller($backupid, $courseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE); $precheck = $rc->execute_precheck(); if (!$expectedprecheckwarning) { $this->assertTrue($precheck); } else { $precheckresults = $rc->get_precheck_results(); $this->assertEqualsCanonicalizing($expectedprecheckwarning, $precheckresults['warnings']); $this->assertCount(1, $precheckresults); } $rc->execute_plan(); $rc->destroy(); } /** * This function tests backup and restore of question tags. */ public function test_backup_question_tags(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a new course category and a new course in that. $category1 = $this->getDataGenerator()->create_category(); $course = $this->getDataGenerator()->create_course(['category' => $category1->id]); $courseshortname = $course->shortname; $coursefullname = $course->fullname; // Create 2 questions. $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); $qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]); $context = \context_module::instance($qbank->cmid); $qcat = question_get_default_category($context->id); $question1 = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q1']); $question2 = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q2']); // Tag the questions with 2 question tags. $qcontext = \context::instance_by_id($qcat->contextid); $coursecontext = context_course::instance($course->id); \core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['qtag1', 'qtag2']); \core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['qtag3', 'qtag4']); // Create a quiz and add one of the questions to that. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); quiz_add_quiz_question($question1->id, $quiz); // Backup the course twice for future use. $backupid1 = $this->backup_course($course); $backupid2 = $this->backup_course($course); // Now delete almost everything. delete_course($course, false); question_delete_question($question1->id); question_delete_question($question2->id); // Restore the backup we had made earlier into a new course. // Do restore to new course with default settings. $courseid2 = \restore_dbops::create_new_course($coursefullname, $courseshortname . '_2', $category1->id); $this->restore_to_course($backupid1, $courseid2); $modinfo = get_fast_modinfo($courseid2); $qbanks = $modinfo->get_instances_of('qbank'); $qbanks = array_filter($qbanks, static fn($qbank) => $qbank->get_name() === 'Question bank 1'); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $qbankcontext = \context_module::instance($qbank->id); $cats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = ?', [$qbankcontext->id]); $this->assertCount(1, $cats); $cat = reset($cats); // The questions should be restored to a mod_qbank context in the new course. $sql = 'SELECT q.*, qbe.idnumber 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 = ? ORDER BY qbe.idnumber'; $questions = $DB->get_records_sql($sql, [$cat->id]); $this->assertCount(2, $questions); // Retrieve tags for each question and check if they are assigned at the right context. $qcount = 1; foreach ($questions as $question) { $tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id); // Each question is tagged with 4 tags (2 question tags + 2 course tags). $this->assertCount(2, $tags); foreach ($tags as $tag) { $this->assertEquals($qbankcontext->id, $tag->taginstancecontextid); } // Also check idnumbers have been backed up and restored. $this->assertEquals('q' . $qcount, $question->idnumber); $qcount++; } // Now, again, delete everything including the course category. delete_course($courseid2, false); foreach ($questions as $question) { question_delete_question($question->id); } $category1->delete_full(false); // Create a new course category to restore the backup file into it. $category2 = $this->getDataGenerator()->create_category(); // Restore to a new course in the new course category. $courseid3 = \restore_dbops::create_new_course($coursefullname, $courseshortname . '_3', $category2->id); $this->restore_to_course($backupid2, $courseid3); $modinfo = get_fast_modinfo($courseid3); $qbanks = $modinfo->get_instances_of('qbank'); $qbanks = array_filter($qbanks, static fn($qbank) => $qbank->get_name() === 'Question bank 1'); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $context = \context_module::instance($qbank->id); // The questions should have been moved to a question category that belongs to a course context. $questions = $DB->get_records_sql("SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ?", [$context->id]); $this->assertCount(2, $questions); // Now, retrieve tags for each question and check if they are assigned at the right context. foreach ($questions as $question) { $tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id); // Each question is tagged with 2 tags (all are question context tags now). $this->assertCount(2, $tags); foreach ($tags as $tag) { $this->assertEquals($context->id, $tag->taginstancecontextid); } } } /** * Test that the question author is retained when they are enrolled in to the course. */ public function test_backup_question_author_retained_when_enrolled(): void { global $DB, $USER, $CFG; $this->resetAfterTest(); $this->setAdminUser(); // Create a course, a category and a user. $course = $this->getDataGenerator()->create_course(); $category = $this->getDataGenerator()->create_category(); $user = $this->getDataGenerator()->create_user(); // Create a question. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncategory = $questiongenerator->create_question_category(); $overrides = ['name' => 'Test question', 'category' => $questioncategory->id, 'createdby' => $user->id, 'modifiedby' => $user->id]; $question = $questiongenerator->create_question('truefalse', null, $overrides); // Create a quiz and a questions. $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id)); quiz_add_quiz_question($question->id, $quiz); // Enrol user with a teacher role. $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id, 'manual'); // Backup the course. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $results = $bc->get_results(); $file = $results['backup_destination']; $fp = get_file_packer('application/vnd.moodle.backup'); $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; $file->extract_to_pathname($fp, $filepath); $bc->destroy(); // Delete the original course and related question. delete_course($course, false); question_delete_question($question->id); // Restore the course. $restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id); $rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Test the question author. $questions = $DB->get_records('question', ['name' => 'Test question']); $this->assertCount(1, $questions); $question3 = array_shift($questions); $this->assertEquals($user->id, $question3->createdby); $this->assertEquals($user->id, $question3->modifiedby); } /** * Test that the question author is retained when they are not enrolled in to the course, * but we are restoring the backup at the same site. */ public function test_backup_question_author_retained_when_not_enrolled(): void { global $DB, $USER, $CFG; $this->resetAfterTest(); $this->setAdminUser(); // Create a course, a category and a user. $course = $this->getDataGenerator()->create_course(); $category = $this->getDataGenerator()->create_category(); $user = $this->getDataGenerator()->create_user(); // Create a question. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncategory = $questiongenerator->create_question_category(); $overrides = ['name' => 'Test question', 'category' => $questioncategory->id, 'createdby' => $user->id, 'modifiedby' => $user->id]; $question = $questiongenerator->create_question('truefalse', null, $overrides); // Create a quiz and a questions. $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id)); quiz_add_quiz_question($question->id, $quiz); // Backup the course. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $results = $bc->get_results(); $file = $results['backup_destination']; $fp = get_file_packer('application/vnd.moodle.backup'); $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; $file->extract_to_pathname($fp, $filepath); $bc->destroy(); // Delete the original course and related question. delete_course($course, false); question_delete_question($question->id); // Restore the course. $restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id); $rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Test the question author. $questions = $DB->get_records('question', ['name' => 'Test question']); $this->assertCount(1, $questions); $question = array_shift($questions); $this->assertEquals($user->id, $question->createdby); $this->assertEquals($user->id, $question->modifiedby); } /** * Test that the current user is set as a question author when we are restoring the backup * at the another site and the question author is not enrolled in to the course. */ public function test_backup_question_author_reset(): void { global $DB, $USER, $CFG; $this->resetAfterTest(); $this->setAdminUser(); // Create a course, a category and a user. $course = $this->getDataGenerator()->create_course(); $category = $this->getDataGenerator()->create_category(); $user = $this->getDataGenerator()->create_user(); // Create a question. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncategory = $questiongenerator->create_question_category(); $overrides = ['name' => 'Test question', 'category' => $questioncategory->id, 'createdby' => $user->id, 'modifiedby' => $user->id]; $question = $questiongenerator->create_question('truefalse', null, $overrides); // Create a quiz and a questions. $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id)); quiz_add_quiz_question($question->id, $quiz); // Backup the course. $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_SAMESITE, $USER->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $results = $bc->get_results(); $file = $results['backup_destination']; $fp = get_file_packer('application/vnd.moodle.backup'); $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; $file->extract_to_pathname($fp, $filepath); $bc->destroy(); // Delete the original course and related question. delete_course($course, false); question_delete_question($question->id); // Emulate restoring to a different site. set_config('siteidentifier', random_string(32) . 'not the same site'); // Restore the course. $restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id); $rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO, \backup::MODE_SAMESITE, $USER->id, \backup::TARGET_NEW_COURSE); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Test the question author. $questions = $DB->get_records('question', ['name' => 'Test question']); $this->assertCount(1, $questions); $question = array_shift($questions); $this->assertEquals($USER->id, $question->createdby); $this->assertEquals($USER->id, $question->modifiedby); } public function test_backup_and_restore_recodes_links_in_questions(): void { global $DB, $USER, $CFG; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a category. $course = $this->getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $category = $this->getDataGenerator()->create_category(); // Create a question with links in all the places that should be recoded. $testlink = new moodle_url('/course/view.php', ['id' => $course->id]); $testcontent = 'Look at the course.'; $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncategory = $questiongenerator->create_question_category( ['contextid' => \context_module::instance($qbank->cmid)->id]); $question = $questiongenerator->create_question('multichoice', null, [ 'name' => 'Test question', 'category' => $questioncategory->id, 'questiontext' => ['text' => 'This is the question. ' . $testcontent], 'generalfeedback' => ['text' => 'Why is this right? ' . $testcontent], 'answer' => [ '0' => ['text' => 'Choose me! ' . $testcontent], ], 'feedback' => [ '0' => ['text' => 'The reason: ' . $testcontent], ], 'hint' => [ '0' => ['text' => 'Hint: ' . $testcontent], ], ]); // Create a quiz and add the question. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); quiz_add_quiz_question($question->id, $quiz); // Backup and restore the course. $backupid = $this->backup_course($course); $newcourse = $this->getDataGenerator()->create_course(); $this->restore_to_course($backupid, $newcourse->id); $modinfo = get_fast_modinfo($newcourse); $qbanks = $modinfo->get_instances_of('qbank'); $qbank = reset($qbanks); // Get the question from the restored course - we are expecting just one, but that is not the real test here. $restoredquestions = $DB->get_records_sql(" SELECT q.id, q.name FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ? ", [\context_module::instance($qbank->id)->id]); $this->assertCount(1, $restoredquestions); $questionid = array_key_first($restoredquestions); $this->assertEquals('Test question', $restoredquestions[$questionid]->name); // Verify the links have been recoded. $restoredquestion = question_bank::load_question_data($questionid); $recodedlink = new moodle_url('/course/view.php', ['id' => $newcourse->id]); $recodedcontent = 'Look at the course.'; $firstanswerid = array_key_first($restoredquestion->options->answers); $firsthintid = array_key_first($restoredquestion->hints); $this->assertEquals('This is the question. ' . $recodedcontent, $restoredquestion->questiontext); $this->assertEquals('Why is this right? ' . $recodedcontent, $restoredquestion->generalfeedback); $this->assertEquals('Choose me! ' . $recodedcontent, $restoredquestion->options->answers[$firstanswerid]->answer); $this->assertEquals('The reason: ' . $recodedcontent, $restoredquestion->options->answers[$firstanswerid]->feedback); $this->assertEquals('Hint: ' . $recodedcontent, $restoredquestion->hints[$firsthintid]->hint); } /** * Boilerplate setup for the tests. Creates a course, a quiz, and a qbank module. It adds a category to each module context * and adds a question to each category. Finally, it adds the 2 questions to the quiz. * * @return \stdClass */ private function add_course_quiz_and_qbank() { $qgen = self::getDataGenerator()->get_plugin_generator('core_question'); // Create a new course. $course = self::getDataGenerator()->create_course(); // Create a question bank module instance, a category for that module, and a question for that category. $qbank = self::getDataGenerator()->create_module( 'qbank', ['type' => question_bank_helper::TYPE_STANDARD, 'course' => $course->id] ); $qbankcontext = \context_module::instance($qbank->cmid); $bankqcat = question_get_default_category($qbankcontext->id); $bankquestion = $qgen->create_question('shortanswer', null, ['name' => 'bank question', 'category' => $bankqcat->id, 'idnumber' => 'bankq1'] ); // Create a quiz module instance, a category for that module, and a question for that category. $quiz = self::getDataGenerator()->create_module('quiz', ['course' => $course->id]); $quizcontext = \context_module::instance($quiz->cmid); $quizqcat = question_get_default_category($quizcontext->id); $quizquestion = $qgen->create_question('shortanswer', null, ['name' => 'quiz question', 'category' => $quizqcat->id, 'idnumber' => 'quizq1'] ); quiz_add_quiz_question($bankquestion->id, $quiz); quiz_add_quiz_question($quizquestion->id, $quiz); $data = new \stdClass(); $data->course = $course; $data->qbank = $qbank; $data->qbankcategory = $bankqcat; $data->qbankquestion = $bankquestion; $data->quiz = $quiz; $data->quizcategory = $quizqcat; $data->quizquestion = $quizquestion; return $data; } /** * If the backup contains ONLY a quiz but that quiz uses questions from a qbank module and itself, * and the original course does not exist on the target system, * then the non-quiz context categories and questions should restore to a default qbank module on the new course * if the old qbank no longer exists. */ public function test_quiz_activity_restore_to_new_course(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); // Create a course to make a backup. $data = $this->add_course_quiz_and_qbank(); $oldquiz = $data->quiz; // Backup ONLY the quiz module. $backupid = $this->backup_course_module($oldquiz->cmid); // Create a new course to restore to. $newcourse = self::getDataGenerator()->create_course(); delete_course($data->course->id, false); $this->restore_to_course($backupid, $newcourse->id); $modinfo = get_fast_modinfo($newcourse); // Assert we have our quiz including the category and question. $newquizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(1, $newquizzes); $newquiz = reset($newquizzes); $newquizcontext = \context_module::instance($newquiz->id); $quizcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $newquizcontext->id] ); $this->assertCount(1, $quizcats); $quizcat = reset($quizcats); $quizcatqs = get_questions_category($quizcat, false); $this->assertCount(1, $quizcatqs); $quizq = reset($quizcatqs); $this->assertEquals('quiz question', $quizq->name); // The backup did not contain the qbank that held the categories, but it is dependant. // So make sure the categories and questions got restored to a 'system' type default qbank module on the course. $defaultbanks = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $defaultbanks); $defaultbank = reset($defaultbanks); $defaultbankcontext = \context_module::instance($defaultbank->id); $bankcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $defaultbankcontext->id] ); $bankcat = reset($bankcats); $bankqs = get_questions_category($bankcat, false); $this->assertCount(1, $bankqs); $bankq = reset($bankqs); $this->assertEquals('bank question', $bankq->name); } /** * If the backup contains ONLY a quiz but that quiz uses questions from a qbank module and itself, * and the original course does exist on the target system but you dont have permission to view the original qbank, * then the non-quiz context categories and questions should restore to a default qbank module on the new course * if the old qbank no longer exists. */ public function test_quiz_activity_restore_to_new_course_no_permission(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); // Create a course to make a backup. $data = $this->add_course_quiz_and_qbank(); $oldquiz = $data->quiz; // Backup ONLY the quiz module. $backupid = $this->backup_course_module($oldquiz->cmid); // Create a new course to restore to. $newcourse = self::getDataGenerator()->create_course(); $restoreuser = self::getDataGenerator()->create_user(); self::getDataGenerator()->enrol_user($restoreuser->id, $newcourse->id, 'manager'); $this->setUser($restoreuser); $this->restore_to_course($backupid, $newcourse->id); $modinfo = get_fast_modinfo($newcourse); // Assert we have our quiz including the category and question. $newquizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(1, $newquizzes); $newquiz = reset($newquizzes); $newquizcontext = \context_module::instance($newquiz->id); $quizcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $newquizcontext->id] ); $this->assertCount(1, $quizcats); $quizcat = reset($quizcats); $quizcatqs = get_questions_category($quizcat, false); $this->assertCount(1, $quizcatqs); $quizq = reset($quizcatqs); $this->assertEquals('quiz question', $quizq->name); // The backup did not contain the qbank that held the categories, but it is dependant. // So make sure the categories and questions got restored to a qbank module on the course. $defaultbanks = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $defaultbanks); $defaultbank = reset($defaultbanks); $defaultbankcontext = \context_module::instance($defaultbank->id); $bankcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $defaultbankcontext->id] ); $bankcat = reset($bankcats); $bankqs = get_questions_category($bankcat, false); $this->assertCount(1, $bankqs); $bankq = reset($bankqs); $this->assertEquals('bank question', $bankq->name); } /** * If the backup contains ONLY a quiz but that quiz uses questions from a qbank module and itself, * and that qbank still exists on the system, and the restoring user can access that qbank, then * the quiz should be restored with a copy of the quiz question, and a reference to the original qbank question. */ public function test_quiz_activity_restore_to_new_course_by_reference(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); // Create a course to make a backup. $data = $this->add_course_quiz_and_qbank(); $oldquiz = $data->quiz; // Backup ONLY the quiz module. $backupid = $this->backup_course_module($oldquiz->cmid); // Create a new course to restore to. $newcourse = self::getDataGenerator()->create_course(); $this->restore_to_course($backupid, $newcourse->id); $modinfo = get_fast_modinfo($newcourse); // Assert we have our new quiz with the expected questions. $newquizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(1, $newquizzes); /** @var \cm_info $newquiz */ $newquiz = reset($newquizzes); $quiz = $DB->get_record('quiz', ['id' => $newquiz->instance], '*', MUST_EXIST); [$course, $cm] = get_course_and_cm_from_instance($quiz, 'quiz'); $newquizsettings = new quiz_settings($quiz, $cm, $course); $newq1 = $newquizsettings->get_structure()->get_question_in_slot(1); $newq2 = $newquizsettings->get_structure()->get_question_in_slot(2); $newquizcontext = \context_module::instance($newquiz->id); $qbankcontext = \context_module::instance($data->qbank->cmid); // Check we've got a copy of the quiz question in the new context. $this->assertEquals($data->quizquestion->name, $newq2->name); $this->assertEquals($newquizcontext->id, $newq2->contextid); // Check we've got a reference to the qbank question in the original context. $this->assertEquals($data->qbankquestion->name, $newq1->name); $this->assertEquals($qbankcontext->id, $newq1->contextid); // Check we have the expected restored categories. $this->assertEquals(2, $DB->count_records('question_categories', ['stamp' => $data->quizcategory->stamp])); $this->assertEquals(1, $DB->count_records('question_categories', ['stamp' => $data->qbankcategory->stamp])); } /** * If the backup contains BOTH a quiz and a qbank module and the quiz uses questions from the qbank module and itself, * then we need to restore the categories and questions to the qbank and quiz modules included in the backup on the new course. * * @return void * @covers \restore_controller::execute_plan() */ public function test_bank_and_quiz_activity_restore_to_new_course(): void { // Create a new course. global $DB; $this->resetAfterTest(); self::setAdminUser(); // Create a course to make a backup from. $data = $this->add_course_quiz_and_qbank(); $oldcourse = $data->course; // Backup the course. $backupid = $this->backup_course($oldcourse); // Create a new course to restore to. $newcourse = self::getDataGenerator()->create_course(); // Restore it. $this->restore_to_course($backupid, $newcourse->id); // Assert the quiz got its question catregories restored. $modinfo = get_fast_modinfo($newcourse); $newquizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(1, $newquizzes); $newquiz = reset($newquizzes); $newquizcontext = \context_module::instance($newquiz->id); $quizcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $newquizcontext->id] ); $quizcat = reset($quizcats); $quizcatqs = get_questions_category($quizcat, false); $this->assertCount(1, $quizcatqs); $quizcatq = reset($quizcatqs); $this->assertEquals('quiz question', $quizcatq->name); // Assert the qbank got its questions restored to the module in the backup. $qbanks = $modinfo->get_instances_of('qbank'); $qbanks = array_filter($qbanks, static function($bank) { global $DB; $modrecord = $DB->get_record('qbank', ['id' => $bank->instance]); return $modrecord->type === question_bank_helper::TYPE_STANDARD; }); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $bankcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => \context_module::instance($qbank->id)->id] ); $bankcat = reset($bankcats); $bankqs = get_questions_category($bankcat, false); $this->assertCount(1, $bankqs); $bankq = reset($bankqs); $this->assertEquals('bank question', $bankq->name); } /** * The course backup file contains question banks and a quiz module. * There is 1 question bank category per deprecated context level i.e. CONTEXT_SYSTEM, CONTEXT_COURSECAT, and CONTEXT_COURSE. * The quiz included in the backup uses a question in each category. * * @return void * @covers \restore_controller::execute_plan() */ public function test_pre_46_course_restore_to_new_course(): void { global $DB, $USER; self::setAdminUser(); $this->resetAfterTest(); $backupid = 'question_category_45_format'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/{$backupid}.mbz", $backuppath ); // Do restore to new course with default settings. $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); $newcourseid = restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid); $rc = new restore_controller($backupid, $newcourseid, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, backup::TARGET_NEW_COURSE ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); $modinfo = get_fast_modinfo($newcourseid); $qbanks = $modinfo->get_instances_of('qbank'); $qbanks = array_filter($qbanks, static function($bank) { global $DB; $modrecord = $DB->get_record('qbank', ['id' => $bank->instance]); return $modrecord->type === question_bank_helper::TYPE_SYSTEM; }); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $qbankcontext = \context_module::instance($qbank->id); $bankcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $qbankcontext->id], 'name ASC' ); // The categories and questions in the 3 deprecated contexts // all got moved to the new default qbank module instance on the new course. $this->assertCount(3, $bankcats); $expectedidentifiers = [ 'Default for Category 1', 'Default for System', 'Default for Test Course 1', 'Default for Quiz', ]; $i = 0; foreach ($bankcats as $bankcat) { $identifer = $expectedidentifiers[$i]; $this->assertEquals($identifer, $bankcat->name); $bankcatqs = get_questions_category($bankcat, false); $this->assertCount(1, $bankcatqs); $bankcatq = reset($bankcatqs); $this->assertEquals($identifer, $bankcatq->name); $i++; } // The question category and question attached to the quiz got restored to its own context correctly. $newquizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(1, $newquizzes); $newquiz = reset($newquizzes); $newquizcontext = \context_module::instance($newquiz->id); $quizcats = $DB->get_records_select('question_categories', 'parent <> 0 AND contextid = :contextid', ['contextid' => $newquizcontext->id] ); $quizcat = reset($quizcats); $quizcatqs = get_questions_category($quizcat, false); $this->assertCount(1, $quizcatqs); $quizcatq = reset($quizcatqs); $this->assertEquals($expectedidentifiers[$i], $quizcatq->name); } /** * Restore a backup containing question versions that were deleted, after new versions were created in their place. * * The new versions and any references to them should have their version numbers bumped up, and the original versions * restored to their original numbers. */ public function test_restore_backup_containing_deleted_versions(): void { global $DB; self::setAdminUser(); $this->resetAfterTest(); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); $testdata = $this->add_course_quiz_and_qbank(); $questionv1 = $testdata->qbankquestion; $questionv2 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 2']); $questionv3 = $questiongenerator->update_question($questionv2, null, ['name' => 'Version 3']); $questionv4 = $questiongenerator->update_question($questionv3, null, ['name' => 'Version 4']); $quizsettings = quiz_settings::create($testdata->quiz->id); $structure1 = $quizsettings->get_structure(); // Set the usage of the question to specifically use version 2. quiz_add_quiz_question($questionv2->id, $testdata->quiz); $structure1->update_slot_version($structure1->get_slot_id_for_slot(1), 2); $backupid = $this->backup_course_module($testdata->quiz->cmid); question_delete_question($questionv4->id); // Actually deleted. question_delete_question($questionv3->id); // Actually deleted. question_delete_question($questionv2->id); // Hidden, it's being used explicitly. $questionv5 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 5']); $DB->set_field('question_versions', 'version', 3, ['questionid' => $questionv5->id]); $questionv6 = $questiongenerator->update_question($questionv5, null, ['name' => 'Version 6']); $DB->set_field('question_versions', 'version', 4, ['questionid' => $questionv6->id]); $DB->set_field('question_bank_entries', 'nextversion', 5, ['id' => $questionv1->questionbankentryid]); // Add a quiz specifically using "version 5" (with version number 3). $quiz2 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]); $quiz2settings = quiz_settings::create($quiz2->id); quiz_add_quiz_question($questionv5->id, $quiz2); $structure2 = $quiz2settings->get_structure(); $structure2->update_slot_version($structure2->get_last_slot()->id, 3); // Add another quiz using the "always latest" version of the question. $quiz3 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]); $quiz3settings = quiz_settings::create($quiz3->id); quiz_add_quiz_question($questionv6->id, $quiz3); $qbe = get_question_bank_entry($questionv1->id); $versions = $DB->get_records( 'question_versions', ['questionbankentryid' => $qbe->id], fields: 'version, questionid, status', ); $this->assertCount(4, $versions); $this->assertEquals($questionv1->id, $versions[1]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status); $this->assertEquals($questionv2->id, $versions[2]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_HIDDEN, $versions[2]->status); $this->assertEquals($questionv5->id, $versions[3]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[3]->status); $this->assertEquals($questionv6->id, $versions[4]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[4]->status); $structure1 = $quizsettings->get_structure(); $this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid); $structure2 = $quiz2settings->get_structure(); $this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid); $structure3 = $quiz3settings->get_structure(); $this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid); $this->restore_to_course($backupid, $testdata->course->id); $versions = $DB->get_records( 'question_versions', ['questionbankentryid' => $qbe->id], fields: 'version, questionid, status', ); $this->assertCount(6, $versions); $this->assertEquals($questionv1->id, $versions[1]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status); // Version 2 is restored to "ready" status. $this->assertEquals($questionv2->id, $versions[2]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[2]->status); // Version 3 is a new copy of $questionv3 with a different ID but the same content. $this->assertEquals($questionv3->name, $DB->get_field('question', 'name', ['id' => $versions[3]->questionid])); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[3]->status); // Version 4 is a new copy of $questionv4 with a different ID but the same content. $this->assertEquals($questionv4->name, $DB->get_field('question', 'name', ['id' => $versions[4]->questionid])); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[4]->status); // Versions 5 and 6 have had their version numbers bumped up by 1. $this->assertEquals($questionv5->id, $versions[5]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[5]->status); $this->assertEquals($questionv6->id, $versions[6]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[6]->status); // Questions referencing specific versions still point to the same question with its new version number. $structure1 = $quizsettings->get_structure(); $this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid); $structure2 = $quiz2settings->get_structure(); $this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid); $structure3 = $quiz3settings->get_structure(); $this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid); // The nextversion was incremented when the old versions were inserted. $this->assertEquals(7, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $questionv1->questionbankentryid])); } /** * Restore a backup containing question versions that were deleted, after new versions were created. * * Unlike {@see test_restore_backup_containing_deleted_versions}, we do not mess with the version numbers or nextversion * counter, meaning a gap should be left for the deleted question, and it should be restore in its original place. */ public function test_restore_backup_containing_deleted_versions_using_nextversion(): void { global $DB; self::setAdminUser(); $this->resetAfterTest(); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); $testdata = $this->add_course_quiz_and_qbank(); $questionv1 = $testdata->qbankquestion; $questionv2 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 2']); $questionv3 = $questiongenerator->update_question($questionv2, null, ['name' => 'Version 3']); $questionv4 = $questiongenerator->update_question($questionv3, null, ['name' => 'Version 4']); $quizsettings = quiz_settings::create($testdata->quiz->id); $structure1 = $quizsettings->get_structure(); // Set the usage of the question to specifically use version 2. quiz_add_quiz_question($questionv2->id, $testdata->quiz); $structure1->update_slot_version($structure1->get_slot_id_for_slot(1), 2); $backupid = $this->backup_course_module($testdata->quiz->cmid); question_delete_question($questionv4->id); // Actually deleted. question_delete_question($questionv3->id); // Actually deleted. question_delete_question($questionv2->id); // Hidden, it's being used explicitly. $questionv5 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 5']); $questionv6 = $questiongenerator->update_question($questionv5, null, ['name' => 'Version 6']); $this->assertEquals(7, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $questionv1->questionbankentryid])); // Add a quiz specifically using "version 5" . $quiz2 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]); $quiz2settings = quiz_settings::create($quiz2->id); quiz_add_quiz_question($questionv5->id, $quiz2); $structure2 = $quiz2settings->get_structure(); $structure2->update_slot_version($structure2->get_last_slot()->id, 5); // Add another quiz using the "always latest" version of the question. $quiz3 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]); $quiz3settings = quiz_settings::create($quiz3->id); quiz_add_quiz_question($questionv6->id, $quiz3); $qbe = get_question_bank_entry($questionv1->id); $versions = $DB->get_records( 'question_versions', ['questionbankentryid' => $qbe->id], fields: 'version, questionid, status', ); $this->assertCount(4, $versions); $this->assertEquals($questionv1->id, $versions[1]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status); $this->assertEquals($questionv2->id, $versions[2]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_HIDDEN, $versions[2]->status); $this->assertArrayNotHasKey(3, $versions); $this->assertArrayNotHasKey(4, $versions); $this->assertEquals($questionv5->id, $versions[5]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[5]->status); $this->assertEquals($questionv6->id, $versions[6]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[6]->status); $structure1 = $quizsettings->get_structure(); $this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid); $structure2 = $quiz2settings->get_structure(); $this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid); $structure3 = $quiz3settings->get_structure(); $this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid); $this->restore_to_course($backupid, $testdata->course->id); $versions = $DB->get_records( 'question_versions', ['questionbankentryid' => $qbe->id], fields: 'version, questionid, status', ); $this->assertCount(6, $versions); $this->assertEquals($questionv1->id, $versions[1]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status); // Version 2 is restored to "ready" status. $this->assertEquals($questionv2->id, $versions[2]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[2]->status); // Version 3 is a new copy of $questionv3 with a different ID but the same content. $this->assertEquals($questionv3->name, $DB->get_field('question', 'name', ['id' => $versions[3]->questionid])); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[3]->status); // Version 4 is a new copy of $questionv4 with a different ID but the same content. $this->assertEquals($questionv4->name, $DB->get_field('question', 'name', ['id' => $versions[4]->questionid])); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[4]->status); // Versions 5 and 6 have had their version numbers bumped up by 1. $this->assertEquals($questionv5->id, $versions[5]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[5]->status); $this->assertEquals($questionv6->id, $versions[6]->questionid); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[6]->status); // Questions referencing specific versions still point to the same question. $structure1 = $quizsettings->get_structure(); $this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid); $structure2 = $quiz2settings->get_structure(); $this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid); $structure3 = $quiz3settings->get_structure(); $this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid); // The nextversion has not changed during the restore. $this->assertEquals(7, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $questionv1->questionbankentryid])); } /** * Restoring a question bank entry retains the original nextquestion value. * * @return void * @throws \dml_exception */ public function test_backup_question_nextquestion_retained(): void { global $DB; self::setAdminUser(); $this->resetAfterTest(); $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); $testdata = $this->add_course_quiz_and_qbank(); $questionv1 = $testdata->qbankquestion; $questiongenerator->update_question($questionv1, null, ['name' => 'Version 2']); $this->assertEquals(3, $DB->get_field('question_bank_entries', 'nextversion', ['id' => $questionv1->questionbankentryid])); $backupid = $this->backup_course($testdata->course); $newcourse = $this->getDataGenerator()->create_course(); $this->restore_to_course($backupid, $newcourse->id); $qbanks = get_fast_modinfo($newcourse)->get_instances_of('qbank'); $qbank = reset($qbanks); $qbankcontext = module::instance($qbank->id); $category = question_get_default_category($qbankcontext->id); // The question bank entry for the restored question has the same nextqueston value as the original. $this->assertEquals(3, $DB->get_field('question_bank_entries', 'nextversion', ['questioncategoryid' => $category->id])); } } PK\59pp.tests/local/bank/question_bank_helper_test.phpnu[. namespace core_question; use core\exception\coding_exception; use core_question\local\bank\question_bank_helper; /** * question bank helper class tests. * * @package core_question * @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 \core_question\local\bank\question_bank_helper */ final class question_bank_helper_test extends \advanced_testcase { /** * Assert that at least 1 module type that shares questions exists and that mod_qbank is in the returned list. * * @return void * @covers ::get_activity_types_with_shareable_questions */ public function test_get_shareable_modules(): void { $openmods = question_bank_helper::get_activity_types_with_shareable_questions(); $this->assertGreaterThanOrEqual(1, count($openmods)); $this->assertContains('qbank', $openmods); $this->assertNotContains('quiz', $openmods); } /** * Assert that at least 1 module type that does not share questions exists and that mod_quiz is in the returned list. * * @return void * @covers ::get_activity_types_with_private_questions */ public function test_get_private_modules(): void { $closedmods = question_bank_helper::get_activity_types_with_private_questions(); $this->assertGreaterThanOrEqual(1, count($closedmods)); $this->assertContains('quiz', $closedmods); $this->assertNotContains('qbank', $closedmods); } /** * Setup some courses with quiz and qbank module instances and set different permissions for a user. * Then assert that the correct results are returned from calls to the class methods. * * @covers ::get_activity_instances_with_shareable_questions * @covers ::get_activity_instances_with_private_questions * @return void */ public function test_get_instances(): void { global $DB; $this->resetAfterTest(); $user = self::getDataGenerator()->create_user(); $roles = $DB->get_records('role', [], '', 'shortname, id'); self::setUser($user); $qgen = self::getDataGenerator()->get_plugin_generator('core_question'); $sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank'); $privatemodgen = self::getDataGenerator()->get_plugin_generator('mod_quiz'); $category1 = self::getDataGenerator()->create_category(); $category2 = self::getDataGenerator()->create_category(); $course1 = self::getDataGenerator()->create_course(['category' => $category1->id]); $course2 = self::getDataGenerator()->create_course(['category' => $category1->id]); $course3 = self::getDataGenerator()->create_course(['category' => $category2->id]); $course4 = self::getDataGenerator()->create_course(['category' => $category2->id]); $sharedmod1 = $sharedmodgen->create_instance(['course' => $course1]); $sharedmod1context = \context_module::instance($sharedmod1->cmid); $sharedmod1qcat1 = question_get_default_category($sharedmod1context->id); $sharedmod1qcat2 = $qgen->create_question_category(['contextid' => $sharedmod1context->id]); $sharedmod1qcat2child = $qgen->create_question_category([ 'contextid' => $sharedmod1context->id, 'parent' => $sharedmod1qcat2->id, 'name' => 'A, B, C', ]); $privatemod1 = $privatemodgen->create_instance(['course' => $course1]); $privatemod1context = \context_module::instance($privatemod1->cmid); $privatemod1qcat1 = question_get_default_category($privatemod1context->id); role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod1->cmid)); role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($privatemod1->cmid)); $sharedmod2 = $sharedmodgen->create_instance(['course' => $course2]); $sharedmod2context = \context_module::instance($sharedmod2->cmid); $sharedmod2qcat1 = question_get_default_category($sharedmod2context->id); $sharedmod2qcat2 = $qgen->create_question_category(['contextid' => $sharedmod2context->id]); $sharedmod2qcat2child = $qgen->create_question_category([ 'contextid' => $sharedmod2context->id, 'parent' => $sharedmod2qcat2->id, ]); $privatemod2 = $privatemodgen->create_instance(['course' => $course2]); $privatemod2context = \context_module::instance($privatemod2->cmid); $privatemod1qcat1 = question_get_default_category($privatemod2context->id); role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod2->cmid)); role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($privatemod2->cmid)); // User doesn't have the capability on this one. $sharedmod3 = $sharedmodgen->create_instance(['course' => $course3]); $privatemod3 = $privatemodgen->create_instance(['course' => $course3]); // Exclude this course in the results despite having the capability. $sharedmod4 = $sharedmodgen->create_instance(['course' => $course4]); role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod4->cmid)); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( [], [$course4->id], ['moodle/question:add'], true ); $count = 0; foreach ($sharedbanks as $courseinstance) { // Must all be mod_qbanks. $this->assertEquals('qbank', $courseinstance->cminfo->modname); // Must have 2 categories each bank. $this->assertCount(3, $courseinstance->questioncategories); // Must not include the bank the user does not have access to. $this->assertNotEquals($sharedmod3->name, $courseinstance->name); $this->assertNotEquals($privatemod3->name, $courseinstance->name); $count++; } // Expect count of 2 bank instances. $this->assertEquals(2, $count); $privatebanks = question_bank_helper::get_activity_instances_with_private_questions( [$course1->id], [], ['moodle/question:add'], true ); $count = 0; foreach ($privatebanks as $courseinstance) { // Must all be mod_quiz. $this->assertEquals('quiz', $courseinstance->cminfo->modname); // Must have 1 category in each bank. $this->assertCount(1, $courseinstance->questioncategories); // Must only include the bank from course 1. $this->assertNotContains($courseinstance->cminfo->course, [$course2->id, $course3->id, $course4->id]); $count++; } // Expect count of 1 bank instances. $this->assertEquals(1, $count); } /** * Tests if applying the limit and capability checks are interacting properly. * * @covers ::get_activity_instances_with_shareable_questions * @covers ::get_activity_instances_with_private_questions */ public function test_get_instances_with_limit_and_capabilities(): void { global $DB; $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $teacher = self::getDataGenerator()->create_user(); self::setUser($teacher); $editingteacherroleid = $DB->get_record('role', ['shortname' => 'editingteacher'])->id; $sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank'); // Create 20 question banks, and give the teacher permission to edit only in the last 5. for ($i = 0; $i < 20; $i++) { $sharedmod = $sharedmodgen->create_instance(['course' => $course]); if ($i >= 15) { role_assign($editingteacherroleid, $teacher->id, \context_module::instance($sharedmod->cmid)); } } // We now have created 20 banks. If the limit is below 20, we have to make sure that the code does NOT first apply a limit // of, for example, 15 and check capabilities afterward. This would mean we end up returning 0 qbanks. $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( havingcap: ['moodle/question:add'], limit: 15 ); $this->assertCount(5, $sharedbanks); // On the other hand, check if the limit parameter works at all and is being applied correctly. $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( havingcap: ['moodle/question:add'], limit: 2 ); $this->assertCount(2, $sharedbanks); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(limit: 10); $this->assertCount(10, $sharedbanks); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(limit: 30); $this->assertCount(20, $sharedbanks); $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(limit: 0); $this->assertCount(20, $sharedbanks); } /** * We should be able to filter sharable question bank instances by name. * * @covers ::get_activity_instances_with_shareable_questions * @return void */ public function test_get_instances_by_name(): void { global $DB; $this->resetAfterTest(); $user = self::getDataGenerator()->create_user(); $roles = $DB->get_records('role', [], '', 'shortname, id'); self::setUser($user); $sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank'); $category1 = self::getDataGenerator()->create_category(); $course1 = self::getDataGenerator()->create_course(['category' => $category1->id]); role_assign($roles['editingteacher']->id, $user->id, \core\context\course::instance($course1->id)); $sharedmods = []; for ($i = 1; $i <= 21; $i++) { $sharedmods[$i] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Shared bank {$i}"]); } $sharedmods[22] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Another bank"]); // We get all banks with no parameters. $allsharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(); $this->assertCount(22, $allsharedbanks); // Searching for "2", we get the 4 banks with "2" in the name. $twobanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: '2'); $this->assertCount(4, $twobanks); $this->assertEquals( [$sharedmods[2]->cmid, $sharedmods[12]->cmid, $sharedmods[20]->cmid, $sharedmods[21]->cmid], array_map(fn($bank) => $bank->modid, $twobanks), ); // Searching for "Shared bank" with no limit, we should get all 21, but not "Another bank". $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank'); $this->assertCount(21, $sharedbanks); $this->assertEmpty(array_filter($sharedbanks, fn($bank) => in_array($bank->name, ['Another bank']))); // Searching for "Shared bank" with a limit of 20, we should get all except number 21 and "Another bank". $limitedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank', limit: 20); $this->assertCount(20, $limitedbanks); $this->assertEmpty(array_filter($limitedbanks, fn($bank) => in_array($bank->name, ['Shared bank 21', 'Another bank']))); } /** * Assert creating a default mod_qbank instance on a course provides the expected boilerplate settings. * * @return void * @covers ::create_default_open_instance */ public function test_create_default_open_instance(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); $course = self::getDataGenerator()->create_course(); // Create the instance and assert default values. question_bank_helper::create_default_open_instance($course, $course->fullname); $modinfo = get_fast_modinfo($course); $cminfos = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $cminfos); $cminfo = reset($cminfos); $this->assertEquals($course->fullname, $cminfo->get_name()); $this->assertEquals(0, $cminfo->sectionnum); $modrecord = $DB->get_record('qbank', ['id' => $cminfo->instance]); $this->assertEquals(question_bank_helper::TYPE_STANDARD, $modrecord->type); $this->assertEmpty($cminfo->idnumber); $this->assertEmpty($cminfo->content); // Create a system type bank. question_bank_helper::create_default_open_instance($course, 'System bank 1', question_bank_helper::TYPE_SYSTEM); // Try and create another system type bank. question_bank_helper::create_default_open_instance($course, 'System bank 2', question_bank_helper::TYPE_SYSTEM); $modinfo = get_fast_modinfo($course); $cminfos = $modinfo->get_instances_of('qbank'); $cminfos = array_filter($cminfos, static function($cminfo) { global $DB; return $DB->record_exists('qbank', ['id' => $cminfo->instance, 'type' => question_bank_helper::TYPE_SYSTEM]); }); // Can only be 1 system 'type' bank per course. $this->assertCount(1, $cminfos); $cminfo = reset($cminfos); $this->assertEquals('System bank 1', $cminfo->get_name()); $moddata = $DB->get_record('qbank', ['id' => $cminfo->instance]); $this->assertEquals(get_string('systembankdescription', 'question'), $moddata->intro); $this->assertEquals(1, $cminfo->showdescription); } /** * Create a default instance, passing a name that is too long for the database. * * @return void * @throws \coding_exception * @throws \dml_exception * @throws \moodle_exception */ public function test_create_default_open_instance_with_long_name(): void { $this->resetAfterTest(); self::setAdminUser(); $coursename = random_string(question_bank_helper::BANK_NAME_MAX_LENGTH); $course = self::getDataGenerator()->create_course(['shortname' => $coursename]); $this->expectExceptionMessage('The provided bankname is too long for the database field.'); question_bank_helper::create_default_open_instance( $course, get_string('defaultbank', 'core_question', ['coursename' => $coursename]), ); } /** * Create a default instance, passing a multibyte-character name. * * The name has more bytes than the max length, but is within the character limit as they are multibyte characters. */ public function test_create_default_open_instance_with_multibyte_name(): void { $this->resetAfterTest(); self::setAdminUser(); $coursename = ''; while (strlen($coursename) < question_bank_helper::BANK_NAME_MAX_LENGTH) { $coursename .= '🙂'; } $course = self::getDataGenerator()->create_course(['shortname' => '🙂']); $bankname = get_string('defaultbank', 'core_question', ['coursename' => $coursename]); $this->assertTrue(strlen($bankname) > question_bank_helper::BANK_NAME_MAX_LENGTH); $this->assertTrue(\core_text::strlen($bankname) < question_bank_helper::BANK_NAME_MAX_LENGTH); question_bank_helper::create_default_open_instance($course, $bankname); $modinfo = get_fast_modinfo($course); $cminfos = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $cminfos); $cminfo = reset($cminfos); $this->assertEquals($bankname, $cminfo->get_name()); } /** * Attempting to create a default bank with an empty name throws an exception and does not create the bank. */ public function test_create_default_open_instance_with_empty_name(): void { $this->resetAfterTest(); self::setAdminUser(); $course = self::getDataGenerator()->create_course(); $bankname = ''; try { question_bank_helper::create_default_open_instance($course, $bankname); } catch (coding_exception $e) { $this->assertStringEndsWith( 'The provided bankname is empty. You must provide a name for the question bank.', $e->getMessage(), ); } $modinfo = get_fast_modinfo($course); $cminfos = $modinfo->get_instances_of('qbank'); $this->assertCount(0, $cminfos); } /** * Assert that viewing a question bank logs the view for that user up to a maximum of 5 unique bank views. * * @return void * @covers ::get_recently_used_open_banks * @covers ::add_bank_context_to_recently_viewed */ public function test_recently_viewed_question_banks(): void { $this->resetAfterTest(); $user = self::getDataGenerator()->create_user(); $course1 = self::getDataGenerator()->create_course(); $course2 = self::getDataGenerator()->create_course(); self::getDataGenerator()->enrol_user($user->id, $course1->id, 'editingteacher'); $banks = []; $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]); $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]); $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]); $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]); $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]); $banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]); self::setUser($user); // Trigger bank view on each of them. foreach ($banks as $bank) { $cat = question_get_default_category(\context_module::instance($bank->cmid)->id, true); $context = \context::instance_by_id($cat->contextid); question_bank_helper::add_bank_context_to_recently_viewed($context); } $viewedorder = array_reverse($banks); // Check that the courseid filter works. $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, $course1->id); $this->assertCount(3, $recentlyviewed); // We should have the viewed banks in course 2. $courseviewed = array_slice($banks, 3, 3); $this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($courseviewed, 'cmid')); // Check that the capability filter works. $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']); $this->assertCount(2, $recentlyviewed); // We should have the 2 most recently viewed banks in course 1. $capabilityviewed = array_slice($banks, 1, 2); $this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($capabilityviewed, 'cmid')); $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id); // We only keep a record of 5 maximum. $this->assertCount(5, $recentlyviewed); foreach ($recentlyviewed as $order => $record) { $this->assertEquals($viewedorder[$order]->cmid, $record->modid); } // Now if we view one of those again it should get bumped to the front of the list. $bank3cat = question_get_default_category(\context_module::instance($banks[2]->cmid)->id, true); $bank3context = \context::instance_by_id($bank3cat->contextid); question_bank_helper::add_bank_context_to_recently_viewed($bank3context); $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id); // We should still have 5 maximum. $this->assertCount(5, $recentlyviewed); // The recently viewed on got bumped to the front. $this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->modid); // The others got sorted accordingly behind it. $this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->modid); $this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->modid); $this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->modid); $this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->modid); // Now create a quiz and trigger the bank view of it. $quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $course1]); $quizcat = question_get_default_category(\context_module::instance($quiz->cmid)->id, true); $quizcontext = \context::instance_by_id($quizcat->contextid); question_bank_helper::add_bank_context_to_recently_viewed($quizcontext); $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id); // We should still have 5 maximum. $this->assertCount(5, $recentlyviewed); // Make sure that we only store bank views for plugins that support FEATURE_PUBLISHES_QUESTIONS. foreach ($recentlyviewed as $record) { $this->assertNotEquals($quiz->cmid, $record->modid); } // Now delete one of the viewed bank modules and get the records again. course_delete_module($banks[2]->cmid); $recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id); $this->assertCount(4, $recentlyviewed); // Check the order was retained. $this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->modid); $this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->modid); $this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->modid); $this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->modid); } /** * Assert that getting a default qbank instance on a course works with and without the "$createifnotexists" argument. * * @return void * @covers ::get_default_open_instance_system_type */ public function test_get_default_open_instance_system_type(): void { global $DB; $this->resetAfterTest(); self::setAdminUser(); $course = self::getDataGenerator()->create_course(); $modinfo = get_fast_modinfo($course); $qbanks = $modinfo->get_instances_of('qbank'); $this->assertCount(0, $qbanks); $qbank = question_bank_helper::get_default_open_instance_system_type($course); $this->assertNull($qbank); $qbank = question_bank_helper::get_default_open_instance_system_type($course, true); $this->assertEquals(get_string('systembank', 'question'), $qbank->get_name()); $modrecord = $DB->get_record('qbank', ['id' => $qbank->instance]); $this->assertEquals(question_bank_helper::TYPE_SYSTEM, $modrecord->type); // Create module other than a qbank with an ID that isn't used by a qbank yet. do { $wiki = self::getDataGenerator()->create_module('wiki', [ 'course' => $course->id, ]); } while ($DB->record_exists('qbank', ['id' => $wiki->id])); // Swap the qbank instance record for one with the same ID as the wiki instance. $newqbank = clone($modrecord); $newqbank->id = $wiki->id; $DB->insert_record_raw('qbank', $newqbank, customsequence: true); $DB->delete_records('qbank', ['id' => $qbank->id]); $DB->set_field('course_modules', 'instance', $newqbank->id, ['instance' => $qbank->instance]); // Retry the above again. \course_modinfo::purge_course_caches([$course->id]); $qbank = question_bank_helper::get_default_open_instance_system_type($course); $this->assertEquals(get_string('systembank', 'question'), $qbank->get_name()); $modrecord = $DB->get_record('qbank', ['id' => $qbank->instance]); $this->assertEquals(question_bank_helper::TYPE_SYSTEM, $modrecord->type); } /** * Assert that get_bank_name_string returns suitably truncated strings. * * @dataProvider bank_name_strings * @param string $identifier * @param string $component * @param mixed $params * @param string $expected */ public function test_get_bank_name_string(string $identifier, string $component, mixed $params, string $expected): void { $this->assertEquals($expected, question_bank_helper::get_bank_name_string($identifier, $component, $params)); } /** * Get string examples with different parameter types and lengths. * * @return array[] */ public static function bank_name_strings(): array { $longname = 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' . 'eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven ' . 'twenty-eight twenty-nine thirty thirty-one'; return [ 'String with no parameters' => [ 'systembank', 'question', null, 'System shared question bank', ], 'String with short string parameter' => [ 'topfor', 'question', 'Test course', 'Top for Test course', ], 'String with long string parameter' => [ 'topfor', 'question', $longname, 'Top for One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen ' . 'seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six ' . 'twenty-seven twenty-eight ...', ], 'String with short array parameter' => [ 'defaultbank', 'question', ['coursename' => 'Test course'], 'Test course course question bank', ], 'String with long array parameter' => [ 'defaultbank', 'question', ['coursename' => $longname], 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' . 'eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six ' . 'twenty-seven twenty-eight ... course question bank', ], 'String with multiple long array parameters' => [ 'markoutofmax', 'question', ['mark' => $longname, 'max' => $longname], 'Mark One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' . 'eighteen ... out of One two three four five six seven eight nine ten eleven twelve thirteen fourteen ' . 'fifteen sixteen seventeen eighteen ...', ], 'Long lang string' => [ 'howquestionsbehave_help', 'question', null, 'Students can interact with the questions in the quiz in various different ways. For example, you may wish the ' . 'students to enter an answer to each question and then submit the entire quiz, before anything is graded or ' . 'they get any feedback. That would ...', ], ]; } } PK\ll)tests/local/bank/version_options_test.phpnu[. namespace core_question\local\bank; /** * Unit tests for the version_options class and associated methods. * * @package core_question * @copyright 2024 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Conn Warwicker * @covers \core_question\local\bank\version_options */ final class version_options_test extends \advanced_testcase { /** * Tests the retrieval and correctness of version selection menu options for a given question. * * This method verifies that the correct version options are available for a specific question * within a course, ensuring that the options are accurately labeled and ordered. It includes * checks for version labeling, the presence of an "Always latest" option, and the correct count * and sequence of version numbers after creating multiple versions of a question. * * @return void */ public function test_get_version_options(): void { // Reset everything after this test completes. $this->resetAfterTest(); // Load the generators we need. $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); // Create a test course we can use the question bank on. $category = self::getDataGenerator()->create_category(); $course = self::getDataGenerator()->create_course(['category' => $category->id]); $coursecontext = \core\context\course::instance($course->id); // Create a question category on the course. $cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]); // Create a question within that category. $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Get the select menu options for the question versions. $options = version_options::get_version_menu_options($question->id); // Assert that there are 2 and that they "Always latest" is first. $this->assertCount(2, $options); $this->assertEquals('Always latest', $options[0]); $this->assertEquals('v1 (latest)', $options[1]); // Create some new versions of the question. $questiongenerator->update_question($question, null, ['name' => 'This is the second version']); $questiongenerator->update_question($question, null, ['name' => 'This is the third version']); // Get the options which would be used for the select menu. $options = version_options::get_version_menu_options($question->id); // Assert that there are now 4. $this->assertCount(4, $options); // Assert that they are keyed to their verison numbers. $this->assertEquals('Always latest', $options[0]); $this->assertEquals('v1', $options[1]); $this->assertEquals('v2', $options[2]); $this->assertEquals('v3 (latest)', $options[3]); // Assert that they are in the correct order of "Always latest" first, then descending versions. $this->assertEquals(array_keys($options), [0, 3, 2, 1]); } } PK\Aɵ2 2 6tests/local/bank/context_to_string_translator_test.phpnu[. namespace core_question\local\bank; use context_course; use context_coursecat; use context_module; use context_system; use context_user; /** * Unit tests for the context_to_string_translator class. * * @package core_question * @category test * @copyright 2023 the Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_question\local\bank\context_to_string_translator */ final class context_to_string_translator_test extends \advanced_testcase { public function test_context_to_string_translator_test_good_case(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); // Generate a quiz in a course in a category. $systemcontext = context_system::instance(); $category = $generator->create_category(); $categorycontext = context_coursecat::instance($category->id); $course = $generator->create_course(['category' => $category->id]); $coursecontext = context_course::instance($course->id); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $quizcontext = context_module::instance($quiz->cmid); // Create the context_to_string_translator. $translator = new context_to_string_translator([$systemcontext, $categorycontext, $coursecontext, $quizcontext]); // Verify its behaviour. $this->assertEquals('module', $translator->context_to_string($quizcontext->id)); $this->assertEquals($quizcontext->id, $translator->string_to_context('module')); $this->assertEquals('course', $translator->context_to_string($coursecontext->id)); $this->assertEquals($coursecontext->id, $translator->string_to_context('course')); $this->assertEquals('cat1', $translator->context_to_string($categorycontext->id)); $this->assertEquals($categorycontext->id, $translator->string_to_context('cat1')); $this->assertEquals('system', $translator->context_to_string($systemcontext->id)); $this->assertEquals($systemcontext->id, $translator->string_to_context('system')); } public function test_context_to_string_translator_throws_exception_with_bad_context(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $context = context_user::instance($USER->id); $this->expectExceptionMessage('Unexpected context level User for context ' . $context->id . ' in generate_context_to_string_array. ' . 'Questions can never exist in this type of context.'); new context_to_string_translator((new question_edit_contexts($context))->all()); } } PK\dNqq6tests/local/statistics/statistics_bulk_loader_test.phpnu[. namespace core_question\local\statistics; use advanced_testcase; use context; use context_module; use core_question\statistics\questions\all_calculated_for_qubaid_condition; use quiz_statistics\tests\statistics_helper; use core_question_generator; use Generator; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use question_engine; use ReflectionMethod; /** * Tests for question statistics. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_question\local\statistics\statistics_bulk_loader */ final class statistics_bulk_loader_test extends advanced_testcase { use \mod_quiz\tests\question_helper_test_trait; /** @var float Delta used when comparing statistics values out-of 1. */ protected const DELTA = 0.00005; /** @var float Delta used when comparing statistics values out-of 100. */ protected const PERCENT_DELTA = 0.005; /** * Test quizzes that contain a specified question. * * @covers ::get_all_places_where_questions_were_attempted */ public function test_get_all_places_where_questions_were_attempted(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $rcm = new ReflectionMethod(statistics_bulk_loader::class, 'get_all_places_where_questions_were_attempted'); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create three quizzes. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz1 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0', ]); $quiz1context = context_module::instance($quiz1->cmid); $quiz2 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0', ]); $quiz2context = context_module::instance($quiz2->cmid); $quiz3 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0', ]); $quiz3context = context_module::instance($quiz3->cmid); // Create questions. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $question2 = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Add question 1 to quiz 1 and make an attempt. quiz_add_quiz_question($question1->id, $quiz1); // Quiz 1 attempt. $this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]); // Add questions 1 and 2 to quiz 2. quiz_add_quiz_question($question1->id, $quiz2); quiz_add_quiz_question($question2->id, $quiz2); $this->submit_quiz($quiz2, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]); // Checking quizzes that use question 1. $q1places = $rcm->invoke(null, [$question1->id]); $this->assertCount(2, $q1places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]); // Checking quizzes that contain question 2. $q2places = $rcm->invoke(null, [$question2->id]); $this->assertCount(1, $q2places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]); // Add a random question to quiz3. $this->add_random_questions($quiz3->id, 0, $cat->id, 1, false); $this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]); // Quiz 3 will now be in one of these arrays. $q1places = $rcm->invoke(null, [$question1->id]); $q2places = $rcm->invoke(null, [$question2->id]); if (count($q1places) == 3) { $newplace = end($q1places); } else { $newplace = end($q2places); } $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace); // Simulate the situation where the context for quiz3 is gone from the database, without // the corresponding attempt data being properly cleaned up. Ensure this does not cause errors. $DB->delete_records('context', ['id' => context_module::instance($quiz3->cmid)->id]); accesslib_clear_all_caches_for_unit_testing(); // Same asserts as above, before we added quiz3. $q1places = $rcm->invoke(null, [$question1->id]); $this->assertCount(2, $q1places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]); $q2places = $rcm->invoke(null, [$question2->id]); $this->assertCount(1, $q2places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]); } /** * Create 2 quizzes. * * @return array return 2 quizzes */ private function prepare_quizzes(): array { // Create a course. $course = $this->getDataGenerator()->create_course(); // Make 2 quizzes. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $layout = '1,2,0,3,4,0'; $quiz1 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout, ]); $quiz2 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout, ]); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $page = 1; $questions = []; foreach (explode(',', $layout) as $slot) { if ($slot == 0) { $page += 1; continue; } $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $questions[$slot] = $question; quiz_add_quiz_question($question->id, $quiz1, $page); quiz_add_quiz_question($question->id, $quiz2, $page); } return [$quiz1, $quiz2, $questions]; } /** * Submit quiz answers * * @param object $quiz * @param array $answers */ private function submit_quiz(object $quiz, array $answers): void { // Create user. $user = $this->getDataGenerator()->create_user(); // Create attempt. $quizobj = quiz_settings::create($quiz->id, $user->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Submit attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, $answers); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); } /** * Generate attempt answers. * * @param array $correctanswerflags array of 1 or 0 * 1 : generate correct answer * 0 : generate wrong answer * * @return array */ private static function generate_attempt_answers(array $correctanswerflags): array { $attempt = []; for ($i = 1; $i <= 4; $i++) { if (isset($correctanswerflags) && $correctanswerflags[$i - 1] == 1) { // Correct answer. $attempt[$i] = ['answer' => 'frog']; } else { $attempt[$i] = ['answer' => 'false']; } } return $attempt; } /** * Generate quizzes and submit answers. * * @param array $quiz1attempts quiz 1 attempts * @param array $quiz2attempts quiz 2 attempts * * @return array */ private function prepare_and_submit_quizzes(array $quiz1attempts, array $quiz2attempts): array { [$quiz1, $quiz2, $questions] = $this->prepare_quizzes(); // Submit attempts of quiz1. foreach ($quiz1attempts as $attempt) { $this->submit_quiz($quiz1, $attempt); } // Submit attempts of quiz2. foreach ($quiz2attempts as $attempt) { $this->submit_quiz($quiz2, $attempt); } // Calculate the statistics. $this->expectOutputRegex('~.*Calculations completed.*~'); statistics_helper::run_pending_recalculation_tasks(); return [$quiz1, $quiz2, $questions]; } /** * To use private helper::extract_item_value function. * * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. * @param int $questionid a question id. * @param string $item one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. * @return float|null the required value. */ private function extract_item_value( all_calculated_for_qubaid_condition $statistics, int $questionid, string $item ): ?float { $rcm = new ReflectionMethod(statistics_bulk_loader::class, 'extract_item_value'); return $rcm->invoke(null, $statistics, $questionid, $item); } /** * To use private helper::load_statistics_for_place function (with mod_quiz component). * * @param context $context the context to load the statistics for. * @return all_calculated_for_qubaid_condition|null question statistics. */ private function load_quiz_statistics_for_place(context $context): ?all_calculated_for_qubaid_condition { $rcm = new ReflectionMethod(statistics_bulk_loader::class, 'load_statistics_for_place'); return $rcm->invoke(null, 'mod_quiz', $context); } /** * Data provider for {@see test_load_question_facility()}. * * @return Generator */ public static function load_question_facility_provider(): Generator { yield 'Facility case 1' => [ // Quiz 1 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), ], // Expected quiz 1 facilities. [1.0, 0.0, 0.0, 0.0], // Quiz 2 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), self::generate_attempt_answers([1, 1, 0, 0]), ], // Expected quiz 2 facilities. [1.0, 0.5, 0.0, 0.0], // Expected average facilities. [1.0, 0.25, 0.0, 0.0], ]; yield 'Facility case 2' => [ // Quiz 1 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), self::generate_attempt_answers([1, 1, 0, 0]), self::generate_attempt_answers([1, 1, 1, 0]), ], // Expected quiz 1 facilities. [1.0, 0.6667, 0.3333, 0.0], // Quiz 2 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), self::generate_attempt_answers([1, 1, 0, 0]), self::generate_attempt_answers([1, 1, 1, 0]), self::generate_attempt_answers([1, 1, 1, 1]), ], // Expected quiz 2 facilities. [1.0, 0.75, 0.5, 0.25], // Expected average facilities. [1.0, 0.7083, 0.4167, 0.1250], ]; } /** * Test question facility * * @dataProvider load_question_facility_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1facilities expected quiz 1 facilities * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2facilities expected quiz 2 facilities * @param array $expectedaveragefacilities expected average facilities */ public function test_load_question_facility( array $quiz1attempts, array $expectedquiz1facilities, array $quiz2attempts, array $expectedquiz2facilities, array $expectedaveragefacilities ): void { $this->resetAfterTest(); [$quiz1, $quiz2, $questions] = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 facilities. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid)); $quiz1facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility'); $quiz1facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility'); $quiz1facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility'); $quiz1facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility'); $this->assertEqualsWithDelta($expectedquiz1facilities[0], $quiz1facility1, self::DELTA); $this->assertEqualsWithDelta($expectedquiz1facilities[1], $quiz1facility2, self::DELTA); $this->assertEqualsWithDelta($expectedquiz1facilities[2], $quiz1facility3, self::DELTA); $this->assertEqualsWithDelta($expectedquiz1facilities[3], $quiz1facility4, self::DELTA); // Quiz 2 facilities. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid)); $quiz2facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility'); $quiz2facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility'); $quiz2facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility'); $quiz2facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility'); $this->assertEqualsWithDelta($expectedquiz2facilities[0], $quiz2facility1, self::DELTA); $this->assertEqualsWithDelta($expectedquiz2facilities[1], $quiz2facility2, self::DELTA); $this->assertEqualsWithDelta($expectedquiz2facilities[2], $quiz2facility3, self::DELTA); $this->assertEqualsWithDelta($expectedquiz2facilities[3], $quiz2facility4, self::DELTA); // Average question facilities. $stats = statistics_bulk_loader::load_aggregate_statistics( [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], ['facility'] ); $this->assertEqualsWithDelta( $expectedaveragefacilities[0], $stats[$questions[1]->id]['facility'], self::DELTA ); $this->assertEqualsWithDelta( $expectedaveragefacilities[1], $stats[$questions[2]->id]['facility'], self::DELTA ); $this->assertEqualsWithDelta( $expectedaveragefacilities[2], $stats[$questions[3]->id]['facility'], self::DELTA ); $this->assertEqualsWithDelta( $expectedaveragefacilities[3], $stats[$questions[4]->id]['facility'], self::DELTA ); } /** * Data provider for {@see test_load_question_discriminative_efficiency()}. * @return Generator */ public static function load_question_discriminative_efficiency_provider(): Generator { yield 'Discriminative efficiency' => [ // Quiz 1 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), self::generate_attempt_answers([1, 1, 0, 0]), self::generate_attempt_answers([1, 0, 1, 0]), self::generate_attempt_answers([1, 1, 1, 1]), ], // Expected quiz 1 discriminative efficiency. [null, 33.33, 33.33, 100.00], // Quiz 2 attempts. [ self::generate_attempt_answers([1, 1, 1, 1]), self::generate_attempt_answers([0, 0, 0, 0]), self::generate_attempt_answers([1, 0, 0, 1]), self::generate_attempt_answers([0, 1, 1, 0]), ], // Expected quiz 2 discriminative efficiency. [50.00, 50.00, 50.00, 50.00], // Expected average discriminative efficiency. [50.00, 41.67, 41.67, 75.00], ]; } /** * Test discriminative efficiency * * @dataProvider load_question_discriminative_efficiency_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1discriminativeefficiency expected quiz 1 discriminative efficiency * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2discriminativeefficiency expected quiz 2 discriminative efficiency * @param array $expectedaveragediscriminativeefficiency expected average discriminative efficiency */ public function test_load_question_discriminative_efficiency( array $quiz1attempts, array $expectedquiz1discriminativeefficiency, array $quiz2attempts, array $expectedquiz2discriminativeefficiency, array $expectedaveragediscriminativeefficiency ): void { $this->resetAfterTest(); [$quiz1, $quiz2, $questions] = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 discriminative efficiency. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid)); $discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency'); $discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency'); $discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency'); $discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency'); $this->assertEqualsWithDelta( $expectedquiz1discriminativeefficiency[0], $discriminativeefficiency1, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminativeefficiency[1], $discriminativeefficiency2, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminativeefficiency[2], $discriminativeefficiency3, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminativeefficiency[3], $discriminativeefficiency4, self::PERCENT_DELTA ); // Quiz 2 discriminative efficiency. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid)); $discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency'); $discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency'); $discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency'); $discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency'); $this->assertEqualsWithDelta( $expectedquiz2discriminativeefficiency[0], $discriminativeefficiency1, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminativeefficiency[1], $discriminativeefficiency2, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminativeefficiency[2], $discriminativeefficiency3, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminativeefficiency[3], $discriminativeefficiency4, self::PERCENT_DELTA ); // Average question discriminative efficiency. $stats = statistics_bulk_loader::load_aggregate_statistics( [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], ['discriminativeefficiency'] ); $this->assertEqualsWithDelta( $expectedaveragediscriminativeefficiency[0], $stats[$questions[1]->id]['discriminativeefficiency'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminativeefficiency[1], $stats[$questions[2]->id]['discriminativeefficiency'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminativeefficiency[2], $stats[$questions[3]->id]['discriminativeefficiency'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminativeefficiency[3], $stats[$questions[4]->id]['discriminativeefficiency'], self::PERCENT_DELTA ); } /** * Data provider for {@see test_load_question_discrimination_index()}. * @return Generator */ public static function load_question_discrimination_index_provider(): Generator { yield 'Discrimination Index' => [ // Quiz 1 attempts. [ self::generate_attempt_answers([1, 0, 0, 0]), self::generate_attempt_answers([1, 1, 0, 0]), self::generate_attempt_answers([1, 0, 1, 0]), self::generate_attempt_answers([1, 1, 1, 1]), ], // Expected quiz 1 Discrimination Index. [null, 30.15, 30.15, 81.65], // Quiz 2 attempts. [ self::generate_attempt_answers([1, 1, 1, 1]), self::generate_attempt_answers([0, 0, 0, 0]), self::generate_attempt_answers([1, 0, 0, 1]), self::generate_attempt_answers([0, 1, 1, 0]), ], // Expected quiz 2 discrimination Index. [44.72, 44.72, 44.72, 44.72], // Expected average discrimination Index. [44.72, 37.44, 37.44, 63.19], ]; } /** * Test discrimination index * * @dataProvider load_question_discrimination_index_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1discriminationindex expected quiz 1 discrimination index * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2discriminationindex expected quiz 2 discrimination index * @param array $expectedaveragediscriminationindex expected average discrimination index */ public function test_load_question_discrimination_index( array $quiz1attempts, array $expectedquiz1discriminationindex, array $quiz2attempts, array $expectedquiz2discriminationindex, array $expectedaveragediscriminationindex ): void { $this->resetAfterTest(); [$quiz1, $quiz2, $questions] = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 discrimination index. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid)); $discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex'); $discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex'); $discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex'); $discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex'); $this->assertEqualsWithDelta( $expectedquiz1discriminationindex[0], $discriminationindex1, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminationindex[1], $discriminationindex2, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminationindex[2], $discriminationindex3, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz1discriminationindex[3], $discriminationindex4, self::PERCENT_DELTA ); // Quiz 2 discrimination index. $stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid)); $discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex'); $discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex'); $discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex'); $discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex'); $this->assertEqualsWithDelta( $expectedquiz2discriminationindex[0], $discriminationindex1, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminationindex[1], $discriminationindex2, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminationindex[2], $discriminationindex3, self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedquiz2discriminationindex[3], $discriminationindex4, self::PERCENT_DELTA ); // Average question discrimination index. $stats = statistics_bulk_loader::load_aggregate_statistics( [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], ['discriminationindex'] ); $this->assertEqualsWithDelta( $expectedaveragediscriminationindex[0], $stats[$questions[1]->id]['discriminationindex'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminationindex[1], $stats[$questions[2]->id]['discriminationindex'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminationindex[2], $stats[$questions[3]->id]['discriminationindex'], self::PERCENT_DELTA ); $this->assertEqualsWithDelta( $expectedaveragediscriminationindex[3], $stats[$questions[4]->id]['discriminationindex'], self::PERCENT_DELTA ); } /** * Test with question statistics disabled */ public function test_statistics_disabled(): void { $this->resetAfterTest(); // Prepare some quizzes and attempts. Exactly what is not important to this test. $quiz1attempts = [self::generate_attempt_answers([1, 0, 0, 0])]; $quiz2attempts = [self::generate_attempt_answers([1, 1, 1, 1])]; [, , $questions] = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Prepare some useful arrays. $expectedstats = [ $questions[1]->id => [], $questions[2]->id => [], $questions[3]->id => [], $questions[4]->id => [], ]; $questionids = array_keys($expectedstats); // Ask to load no statistics at all. $stats = statistics_bulk_loader::load_aggregate_statistics($questionids, []); // Verify we got the right thing. $this->assertEquals($expectedstats, $stats); } } PK\eAHHtests/version_test.phpnu[. namespace core_question; use core_question\local\bank\question_version_status; use core_question\output\question_version_info; use question_bank; /** * Question version unit tests. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \question_bank */ final class version_test extends \advanced_testcase { /** * @var \context_module module context. */ protected $context; /** * @var \stdClass course object. */ protected $course; /** * @var \component_generator_base question generator. */ protected $qgenerator; /** * @var \stdClass quiz object. */ protected $quiz; /** * Called before every test. */ protected function setUp(): void { parent::setUp(); self::setAdminUser(); $this->resetAfterTest(); $datagenerator = $this->getDataGenerator(); $this->course = $datagenerator->create_course(); $this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]); $this->qgenerator = $datagenerator->get_plugin_generator('core_question'); $this->context = \context_module::instance($this->quiz->cmid); } protected function tearDown(): void { question_version_info::$pendingdefinitions = []; parent::tearDown(); } /** * Test if creating a question a new version and bank entry records are created. * * @covers ::load_question */ public function test_make_question_create_version_and_bank_entry(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); // Get the question object after creating a question. $questiondefinition = question_bank::load_question($question->id); // The version and bank entry in the object should be the same. $sql = "SELECT qv.id AS versionid, qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.id = ?"; $questionversion = $DB->get_record_sql($sql, [$questiondefinition->id]); $this->assertEquals($questionversion->versionid, $questiondefinition->versionid); $this->assertEquals($questionversion->questionbankentryid, $questiondefinition->questionbankentryid); // If a question is updated, a new version should be created. $question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); $newquestiondefinition = question_bank::load_question($question->id); // The version should be 2. $this->assertEquals('2', $newquestiondefinition->version); // Both versions should be in same bank entry. $this->assertEquals($questiondefinition->questionbankentryid, $newquestiondefinition->questionbankentryid); } /** * Test if deleting a question the related version and bank entry records are deleted. * * @covers ::load_question * @covers ::question_delete_question */ public function test_delete_question_delete_versions(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); $questionfirstversionid = $question->id; // Create a new version and try to remove it. $question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); // The new version and bank entry record should exist. $sql = "SELECT q.id, qv.id AS versionid, qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.id = ?"; $questionobject = $DB->get_records_sql($sql, [$question->id]); $this->assertCount(1, $questionobject); // Try to delete new version. question_delete_question($question->id); // The version record should not exist. $sql = "SELECT qv.* FROM {question_versions} qv WHERE qv.id = ?"; $questionversion = $DB->get_record_sql($sql, [$questionobject[$question->id]->versionid]); $this->assertFalse($questionversion); // The bank entry record should exist because there is an older version. $sql = "SELECT qbe.* FROM {question_bank_entries} qbe WHERE qbe.id = ?"; $questionbankentry = $DB->get_records_sql($sql, [$questionobject[$question->id]->questionbankentryid]); $this->assertCount(1, $questionbankentry); // Now remove the first version. question_delete_question($questionfirstversionid); $sql = "SELECT qbe.* FROM {question_bank_entries} qbe WHERE qbe.id = ?"; $questionbankentry = $DB->get_record_sql($sql, [$questionobject[$question->id]->questionbankentryid]); // The bank entry record should not exist. $this->assertFalse($questionbankentry); } /** * Test if deleting a question will not break a quiz. * * @covers ::load_question * @covers ::quiz_add_quiz_question * @covers ::question_delete_question */ public function test_delete_question_in_use(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); $questionfirstversionid = $question->id; // Create a new version and try to remove it after adding it to a quiz. $question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); // Add it to the quiz. quiz_add_quiz_question($question->id, $this->quiz); // Try to delete new version. question_delete_question($question->id); // Try to delete old version. question_delete_question($questionfirstversionid); // The used question version should exist even after trying to remove it, but now hidden. $questionversion2 = question_bank::load_question($question->id); $this->assertEquals($question->id, $questionversion2->id); $this->assertEquals(question_version_status::QUESTION_STATUS_HIDDEN, $questionversion2->status); // The unused version should be completely gone. $this->assertFalse($DB->record_exists('question', ['id' => $questionfirstversionid])); } /** * Test if moving a category will not break a quiz. * * @covers ::load_question * @covers ::quiz_add_quiz_question */ public function test_move_category_with_questions(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory->id]); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course->id]); $bankcontext = \context_module::instance($qbank->cmid); $qcategorysys = $this->qgenerator->create_question_category(['contextid' => $bankcontext->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategorychild->id]); $questiondefinition = question_bank::load_question($question->id); // Add it to the quiz. quiz_add_quiz_question($question->id, $this->quiz); // Move the category to qbank context. $manager = new category_manager(); $manager->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id); // The bank entry record should point to the new category in order to not break quizzes. $sql = "SELECT qbe.questioncategoryid FROM {question_bank_entries} qbe WHERE qbe.id = ?"; $questionbankentry = $DB->get_record_sql($sql, [$questiondefinition->questionbankentryid]); $this->assertEquals($qcategorysys->id, $questionbankentry->questioncategoryid); } /** * Test that all versions will have the same bank entry idnumber value. * * @covers ::load_question */ public function test_id_number_in_bank_entry(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, [ 'category' => $qcategory->id, 'idnumber' => 'id1' ]); $questionid1 = $question->id; // Create a new version and try to remove it after adding it to a quiz. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']); $questionid2 = $question->id; // Change the id number and get the question object. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']); $questionid3 = $question->id; // The new version and bank entry record should exist. $questiondefinition = question_bank::load_question($question->id); $sql = "SELECT q.id AS questionid, qv.id AS versionid, qbe.id AS questionbankentryid, qbe.idnumber FROM {question_bank_entries} qbe JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id JOIN {question} q ON q.id = qv.questionid WHERE qbe.id = ?"; $questionbankentry = $DB->get_records_sql($sql, [$questiondefinition->questionbankentryid]); // We should have 3 versions and 1 question bank entry with the same idnumber. $this->assertCount(3, $questionbankentry); $this->assertEquals($questionbankentry[$questionid1]->idnumber, 'id3'); $this->assertEquals($questionbankentry[$questionid2]->idnumber, 'id3'); $this->assertEquals($questionbankentry[$questionid3]->idnumber, 'id3'); } /** * Test that all the versions are available from the method. * * @covers ::get_all_versions_of_question */ public function test_get_all_versions_of_question(): void { $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, [ 'category' => $qcategory->id, 'idnumber' => 'id1' ]); $questionid1 = $question->id; // Create a new version. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']); $questionid2 = $question->id; // Change the id number and get the question object. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']); $questionid3 = $question->id; $questiondefinition = question_bank::get_all_versions_of_question($question->id); // Test the versions are available. $this->assertEquals(array_slice($questiondefinition, 0, 1)[0]->questionid, $questionid3); $this->assertEquals(array_slice($questiondefinition, 1, 1)[0]->questionid, $questionid2); $this->assertEquals(array_slice($questiondefinition, 2, 1)[0]->questionid, $questionid1); } /** * Test that all the versions of questions are available from the method. * * @covers ::get_all_versions_of_questions */ public function test_get_all_versions_of_questions(): void { global $DB; $questionversions = []; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, [ 'category' => $qcategory->id, 'idnumber' => 'id1' ]); $questionversions[1] = $question->id; // Create a new version. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']); $questionversions[2] = $question->id; // Change the id number and get the question object. $question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']); $questionversions[3] = $question->id; $questionbankentryid = $DB->get_record('question_versions', ['questionid' => $question->id], 'questionbankentryid'); $questionversionsofquestions = question_bank::get_all_versions_of_questions([$question->id]); $questionbankentryids = array_keys($questionversionsofquestions)[0]; $this->assertEquals($questionbankentryid->questionbankentryid, $questionbankentryids); $this->assertEquals($questionversions, $questionversionsofquestions[$questionbankentryids]); } /** * Test the get_version_of_questions function. * * @covers ::get_version_of_questions */ public function test_get_version_of_questions(): void { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); // Update the question to create new versions. $question = $this->qgenerator->update_question($question, null, ['name' => 'Version 2']); // Get the current version of the question. $currentversions = question_bank::get_version_of_questions([$question->id]); // Get questionbankentryid for assertion. $questionbankentryid = $DB->get_field('question_versions', 'questionbankentryid', ['questionid' => $question->id]); // Assert that the structure matches. $this->assertArrayHasKey($questionbankentryid, $currentversions); $this->assertArrayHasKey(2, $currentversions[$questionbankentryid]); $this->assertEquals($question->id, $currentversions[$questionbankentryid][2]); // Update the question to create new versions. $question = $this->qgenerator->update_question($question, null, ['name' => 'Version 3']); $currentversions = question_bank::get_version_of_questions([$question->id]); // Assert the updated version. $this->assertArrayHasKey($questionbankentryid, $currentversions); $this->assertArrayHasKey(3, $currentversions[$questionbankentryid]); $this->assertEquals($question->id, $currentversions[$questionbankentryid][3]); } /** * Test population of latestversion field in question_definition objects * * When an instance of question_definition is created, it is added to an array of pending definitions which * do not yet have the latestversion field populated. When one definition has its latestversion property accessed, * all pending definitions have their latestversion field populated at once. * * @covers \core_question\output\question_version_info::populate_latest_versions() * @return void */ public function test_populate_definition_latestversions(): void { $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $question1 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); $question2 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); $question3 = $this->qgenerator->update_question($question2, null, ['idnumber' => 'id2']); $latestversioninspector = new \ReflectionProperty('question_definition', 'latestversion'); $this->assertEmpty(question_version_info::$pendingdefinitions); $questiondef1 = question_bank::load_question($question1->id); $questiondef2 = question_bank::load_question($question2->id); $questiondef3 = question_bank::load_question($question3->id); $this->assertContains($questiondef1, question_version_info::$pendingdefinitions); $this->assertContains($questiondef2, question_version_info::$pendingdefinitions); $this->assertContains($questiondef3, question_version_info::$pendingdefinitions); $this->assertNull($latestversioninspector->getValue($questiondef1)); $this->assertNull($latestversioninspector->getValue($questiondef2)); $this->assertNull($latestversioninspector->getValue($questiondef3)); // Read latestversion from one definition. This should populate the field in all pending definitions. $latestversion1 = $questiondef1->latestversion; $this->assertEmpty(question_version_info::$pendingdefinitions); $this->assertNotNull($latestversioninspector->getValue($questiondef1)); $this->assertNotNull($latestversioninspector->getValue($questiondef2)); $this->assertNotNull($latestversioninspector->getValue($questiondef3)); $this->assertEquals($latestversion1, $latestversioninspector->getValue($questiondef1)); $this->assertEquals($questiondef1->version, $questiondef1->latestversion); $this->assertNotEquals($questiondef2->version, $questiondef2->latestversion); $this->assertEquals($questiondef3->version, $questiondef3->latestversion); } } PK\oL#tests/question_bank_column_test.phpnu[. namespace core_question; use core_question\local\bank\question_edit_contexts; use core_question\local\bank\view; use testable_core_question_column; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/question/tests/fixtures/testable_core_question_column.php'); /** * Unit tests for the question bank column class. * * @package core_question * @copyright 2018 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_bank_column_test extends \advanced_testcase { /** * Test function display_header multiple sorts with no custom tooltips. * */ public function test_column_header_multi_sort_no_tooltips(): void { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]); $cm = get_coursemodule_from_id('qbank', $qbank->cmid); $questionbank = new view( new question_edit_contexts(\context_module::instance($cm->id)), new \moodle_url('/'), $course, $cm ); $columnbase = new testable_core_question_column($questionbank); $sortable = [ 'apple' => [ 'field' => 'apple', 'title' => 'Apple' ], 'banana' => [ 'field' => 'banana', 'title' => 'Banana' ] ]; $columnbase->set_sortable($sortable); ob_start(); $columnbase->display_header(); $output = ob_get_clean(); $this->assertStringContainsString(' title="Sort by Apple ascending">', $output); $this->assertStringContainsString(' title="Sort by Banana ascending">', $output); } /** * Test function display_header multiple sorts with custom tooltips. * */ public function test_column_header_multi_sort_with_tooltips(): void { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]); $cm = get_coursemodule_from_id('qbank', $qbank->cmid); $questionbank = new view( new question_edit_contexts(\context_module::instance($cm->id)), new \moodle_url('/'), $course, $cm ); $columnbase = new testable_core_question_column($questionbank); $sortable = [ 'apple' => [ 'field' => 'apple', 'title' => 'Apple', 'tip' => 'Apple Tooltips' ], 'banana' => [ 'field' => 'banana', 'title' => 'Banana', 'tip' => 'Banana Tooltips' ] ]; $columnbase->set_sortable($sortable); ob_start(); $columnbase->display_header(); $output = ob_get_clean(); $this->assertStringContainsString(' title="Sort by Apple Tooltips ascending">', $output); $this->assertStringContainsString(' title="Sort by Banana Tooltips ascending">', $output); } } PK\a@@-tests/legacy_question_set_conversion_test.phpnu[. namespace core_question; /** * Test the conversion of legacy random question sets into the newer format. * * @package core_question * @copyright 2025 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Conn Warwicker * @covers \core_question\question_reference_manager */ final class legacy_question_set_conversion_test extends \advanced_testcase { /** * Test the conversion of the old formatted `filtercondition` value to the newer format. * @covers \core_question\question_reference_manager::convert_legacy_set_reference_filter_condition * @return void */ public function test_legacy_question_set_conversion(): void { $this->resetAfterTest(false); // Test conversion without a valid question category. $old = [ 'questioncategoryid' => 123, 'includingsubcategories' => 0, ]; $new = question_reference_manager::convert_legacy_set_reference_filter_condition($old); $expected = [ 'filter' => [ 'category' => [ 'jointype' => 1, 'values' => [123], 'filteroptions' => [ 'includesubcategories' => 0, ], ], ], 'cat' => '', 'tabname' => 'questions', 'qpage' => 0, 'qperpage' => 100, 'jointype' => 2, ]; $this->assertEquals($new, $expected); // Test conversion with a valid question category. // Generate a question category. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); // Add the valid category into the arrays. $old['questioncategoryid'] = $cat->id; $expected['cat'] = "{$cat->id},{$cat->contextid}"; $expected['filter']['category']['values'] = [$cat->id]; $new = question_reference_manager::convert_legacy_set_reference_filter_condition($old); $this->assertEquals($new, $expected); } /** * Verifies that a legacy tag filter re-uses an existing tag that * lives in the default collection and does not create a duplicate. * * @covers \core_question\question_reference_manager::convert_legacy_set_reference_filter_condition * @return void */ public function test_tag_conversion_uses_existing_tag_in_default_collection(): void { $this->resetAfterTest(); // Prepare a tag that already exists in the default collection. $defaultcollectionid = \core_tag_collection::get_default(); // Create a tag inside that collection. $tag = \core_tag_tag::create_if_missing($defaultcollectionid, ['legacytag'])['legacytag']; // Legacy random-question filter – tag specified as "id,rawname". $legacyfilter = ['tags' => ["{$tag->id},legacytag"]]; $converted = question_reference_manager::convert_legacy_set_reference_filter_condition($legacyfilter); $this->assertEquals( [$tag->id], $converted['filter']['qtagids']['values'], 'Converter should preserve the existing tag ID and avoid duplicates.' ); } /** * Verifies that a legacy tag filter respects a custom collection after * the *question* tag-area has been moved there. * * @covers \core_question\question_reference_manager::convert_legacy_set_reference_filter_condition * @return void */ public function test_tag_conversion_respects_custom_collection(): void { $this->resetAfterTest(); // Create a custom collection and move the question tag-area to it. $customcollection = \core_tag_collection::create((object) [ 'name' => 'Questions', 'component' => 'core_question', 'searchable' => 0, ]); $questionarea = \core_tag_area::get_areas()['question']['core_question']; \core_tag_area::update($questionarea, ['tagcollid' => $customcollection->id]); // Create a tag inside that collection. $tag = \core_tag_tag::create_if_missing($customcollection->id, ['legacytag'])['legacytag']; $legacyfilter = ['tags' => ["{$tag->id},legacytag"]]; $converted = question_reference_manager::convert_legacy_set_reference_filter_condition($legacyfilter); $this->assertEquals( [$tag->id], $converted['filter']['qtagids']['values'], 'Converter should use the tag ID from the custom collection.' ); } } PK\z *tests/calculated_question_summary_test.phpnu[. namespace core_question; use core_question\statistics\questions\calculated_question_summary; /** * Class core_question_calculated_question_summary_testcase * * @package core_question * @category test * @copyright 2018 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class calculated_question_summary_test extends \advanced_testcase { /** * Provider for test_get_min_max_of. * * @return array */ public static function get_min_max_provider(): array { return [ 'negative number and null' => [ [ (object)['questionid' => 1, 'index' => 2], (object)['questionid' => 2, 'index' => -7], (object)['questionid' => 3, 'index' => null], (object)['questionid' => 4, 'index' => 12], ], [-7, 12] ], 'null and negative number' => [ [ (object)['questionid' => 1, 'index' => 2], (object)['questionid' => 2, 'index' => null], (object)['questionid' => 3, 'index' => -7], (object)['questionid' => 4, 'index' => 12], ], [-7, 12] ], 'negative number and null as maximum' => [ [ (object)['questionid' => 1, 'index' => -2], (object)['questionid' => 2, 'index' => null], (object)['questionid' => 3, 'index' => -7], ], [-7, null] ], 'zero and null' => [ [ (object)['questionid' => 1, 'index' => 2], (object)['questionid' => 2, 'index' => 0], (object)['questionid' => 3, 'index' => null], (object)['questionid' => 4, 'index' => 12], ], [0, 12] ], 'null as minimum' => [ [ (object)['questionid' => 1, 'index' => 2], (object)['questionid' => 2, 'index' => null], (object)['questionid' => 3, 'index' => 12], ], [null, 12] ], 'null and null' => [ [ (object)['questionid' => 1, 'index' => 2], (object)['questionid' => 2, 'index' => null], (object)['questionid' => 3, 'index' => null], ], [null, 2] ], ]; } /** * Unit test for get_min_max_of() method. * * @dataProvider get_min_max_provider */ public function test_get_min_max_of($subqstats, $expected): void { $calculatedsummary = new calculated_question_summary(null, null, $subqstats); $res = $calculatedsummary->get_min_max_of('index'); $this->assertEquals($expected, $res); } /** * Provider for test_get_min_max_of. * * @return array */ public static function get_sd_min_max_provider(): array { return [ 'null and number' => [ [ (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5], (object)['questionid' => 2, 'sd' => null, 'maxmark' => 1], (object)['questionid' => 3, 'sd' => 0.1049, 'maxmark' => 1], (object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1], ], [null, 0.4] ], 'null and zero' => [ [ (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5], (object)['questionid' => 2, 'sd' => null, 'maxmark' => 1], (object)['questionid' => 3, 'sd' => 0, 'maxmark' => 1], (object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1], ], [0, 0.4] ], 'zero mark' => [ [ (object)['questionid' => 1, 'sd' => 0, 'maxmark' => 0], (object)['questionid' => 2, 'sd' => 0.1049, 'maxmark' => 1], ], [null, 0.1049] ], 'nonzero and nonzero' => [ [ (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5], (object)['questionid' => 2, 'sd' => 0.7, 'maxmark' => 2], ], [0.35, 0.4] ], 'zero max mark as loaded from the DB' => [ [ (object)['questionid' => 1, 'sd' => '0.0000000000', 'maxmark' => '0.0000000'], (object)['questionid' => 2, 'sd' => '0.0000000000', 'maxmark' => '0.0000000'], ], [null, null] ], ]; } /** * Unit test for get_min_max_of_sd() method. * * @dataProvider get_sd_min_max_provider */ public function test_get_min_max_of_sd($subqstats, $expected): void { $calculatedsummary = new calculated_question_summary(null, null, $subqstats); $res = $calculatedsummary->get_min_max_of('sd'); $this->assertEquals($expected, $res); } } PK\oUQKQKtests/externallib_test.phpnu[. namespace core_question; use core_external\restricted_context_exception; use core_question_external; use externallib_advanced_testcase; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** * Question external functions tests * * @package core_question * @covers \core_question_external * @category external * @copyright 2016 Pau Ferrer * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ final class externallib_test extends externallib_advanced_testcase { /** @var \stdClass course record. */ protected $course; /** @var \stdClass user record. */ protected $student; /** @var \stdClass user role record. */ protected $studentrole; /** * Set up for every test */ public function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); // Setup test data. $this->course = $this->getDataGenerator()->create_course(); // Create users. $this->student = self::getDataGenerator()->create_user(); // Users enrolments. $this->studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual'); } /** * Test update question flag */ public function test_core_question_update_flag(): void { $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(); $quba = \question_engine::make_questions_usage_by_activity('core_question_update_flag', \context_system::instance()); $quba->set_preferred_behaviour('deferredfeedback'); $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $slot = $quba->add_question($question); $qa = $quba->get_question_attempt($slot); self::setUser($this->student); $quba->start_all_questions(); \question_engine::save_questions_usage_by_activity($quba); $qubaid = $quba->get_id(); $questionid = $question->id; $qaid = $qa->get_database_id(); $checksum = md5($qubaid . "_" . $this->student->secret . "_" . $questionid . "_" . $qaid . "_" . $slot); $flag = core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true); $this->assertTrue($flag['status']); // Test invalid checksum. try { // Using random_string to force failing. $checksum = md5($qubaid . "_" . random_string(11) . "_" . $questionid . "_" . $qaid . "_" . $slot); core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true); $this->fail('Exception expected due to invalid checksum.'); } catch (\moodle_exception $e) { $this->assertEquals('errorsavingflags', $e->errorcode); } } /** * Data provider for the get_random_question_summaries test. */ public static function get_random_question_summaries_test_cases(): array { return [ 'empty category' => [ 'categoryindex' => 'emptycat', 'includesubcategories' => false, 'usetagnames' => [], 'expectedquestionindexes' => [] ], 'single category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => [], 'expectedquestionindexes' => ['cat1q1', 'cat1q2'] ], 'include sub category' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => [], 'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2'] ], 'single category with tags' => [ 'categoryindex' => 'cat1', 'includesubcategories' => false, 'usetagnames' => ['cat1'], 'expectedquestionindexes' => ['cat1q1'] ], 'include sub category with tag on parent' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1'], 'expectedquestionindexes' => ['cat1q1'] ], 'include sub category with tag on sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['subcat'], 'expectedquestionindexes' => ['subcatq1'] ], 'include sub category with same tag on parent and sub' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['foo'], 'expectedquestionindexes' => ['cat1q1', 'subcatq1'] ], 'include sub category with tag not matching' => [ 'categoryindex' => 'cat1', 'includesubcategories' => true, 'usetagnames' => ['cat1', 'cat2'], 'expectedquestionindexes' => [] ] ]; } /** * Test the get_random_question_summaries function with various parameter combinations. * * This function creates a data set as follows: * Category: cat1 * Question: cat1q1 * Tags: 'cat1', 'foo' * Question: cat1q2 * Category: cat2 * Question: cat2q1 * Tags: 'cat2', 'foo' * Question: cat2q2 * Category: subcat * Question: subcatq1 * Tags: 'subcat', 'foo' * Question: subcatq2 * Parent: cat1 * Category: emptycat * * @dataProvider get_random_question_summaries_test_cases * @param string $categoryindex The named index for the category to use * @param bool $includesubcategories If the search should include subcategories * @param string[] $usetagnames The tag names to include in the search * @param string[] $expectedquestionindexes The questions expected in the result */ public function test_get_random_question_summaries_variations( $categoryindex, $includesubcategories, $usetagnames, $expectedquestionindexes ): void { $this->resetAfterTest(); $context = \context_system::instance(); $categories = []; $questions = []; $tagnames = [ 'cat1', 'cat2', 'subcat', 'foo' ]; $collid = \core_tag_collection::get_default(); $tags = \core_tag_tag::create_if_missing($collid, $tagnames); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); // First category and questions. list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']); $categories['cat1'] = $category; $questions['cat1q1'] = $categoryquestions[0]; $questions['cat1q2'] = $categoryquestions[1]; // Second category and questions. list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']); $categories['cat2'] = $category; $questions['cat2q1'] = $categoryquestions[0]; $questions['cat2q2'] = $categoryquestions[1]; // Sub category and questions. list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']); $categories['subcat'] = $category; $questions['subcatq1'] = $categoryquestions[0]; $questions['subcatq2'] = $categoryquestions[1]; // Empty category. list($category, $categoryquestions) = $this->create_category_and_questions(0); $categories['emptycat'] = $category; // Generate the arguments for the get_questions function. $category = $categories[$categoryindex]; $tagids = array_map(function($tagname) use ($tags) { return $tags[$tagname]->id; }, $usetagnames); $result = core_question_external::get_random_question_summaries($category->id, $includesubcategories, $tagids, $context->id); $resultquestions = $result['questions']; $resulttotalcount = $result['totalcount']; // Generate the expected question set. $expectedquestions = array_map(function($index) use ($questions) { return $questions[$index]; }, $expectedquestionindexes); // Ensure the resultquestions matches what was expected. $this->assertCount(count($expectedquestions), $resultquestions); $this->assertEquals(count($expectedquestions), $resulttotalcount); foreach ($expectedquestions as $question) { $this->assertEquals($resultquestions[$question->id]->id, $question->id); $this->assertEquals($resultquestions[$question->id]->category, $question->category); } } /** * get_random_question_summaries should throw an invalid_parameter_exception if not * given an integer for the category id. */ public function test_get_random_question_summaries_invalid_category_id_param(): void { $this->resetAfterTest(); $context = \context_system::instance(); $this->expectException('\invalid_parameter_exception'); core_question_external::get_random_question_summaries('invalid value', false, [], $context->id); } /** * get_random_question_summaries should throw an invalid_parameter_exception if not * given a boolean for the $includesubcategories parameter. */ public function test_get_random_question_summaries_invalid_includesubcategories_param(): void { $this->resetAfterTest(); $context = \context_system::instance(); $this->expectException('\invalid_parameter_exception'); core_question_external::get_random_question_summaries(1, 'invalid value', [], $context->id); } /** * get_random_question_summaries should throw an invalid_parameter_exception if not * given an array of integers for the tag ids parameter. */ public function test_get_random_question_summaries_invalid_tagids_param(): void { $this->resetAfterTest(); $context = \context_system::instance(); $this->expectException('\invalid_parameter_exception'); core_question_external::get_random_question_summaries(1, false, ['invalid', 'values'], $context->id); } /** * get_random_question_summaries should throw an invalid_parameter_exception if not * given a context. */ public function test_get_random_question_summaries_invalid_context(): void { $this->resetAfterTest(); $this->expectException('\invalid_parameter_exception'); core_question_external::get_random_question_summaries(1, false, [1, 2], 'context'); } /** * get_random_question_summaries should throw an restricted_context_exception * if the given context is outside of the set of restricted contexts the user * is allowed to call external functions in. */ public function test_get_random_question_summaries_restricted_context(): void { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $coursecontext = \context_course::instance($course->id); $systemcontext = \context_system::instance(); // Restrict access to external functions for the logged in user to only // the course we just created. External functions should not be allowed // to execute in any contexts above the course context. core_question_external::set_context_restriction($coursecontext); // An exception should be thrown when we try to execute at the system context // since we're restricted to the course context. try { // Do this in a try/catch statement to allow the context restriction // to be reset afterwards. core_question_external::get_random_question_summaries(1, false, [], $systemcontext->id); } catch (\Exception $e) { $this->assertInstanceOf(restricted_context_exception::class, $e); } // Reset the restriction so that other tests don't fail aftwards. core_question_external::set_context_restriction($systemcontext); } /** * get_random_question_summaries should return a question that is formatted correctly. */ public function test_get_random_question_summaries_formats_returned_questions(): void { $this->resetAfterTest(); list($category, $questions) = $this->create_category_and_questions(1); $context = \context_system::instance(); $question = $questions[0]; $expected = (object) [ 'id' => $question->id, 'category' => $question->category, 'parent' => $question->parent, 'name' => $question->name, 'qtype' => $question->qtype ]; $result = core_question_external::get_random_question_summaries($category->id, false, [], $context->id); $actual = $result['questions'][$question->id]; $this->assertEquals($expected->id, $actual->id); $this->assertEquals($expected->category, $actual->category); $this->assertEquals($expected->parent, $actual->parent); $this->assertEquals($expected->name, $actual->name); $this->assertEquals($expected->qtype, $actual->qtype); // These values are added by the formatting. It doesn't matter what the // exact values are just that they are returned. $this->assertObjectHasProperty('icon', $actual); $this->assertObjectHasProperty('key', $actual->icon); $this->assertObjectHasProperty('component', $actual->icon); $this->assertObjectHasProperty('alttext', $actual->icon); } /** * get_random_question_summaries should allow limiting and offsetting of the result set. */ public function test_get_random_question_summaries_with_limit_and_offset(): void { $this->resetAfterTest(); $numberofquestions = 5; $includesubcategories = false; $tagids = []; $limit = 1; $offset = 0; $context = \context_system::instance(); list($category, $questions) = $this->create_category_and_questions($numberofquestions); // Sort the questions by id to match the ordering of the result. usort($questions, function($a, $b) { $aid = $a->id; $bid = $b->id; if ($aid == $bid) { return 0; } return $aid < $bid ? -1 : 1; }); for ($i = 0; $i < $numberofquestions; $i++) { $result = core_question_external::get_random_question_summaries( $category->id, $includesubcategories, $tagids, $context->id, $limit, $offset ); $resultquestions = $result['questions']; $totalcount = $result['totalcount']; $this->assertCount($limit, $resultquestions); $this->assertEquals($numberofquestions, $totalcount); $actual = array_shift($resultquestions); $expected = $questions[$i]; $this->assertEquals($expected->id, $actual->id); $offset++; } } /** * get_random_question_summaries should throw an exception if the user doesn't * have the capability to use the questions in the requested category. */ public function test_get_random_question_summaries_without_capability(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $roleid = $generator->create_role(); $systemcontext = \context_system::instance(); $numberofquestions = 5; $includesubcategories = false; $tagids = []; $context = \context_system::instance(); list($category, $questions) = $this->create_category_and_questions($numberofquestions); $categorycontext = \context::instance_by_id($category->contextid); $generator->role_assign($roleid, $user->id, $systemcontext->id); // Prohibit all of the tag capabilities. assign_capability('moodle/question:viewall', CAP_PROHIBIT, $roleid, $categorycontext->id); $this->setUser($user); $this->expectException('moodle_exception'); core_question_external::get_random_question_summaries( $category->id, $includesubcategories, $tagids, $context->id ); } /** * Create a question category and create questions in that category. Tag * the first question in each category with the given tags. * * @param int $questioncount How many questions to create. * @param string[] $tagnames The list of tags to use. * @param stdClass|null $parentcategory The category to set as the parent of the created category. * @return array The category and questions. */ protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) { $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); if ($parentcategory) { $catparams = ['parent' => $parentcategory->id]; } else { $catparams = []; } $category = $generator->create_question_category($catparams); $questions = []; for ($i = 0; $i < $questioncount; $i++) { $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]); } if (!empty($tagnames) && !empty($questions)) { $context = \context::instance_by_id($category->contextid); \core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames); } return [$category, $questions]; } } PK\} qqtests/category_manager_test.phpnu[. namespace core_question; use context_course; use context_module; use moodle_url; use core_question\local\bank\question_edit_contexts; /** * Unit tests for category_manager * * @package core_question * @copyright 2024 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 \core_question\category_manager */ final class category_manager_test extends \advanced_testcase { /** * Create a course and qbank module and return the module context for use in tests. * * @return \core\context\module|false */ private function create_course_and_get_qbank_context() { $course = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); return context_module::instance($qbank->cmid); } /** * Test creating a category. */ public function test_add_category_no_idnumber(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $topcat = question_get_top_category($context->id, true); $manager = new category_manager(); $id = $manager->add_category( $topcat->id . ',' . $topcat->contextid, 'New category', '', FORMAT_HTML, '', // No idnumber passed as '' to match form data. ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New category', $newcat->name); $this->assertNull($newcat->idnumber); } /** * Test creating a category with a tricky idnumber. */ public function test_add_category_set_idnumber_0(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $topcat = question_get_top_category($context->id, true); $manager = new category_manager(); $id = $manager->add_category( $topcat->id . ',' . $topcat->contextid, 'New category', '', FORMAT_HTML, '0', ); $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); $this->assertSame('New category', $newcat->name); $this->assertSame('0', $newcat->idnumber); } /** * Trying to add a category with duplicate idnumber throws an exception. */ public function test_add_category_try_to_set_duplicate_idnumber(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $topcat = question_get_top_category($context->id, true); $manager = new category_manager(); $manager->add_category( $topcat->id . ',' . $topcat->contextid, 'Existing category', '', FORMAT_HTML, 'frog', ); $this->expectExceptionMessage(get_string('idnumbertaken', 'error')); $manager->add_category( $topcat->id . ',' . $topcat->contextid, 'New category', '', FORMAT_HTML, 'frog', ); $this->assertFalse($DB->record_exists('question_categories', ['name' => 'New category'])); } /** * Test updating a category. */ public function test_update_category(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $topcat = question_get_top_category($context->id, true); $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Old name', 'info' => 'Description', 'idnumber' => 'frog', ]); $existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('Old name', $existingcat->name); $this->assertSame('Description', $existingcat->info); $this->assertSame('frog', $existingcat->idnumber); $manager = new category_manager(new moodle_url('/')); $manager->update_category( $category->id, $topcat->id . ',' . $topcat->contextid, 'New name', 'New description', FORMAT_HTML, '0' ); $updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('New name', $updatedcat->name); $this->assertSame('New description', $updatedcat->info); $this->assertSame('0', $updatedcat->idnumber); } /** * Test updating a category to remove the idnumber. */ public function test_update_category_removing_idnumber(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $topcat = question_get_top_category($context->id, true); $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Old name', 'info' => 'Description', 'idnumber' => 'frog', ]); $existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('Old name', $existingcat->name); $this->assertSame('Description', $existingcat->info); $this->assertSame('frog', $existingcat->idnumber); $manager = new category_manager(new moodle_url('/')); $manager->update_category( $category->id, $topcat->id . ',' . $topcat->contextid, 'New name', 'New description', FORMAT_HTML, '' ); $updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('New name', $updatedcat->name); $this->assertSame('New description', $updatedcat->info); $this->assertNull($updatedcat->idnumber); } /** * Test updating a category without changing the idnumber. */ public function test_update_category_dont_change_idnumber(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $topcat = question_get_top_category($context->id, true); $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Old name', 'info' => 'Description', 'idnumber' => 'frog', ]); $existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('Old name', $existingcat->name); $this->assertSame('Description', $existingcat->info); $this->assertSame('frog', $existingcat->idnumber); $manager = new category_manager(new moodle_url('/')); $manager->update_category( $category->id, $topcat->id . ',' . $topcat->contextid, 'New name', 'New description', FORMAT_HTML, 'frog' ); $updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('New name', $updatedcat->name); $this->assertSame('New description', $updatedcat->info); $this->assertSame('frog', $updatedcat->idnumber); } /** * Trying to update a category so its idnumber is a duplicate throws an exception and does not update. */ public function test_update_category_try_to_set_duplicate_idnumber(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $context = $this->create_course_and_get_qbank_context(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $topcat = question_get_top_category($context->id, true); $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Toad category', 'idnumber' => 'toad', ]); $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'Frog category', 'idnumber' => 'frog', ]); $existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertSame('Frog category', $existingcat->name); $this->assertSame('frog', $existingcat->idnumber); $manager = new category_manager(); $this->expectExceptionMessage(get_string('idnumbertaken', 'error')); $manager->update_category( $category->id, $topcat->id . ',' . $topcat->contextid, 'New name', '', FORMAT_HTML, 'toad' ); $updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST); $this->assertEquals('Frog category', $updatedcat->name); $this->assertEquals('frog', $updatedcat->idnumber); } /** * Test the question category created event. */ public function test_question_category_created(): void { $this->setAdminUser(); $this->resetAfterTest(); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]); $context = context_module::instance($quiz->cmid); $topcat = question_get_top_category($context->id, true); $manager = new category_manager(new moodle_url('/')); // Trigger and capture the event. $sink = $this->redirectEvents(); $manager->add_category( $topcat->id . ',' . $topcat->contextid, 'New category', 'Description', FORMAT_HTML, 'frog', ); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_created', $event); $this->assertEquals($context, $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the question category deleted event. */ public function test_question_category_deleted(): void { $this->setAdminUser(); $this->resetAfterTest(); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]); $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcat = question_get_default_category($contexts->lowest()->id, true); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create the category. $category = $questiongenerator->create_question_category([ 'contextid' => $contexts->lowest()->id, 'name' => 'New category', 'info' => 'Description', 'idnumber' => 'newcategory', 'parent' => $defaultcat->id, ]); // Trigger and capture the event. $sink = $this->redirectEvents(); $manager = new category_manager(new moodle_url('/')); $manager->delete_category($category->id); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_deleted', $event); $this->assertEquals($contexts->lowest(), $event->get_context()); $this->assertEquals($category->id, $event->objectid); $this->assertDebuggingNotCalled(); } /** * Test the question category updated event. */ public function test_question_category_updated(): void { $this->setAdminUser(); $this->resetAfterTest(); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]); $context = context_module::instance($quiz->cmid); $topcat = question_get_top_category($context->id, true); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create the category. $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'New category', 'info' => 'Description', 'idnumber' => 'newcategory', ]); // Trigger and capture the event. $sink = $this->redirectEvents(); $manager = new category_manager(new moodle_url('/')); $manager->update_category( $category->id, $topcat->id . ',' . $topcat->contextid, 'Updated category', '', true, FORMAT_HTML, ); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_updated', $event); $this->assertEquals($context, $event->get_context()); $this->assertEquals($category->id, $event->objectid); $this->assertDebuggingNotCalled(); } /** * Test that get_real_question_ids_in_category() returns question id * of a shortanswer question in a category. */ public function test_get_real_question_ids_in_category_shortanswer(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $questiongerator = $generator->get_plugin_generator('core_question'); // Short answer question is made of one question. $shortanswer = $questiongerator->create_question('shortanswer', null, ['category' => $defaultcategory->id]); $manager = new category_manager(); $questionids = $manager->get_real_question_ids_in_category($defaultcategory->id); $this->assertCount(1, $questionids); $this->assertContains($shortanswer->id, $questionids); } /** * Test that get_real_question_ids_in_category() returns question id * of a multianswer question in a category. */ public function test_get_real_question_ids_in_category_multianswer(): void { global $DB; $this->resetAfterTest(); $countq = $DB->count_records('question'); $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $questiongerator = $generator->get_plugin_generator('core_question'); // Multi answer question is made of one parent and two child questions. $multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]); $manager = new category_manager(); $questionids = $manager->get_real_question_ids_in_category($defaultcategory->id); $this->assertCount(1, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertEquals(3, $DB->count_records('question') - $countq); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } /** * Test that get_real_question_ids_in_category() returns question ids * of two versions of a multianswer question in a category. */ public function test_get_real_question_ids_in_category_multianswer_two_versions(): void { global $DB; $this->resetAfterTest(); $countq = $DB->count_records('question'); $countqv = $DB->count_records('question_versions'); $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $questiongerator = $generator->get_plugin_generator('core_question'); // Create two versions of a multianswer question which will lead to // 2 parents and 4 child questions in the question bank. $multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]); $multianswernew = $questiongerator->update_question($multianswer, null, ['name' => 'This is a new version']); $manager = new category_manager(); $questionids = $manager->get_real_question_ids_in_category($defaultcategory->id); $this->assertCount(2, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertContains($multianswernew->id, $questionids); $this->assertEquals(6, $DB->count_records('question') - $countq); $this->assertEquals(6, $DB->count_records('question_versions') - $countqv); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } /** * Test that get_real_question_ids_in_category() returns question id * of a multianswer question in a category even if their child questions are * linked to a category that doesn't exist. */ public function test_get_real_question_ids_in_category_multianswer_bad_data(): void { global $DB; $this->resetAfterTest(); $countqbe = $DB->count_records('question_bank_entries'); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $questiongerator = $generator->get_plugin_generator('core_question'); // Multi answer question is made of one parent and two child questions. $multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]); $qversion = $DB->get_record('question_versions', ['questionid' => $multianswer->id]); // Update category id for child questions to a category that doesn't exist. $DB->set_field_select( 'question_bank_entries', 'questioncategoryid', 123456, 'id <> :id', ['id' => $qversion->questionbankentryid] ); $manager = new category_manager(); $questionids = $manager->get_real_question_ids_in_category($defaultcategory->id); $this->assertCount(1, $questionids); $this->assertContains($multianswer->id, $questionids); $this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe); } /** * Test delete top category in function question_can_delete_cat. */ public function test_question_can_delete_cat_top_category(): void { $this->setAdminUser(); $this->resetAfterTest(); $manager = new category_manager(); // Create a category. $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $context = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]); // Try to delete a top category. $categorytop = question_get_top_category($qcategory1->contextid, true)->id; $this->expectException('moodle_exception'); $this->expectExceptionMessage(get_string('cannotdeletetopcat', 'question')); $manager->require_can_delete_category($categorytop); } /** * Test delete only child category in function question_can_delete_cat. */ public function test_question_can_delete_cat_child_category(): void { $this->setAdminUser(); $this->resetAfterTest(); $manager = new category_manager(); // Create a category. $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $context = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcategory1 = question_get_default_category($context->id); // Try to delete an only child of top category having also at least one child. $this->expectException('moodle_exception'); $this->expectExceptionMessage(get_string('cannotdeletecate', 'question')); $manager->require_can_delete_category($qcategory1->id); } /** * Test delete category in function question_can_delete_cat without capabilities. */ public function test_can_delete_category_capability(): void { $this->setAdminUser(); $this->resetAfterTest(); $manager = new category_manager(); // Create 2 categories. $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $context = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]); $qcategory2 = $questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory1->id]); // This call should not throw an exception as admin user has the capabilities moodle/question:managecategory. $manager->require_can_delete_category($qcategory2->id); // Try to delete a category with and user without the capability. $manager = new category_manager(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->expectException(\required_capability_exception::class); $this->expectExceptionMessage(get_string('nopermissions', 'error', get_string('question:managecategory', 'role'))); $manager->require_can_delete_category($qcategory2->id); } /** * Test get max sortorder */ public function test_get_max_sortorder(): void { $this->setAdminUser(); $this->resetAfterTest(); $manager = new category_manager(); // Create question categories for a course. $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $context = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $topcategory = question_get_top_category($context->id, true); $qcategory1 = question_get_default_category($context->id); $this->assertEquals(999, $manager->get_max_sortorder($topcategory->id)); $qcategory2 = $questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory1->id]); $this->assertEquals(1, $manager->get_max_sortorder($qcategory1->id)); $questiongenerator->create_question_category(['contextid' => $context->id]); $this->assertEquals(1000, $manager->get_max_sortorder($topcategory->id)); $this->assertEquals(0, $manager->get_max_sortorder($qcategory2->id)); $questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory2->id]); $this->assertEquals(1, $manager->get_max_sortorder($qcategory2->id)); } /** * Check that question categories with the wrong parent are fixed. */ public function test_fix_restored_category_parents(): void { global $CFG, $DB, $USER; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $generator = $this->getDataGenerator(); $course = $generator->create_course(); // Add 2 quizzes with question categories. $quiz1 = $generator->create_module('quiz', ['course' => $course->id]); $quiz1context = \context_module::instance($quiz1->cmid); $quiz1top = question_get_top_category($quiz1context->id); $quiz1questioncats = $DB->get_records('question_categories', ['contextid' => $quiz1context->id]); $this->assertCount(2, $quiz1questioncats); $quiz2 = $generator->create_module('quiz', ['course' => $course->id]); $quiz2context = \context_module::instance($quiz2->cmid); $quiz2top = question_get_top_category($quiz2context->id); $quiz2questioncats = $DB->get_records('question_categories', ['contextid' => $quiz2context->id]); $this->assertCount(2, $quiz2questioncats); // Add 2 question banks with question categories. $qbank1 = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank1context = \context_module::instance($qbank1->cmid); $qbank1top = question_get_top_category($qbank1context->id); $qbank1questioncats = $DB->get_records('question_categories', ['contextid' => $qbank1context->id]); $this->assertCount(2, $qbank1questioncats); $qbank2 = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbank2context = \context_module::instance($qbank2->cmid); $qbank2top = question_get_top_category($qbank2context->id); $qbank2questioncats = $DB->get_records('question_categories', ['contextid' => $qbank2context->id]); $this->assertCount(2, $qbank2questioncats); // Modify the child question categories of the second quiz and qbank instances so they are children of the // first instances' top categories. $quiz2nontop = question_get_default_category($quiz2context->id); $DB->set_field('question_categories', 'parent', $quiz1top->id, ['id' => $quiz2nontop->id]); $qbank2nontop = question_get_default_category($qbank2context->id); $DB->set_field('question_categories', 'parent', $qbank1top->id, ['id' => $qbank2nontop->id]); $this->assertEquals($quiz1top->id, $DB->get_field('question_categories', 'parent', ['id' => $quiz2nontop->id])); $this->assertEquals($qbank1top->id, $DB->get_field('question_categories', 'parent', ['id' => $qbank2nontop->id])); // Run the fix. category_manager::fix_restored_category_parents(); // Check that the child categories now have the correct parents. $this->assertEquals($quiz2top->id, $DB->get_field('question_categories', 'parent', ['id' => $quiz2nontop->id])); $this->assertEquals($qbank2top->id, $DB->get_field('question_categories', 'parent', ['id' => $qbank2nontop->id])); } } PK\S#A++tests/event/events_test.phpnu[. /** * Events tests. * * @package core_question * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\event; use qtype_description; use qtype_description_edit_form; use qtype_description_test_helper; use test_question_maker; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); final class events_test extends \advanced_testcase { /** * Tests set up. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * Test the questions imported event. * There is no easy way to trigger this event using the API, so the unit test will simply * create and trigger the event and ensure data is returned as expected. */ public function test_questions_imported(): void { $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $category = $questiongenerator->create_question_category([ 'name' => 'newcategory', 'parent' => $defaultcategory->id, ]); // Log the view of this category. $params = [ 'context' => \context_module::instance($quiz->cmid), 'other' => ['categoryid' => $category->id, 'format' => 'testformat'], ]; $event = \core\event\questions_imported::create($params); // Trigger and capture the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\questions_imported', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEquals($category->id, $event->other['categoryid']); $this->assertEquals('testformat', $event->other['format']); $this->assertDebuggingNotCalled(); } /** * Test the questions exported event. * There is no easy way to trigger this event using the API, so the unit test will simply * create and trigger the event and ensure data is returned as expected. */ public function test_questions_exported(): void { $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $category = $questiongenerator->create_question_category([ 'name' => 'newcategory', 'parent' => $defaultcategory->id, ]); // Log the view of this category. $params = [ 'context' => \context_module::instance($quiz->cmid), 'other' => ['categoryid' => $category->id, 'format' => 'testformat'], ]; $event = \core\event\questions_exported::create($params); // Trigger and capture the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\questions_exported', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEquals($category->id, $event->other['categoryid']); $this->assertEquals('testformat', $event->other['format']); $this->assertDebuggingNotCalled(); } /** * Test the question created event. */ public function test_question_created(): void { $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]); // Trigger and capture the event. $sink = $this->redirectEvents(); $questiondata = $generator->create_question('description', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_created', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat->id, $event->other['categoryid']); $this->assertDebuggingNotCalled(); } /** * Test the question deleted event. */ public function test_question_deleted(): void { $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]); $questiondata = $generator->create_question('description', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); // Trigger and capture the event. $sink = $this->redirectEvents(); question_delete_question($question->id); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_deleted', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat->id, $event->other['categoryid']); $this->assertDebuggingNotCalled(); } /** * Test the question updated event. */ public function test_question_updated(): void { global $CFG; require_once($CFG->dirroot . '/question/type/description/questiontype.php'); require_once($CFG->dirroot . '/question/type/edit_question_form.php'); require_once($CFG->dirroot . '/question/type/description/edit_description_form.php'); $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]); $questiondata = $generator->create_question('description', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $qtype = new qtype_description(); $formdata = test_question_maker::get_question_form_data('description'); $formdata->category = "{$cat->id},{$cat->contextid}"; qtype_description_edit_form::mock_submit((array) $formdata); $form = qtype_description_test_helper::get_question_editing_form($cat, $questiondata); $fromform = $form->get_data(); // Trigger and capture the event. $sink = $this->redirectEvents(); $question = $qtype->save_question($questiondata, $fromform); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. // Every save is a new question after Moodle 4.0. $this->assertInstanceOf('\core\event\question_created', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat->id, $event->other['categoryid']); $this->assertDebuggingNotCalled(); } /** * Test the question moved event. */ public function test_question_moved(): void { $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat1 = $generator->create_question_category([ 'name' => 'My category 1', 'sortorder' => 1]); $cat2 = $generator->create_question_category([ 'name' => 'My category 2', 'sortorder' => 2]); $questiondata = $generator->create_question('description', null, ['category' => $cat1->id]); $question = \question_bank::load_question($questiondata->id); // Trigger and capture the event. $sink = $this->redirectEvents(); question_move_questions_to_category([$question->id], $cat2->id); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_moved', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat1->id, $event->other['oldcategoryid']); $this->assertEquals($cat2->id, $event->other['newcategoryid']); $this->assertDebuggingNotCalled(); } /** * Test the question viewed event. * There is no external API for viewing the question, so the unit test will simply * create and trigger the event and ensure data is returned as expected. */ public function test_question_viewed(): void { $this->setAdminUser(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]); $questiondata = $generator->create_question('description', null, ['category' => $cat->id]); $question = \question_bank::load_question($questiondata->id); $event = \core\event\question_viewed::create_from_question_instance($question, \context::instance_by_id($cat->contextid)); // Trigger and capture the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_viewed', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat->id, $event->other['categoryid']); $this->assertDebuggingNotCalled(); } } PK\. namespace core_question\event; use context_module; /** * Unit tests for question_category_viewed * * @package core_question * @copyright 2024 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 \core_question\event\question_category_viewed */ final class question_category_viewed_test extends \advanced_testcase { /** * Test creating and triggering an event from a category instance. * * @covers ::create_from_question_category_instance */ public function test_create_from_question_category_instance(): void { $this->resetAfterTest(); // Create the category. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]); $context = context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create the category. $category = $questiongenerator->create_question_category([ 'contextid' => $context->id, 'name' => 'New category', 'info' => 'Description', 'idnumber' => 'newcategory', ]); // Log the view of this category. $event = \core\event\question_category_viewed::create_from_question_category_instance($category, $context); // Trigger and capture the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\core\event\question_category_viewed', $event); $this->assertEquals($context, $event->get_context()); $this->assertEquals($category->id, $event->objectid); $this->assertDebuggingNotCalled(); } } PK\p  !tests/question_bank_view_test.phpnu[. namespace core_question; use core_question\local\bank\question_edit_contexts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); /** * Unit tests for the question own question bank view class. * * @package core_question * @copyright 2024 the Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_question\local\bank\view */ final class question_bank_view_test extends \advanced_testcase { public function test_viewing_question_bank_should_not_load_hidden_question(): void { $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); /** @var core_question_generator $questiongenerator */ $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course and a quiz. $course = $generator->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $context = \context_module::instance($quiz->cmid); $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Create a question in the default category. $contexts = new question_edit_contexts($context); $cat = question_get_default_category($context->id, true); $question = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); // Create another version. $newversion = $questiongenerator->update_question($question, null, ['name' => 'This is the latest version']); // Add them to the quiz. quiz_add_quiz_question($newversion->id, $quiz); // Generate the view. $params = [ 'qpage' => 0, 'qperpage' => 20, 'cat' => $cat->id . ',' . $context->id, 'recurse' => false, 'qbshowtext' => false, 'tabname' => 'editq', ]; $extraparams = ['cmid' => $cm->id]; $view = new \core_question\local\bank\view($contexts, new \moodle_url('/'), $course, $cm, $params, $extraparams); ob_start(); $view->display(); $html = ob_get_clean(); // Verify the output should included the latest version. $this->assertStringContainsString('This is the latest version', $html); $this->assertStringNotContainsString('Example question', $html); // Delete the latest version. question_delete_question($newversion->id); // Verify the output should display the old version with status ready. ob_start(); $view->display(); $html = ob_get_clean(); $this->assertStringContainsString('Example question', $html); $this->assertStringNotContainsString('This is the latest version', $html); // Use show hidden question filter. $params['filter'] = [ 'category' => [ 'name' => 'category', 'jointype' => 1, 'values' => [$cat->id], 'filteroptions' => [], ], 'hidden' => [ 'name' => 'hidden', 'jointype' => 1, 'values' => [1], 'filteroptions' => [], ], ]; $view = new \core_question\local\bank\view($contexts, new \moodle_url('/'), $course, $cm, $params, $extraparams); ob_start(); $view->display(); $html = ob_get_clean(); $this->assertStringContainsString('This is the latest version', $html); $this->assertStringNotContainsString('Example question', $html); } } PK\. 33tests/generator_test.phpnu[. /** * Data generators tests * * @package core_question * @subpackage questionengine * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question; /** * Test data generator * * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class generator_test extends \advanced_testcase { public function test_create(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); $count = $DB->count_records('question_categories'); $cat = $generator->create_question_category(); $count += $count ? 1 : 3; // Calling $generator->create_question_category() for the first time // creates a Top and Default category as well. $this->assertEquals($count, $DB->count_records('question_categories')); $cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]); $this->assertSame('My category', $cat->name); $this->assertSame(1, $cat->sortorder); } public function test_idnumbers_in_categories_and_questions(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); list($category, $course, $qcat, $questions) = $generator->setup_course_and_questions(); // Check default idnumbers in question_category and questions. $this->assertNull($qcat->idnumber); $this->assertNull($questions[0]->idnumber); $this->assertNull($questions[1]->idnumber); // Check created idnumbers. $qcat1 = $generator->create_question_category([ 'name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']); $this->assertSame('myqcat', $qcat1->idnumber); $quest1 = $generator->update_question($questions[0], null, ['idnumber' => 'myquest']); $this->assertSame('myquest', $quest1->idnumber); $quest3 = $generator->create_question('shortanswer', null, ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => 'myquest_3']); $this->assertSame('myquest_3', $quest3->idnumber); // Check idnumbers of questions moved. Note have to use load_question_data or we only get to see old cached data. question_move_questions_to_category([$quest1->id], $qcat1->id); $this->assertSame('myquest', \question_bank::load_question_data($quest1->id)->idnumber); // Can only change idnumber of quest2 once quest1 has been moved to another category. $quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest_4']); question_move_questions_to_category([$quest2->id], $qcat1->id); $this->assertSame('myquest_4', \question_bank::load_question_data($quest2->id)->idnumber); // Check can add an idnumber of 0. $quest4 = $generator->create_question('shortanswer', null, ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => '0']); $this->assertSame('0', $quest4->idnumber); } } PK\PMM#tests/behat/behat_core_question.phpnu[. // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/behat_question_base.php'); use Behat\Gherkin\Node\TableNode as TableNode; use Behat\Mink\Exception\ExpectationException as ExpectationException; use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; /** * Steps definitions related with the question bank management. * * @package core_question * @category test * @copyright 2013 David Monllaó * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_core_question extends behat_question_base { /** * Convert page names to URLs for steps like 'When I am on the "[page name]" page'. * * Recognised page names are: * | None so far! | | * * @param string $page name of the page, with the component name removed e.g. 'Admin notification'. * @return moodle_url the corresponding URL. * @throws Exception with a meaningful error message if the specified page cannot be found. */ protected function resolve_page_url(string $page): moodle_url { switch (strtolower($page)) { default: throw new Exception('Unrecognised core_question page type "' . $page . '."'); } } /** * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'. * * Recognised page names are: * | pagetype | name meaning | description | * | course question bank | Course name | The default question bank for a course | * | course question import | Course name | The import questions screen for a course default bank | * | course question export | Course name | The export questions screen for a course default bank | * | question bank | Question bank name | The question bank module | * | question import | Question bank name | The import questions screen for a question bank | * | question export | Question bank name | The export questions screen for a question bank | * | preview | Question name | The screen to preview a question | * | edit | Question name | The screen to edit a question | * * @param string $type identifies which type of page this is, e.g. 'Preview'. * @param string $identifier identifies the particular page, e.g. 'My question'. * @return moodle_url the corresponding URL. * @throws Exception with a meaningful error message if the specified page cannot be found. */ protected function resolve_page_instance_url(string $type, string $identifier): moodle_url { switch (strtolower($type)) { case 'course question bank': // The question bank does not handle fields at the edge of the viewport well. // Increase the size to avoid this. $this->execute('behat_general::i_change_window_size_to', ['window', 'large']); $qbank = $this->get_default_bank_for_course_identifier($identifier); return new moodle_url('/question/edit.php', [ 'cmid' => $qbank->id, ]); case 'question bank': // The question bank does not handle fields at the edge of the viewport well. // Increase the size to avoid this. $this->execute('behat_general::i_change_window_size_to', ['window', 'large']); return new moodle_url('/question/edit.php', ['cmid' => $this->get_cm_by_activity_name('qbank', $identifier)->id] ); case 'course question categories': $qbank = $this->get_default_bank_for_course_identifier($identifier); return new moodle_url('/question/bank/managecategories/category.php', ['cmid' => $qbank->id] ); case 'question categories': return new moodle_url('/question/bank/managecategories/category.php', ['cmid' => $this->get_cm_by_activity_name('qbank', $identifier)->id] ); case 'course question import': $qbank = $this->get_default_bank_for_course_identifier($identifier); return new moodle_url('/question/bank/importquestions/import.php', ['cmid' => $qbank->id] ); case 'question import': return new moodle_url('/question/bank/importquestions/import.php', ['cmid' => $this->get_cm_by_activity_name('qbank', $identifier)->id] ); case 'course question export': $qbank = $this->get_default_bank_for_course_identifier($identifier); return new moodle_url('/question/bank/exportquestions/export.php', ['cmid' => $qbank->id] ); case 'question export': return new moodle_url('/question/bank/exportquestions/export.php', ['cmid' => $this->get_cm_by_activity_name('qbank', $identifier)->id] ); case 'preview': [$questionid, $otheridtype, $otherid] = $this->find_question_by_name($identifier); return new moodle_url('/question/bank/previewquestion/preview.php', ['id' => $questionid, $otheridtype => $otherid]); case 'edit': [$questionid, $otheridtype, $otherid] = $this->find_question_by_name($identifier); return new moodle_url('/question/bank/editquestion/question.php', ['id' => $questionid, $otheridtype => $otherid]); default: throw new Exception('Unrecognised core_question page type "' . $type . '."'); } } /** * Get the course default question bank, creating it if it doesn't yet exist * * @param string $identifier The name of the course to add a default bank to. * @return cm_info The newly created default question bank. */ private function get_default_bank_for_course_identifier(string $identifier): cm_info { $course = get_course($this->get_course_id($identifier)); return \core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true); } /** * Find a question, and where it is, from the question name. * * This is a helper used by resolve_page_instance_url. * * @param string $questionname * @return array with three elemnets, int question id, a string 'cmid' or 'courseid', * and int either cmid or courseid as applicable. */ protected function find_question_by_name(string $questionname): array { global $DB; $questionid = $DB->get_field('question', 'id', ['name' => $questionname], MUST_EXIST); $question = question_bank::load_question_data($questionid); $context = context_helper::instance_by_id($question->contextid); if ($context->contextlevel == CONTEXT_MODULE) { return [$questionid, 'cmid', $context->instanceid]; } else if ($context->contextlevel == CONTEXT_COURSE) { return [$questionid, 'courseid', $context->instanceid]; } else { throw new coding_exception('Unsupported context level ' . $context->contextlevel); } } /** * Creates a question in the current course questions bank with the provided data. * This step can only be used when creating question types composed by a single form. * * @Given /^I add a "(?P(?:[^"]|\\")*)" question filling the form with:$/ * @param string $questiontypename The question type name * @param TableNode $questiondata The data to fill the question type form. */ public function i_add_a_question_filling_the_form_with($questiontypename, TableNode $questiondata) { // Click on create question. $this->execute('behat_forms::press_button', get_string('createnewquestion', 'question')); // Add question. $this->finish_adding_question($questiontypename, $questiondata); } /** * Checks the state of the specified question. * * @Then /^the state of "(?P(?:[^"]|\\")*)" question is shown as "(?P(?:[^"]|\\")*)"$/ * @throws ExpectationException * @throws ElementNotFoundException * @param string $questiondescription * @param string $state */ public function the_state_of_question_is_shown_as($questiondescription, $state) { // Using xpath literal to avoid quotes problems. $questiondescriptionliteral = behat_context_helper::escape($questiondescription); $stateliteral = behat_context_helper::escape($state); // Split in two checkings to give more feedback in case of exception. $exception = new ElementNotFoundException($this->getSession(), 'Question "' . $questiondescription . '" '); $questionxpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' que ')]" . "[contains(div[@class='content']/div[contains(concat(' ', normalize-space(@class), ' '), ' formulation ')]," . "{$questiondescriptionliteral})]"; $this->find('xpath', $questionxpath, $exception); $exception = new ExpectationException('Question "' . $questiondescription . '" state is not "' . $state . '"', $this->getSession()); $xpath = $questionxpath . "/div[@class='info']/div[@class='state' and contains(., {$stateliteral})]"; $this->find('xpath', $xpath, $exception); } /** * Activates a particular action on a particular question in the question bank UI. * * @When I choose :action action for :questionname in the question bank * @param string $action the label for the action you want to activate. * @param string $questionname the question name. */ public function i_action_the_question($action, $questionname) { if ($this->running_javascript()) { // This method isn't allowed unless Javascript is running. $this->execute('behat_action_menu::i_open_the_action_menu_in', [ $questionname, 'table_row', ]); $this->execute('behat_action_menu::i_choose_in_the_open_action_menu', [ $action ]); } else { // This method doesn't open the menu correctly when Javascript is running. $this->execute('behat_action_menu::i_choose_in_the_named_menu_in_container', [ $action, get_string('edit', 'core'), $questionname, 'table_row', ]); } } /** * Checks that action does exist for a question. * * @Then the :action action should exist for the :questionname question in the question bank * @param string $action the label for the action you want to activate. * @param string $questionname the question name. */ public function action_exists($action, $questionname) { $this->execute('behat_action_menu::item_should_exist_in_the', [ $action, get_string('edit', 'core'), $questionname, 'table_row', ]); } /** * Checks that action does not exist for a question. * * @Then the :action action should not exist for the :questionname question in the question bank * @param string $action the label for the action you want to activate. * @param string $questionname the question name. */ public function action_not_exists($action, $questionname) { $this->execute('behat_action_menu::item_should_not_exist_in_the', [ $action, get_string('edit', 'core'), $questionname, 'table_row', ]); } /** * A particular bulk action is visible in the question bank UI. * * @When I should see question bulk action :action * @param string $action the value of the input for the action. */ public function i_should_see_question_bulk_action($action) { // Check if its visible. $this->execute("behat_general::should_be_visible", ["#bulkactionsui-container input[name='$action']", "css_element"]); } /** * A particular bulk action should not be visible in the question bank UI. * * @When I should not see question bulk action :action * @param string $action the value of the input for the action. */ public function i_should_not_see_question_bulk_action($action) { // Check if its visible. $this->execute("behat_general::should_not_be_visible", ["#bulkactionsui-container input[name='$action']", "css_element"]); } /** * A click on a particular bulk action in the question bank UI. * * @When I click on question bulk action :action * @param string $action the value of the input for the action. */ public function i_click_on_question_bulk_action($action) { // Click the bulk action. $this->execute("behat_general::i_click_on", ["#bulkactionsui-container input[name='$action']", "css_element"]); } /** * Change the question type of the give question to a type that does not exist. * * This is useful for testing robustness of the code when a question type * has been uninstalled, even though there are still questions of that type * or attempts at them. * * In order to set things up, you probably need to start by generating * questions of a valid type, then using this to change the type once the * data is created. * * @Given question :questionname is changed to simulate being of an uninstalled type * @param string $questionname the question name. */ public function change_question_to_nonexistant_type($questionname) { global $DB; [$id] = $this->find_question_by_name($questionname); // Check our assumption. $nonexistanttype = 'invalidqtype'; if (question_bank::is_qtype_installed($nonexistanttype)) { throw new coding_exception('This code assumes that the qtype_' . $nonexistanttype . ' is not a valid plugin name, but that plugin now seems to exist!'); } $DB->set_field('question', 'qtype', $nonexistanttype, ['id' => $id]); question_bank::notify_question_edited($id); } /** * Forcibly delete a question from the database. * * This is useful for testing robustness of the code when a question * record is no longer in the database, even though it is referred to. * Obviously, this should never happen, but it has been known to in the past * and so we sometimes need to be able to test the code can handle this situation. * * In order to set things up, you probably need to start by generating * a valid questions, then using this to remove it once the data is created. * * @Given question :questionname no longer exists in the database * @param string $questionname the question name. */ public function remove_question_from_db($questionname) { global $DB; [$id] = $this->find_question_by_name($questionname); $DB->delete_records('question', ['id' => $id]); question_bank::notify_question_edited($id); } /** * Add a question bank filter * * This will add the filter if it does not exist, but leave the value empty. * * @When I add question bank filter :filtertype * @param string $filtertype The filter we are adding */ public function i_add_question_bank_filter(string $filtertype) { $filter = $this->getSession()->getPage()->find('css', '[data-filterregion=filter] [data-field-title="' . $filtertype . '"]'); if ($filter === null) { $this->execute('behat_forms::press_button', [get_string('addcondition')]); $this->execute('behat_forms::i_set_the_field_in_container_to', [ "type", "[data-filterregion=filter]:last-child fieldset", "css_element", $filtertype ]); } } /** * Apply question bank filter. * * This will change the existing value of the specified filter, or add the filter and set its value if it doesn't already * exist. * * @When I apply question bank filter :filtertype with value :value * @param string $filtertype The filter to apply. This should match the get_title() return value from the * filter's condition class. * @param string $value The value to set for the condition. */ public function i_apply_question_bank_filter(string $filtertype, string $value) { // Add the filter if needed. $this->execute('behat_core_question::i_add_question_bank_filter', [ $filtertype, ]); // Set the filter value. $this->execute('behat_forms::i_set_the_field_to', [ $filtertype, $value ]); // Apply filters. $this->execute("behat_forms::press_button", [get_string('applyfilters')]); } /** * Record that a user has recently accessed the question bank related to a particular activity. * * @Given :user has recently viewed the :activityname :activitytype question bank * @param string $useridentifier The user's username or email. * @param string $activityname name of an activity. * @param string $activitytype type of an activity, e.g. 'quiz' or 'qbank'. */ public function user_has_recently_viewed_question_bank( string $useridentifier, string $activityname, string $activitytype, ): void { global $USER; $originaluser = $USER; if (!plugin_supports('mod', $activitytype, FEATURE_USES_QUESTIONS)) { throw new Exception($activitytype . ' do not have a question bank.'); } $user = $this->get_user_by_identifier($useridentifier); if (!$user) { throw new Exception('Unknow user ' . $useridentifier . '.'); } $USER = $user; $cm = $this->get_cm_by_activity_name($activitytype, $activityname); \core_question\local\bank\question_bank_helper::add_bank_context_to_recently_viewed($cm->context); $USER = $originaluser; } } PK\AN| +tests/behat/filter_questions_by_tag.featurenu[@core @core_question Feature: The questions in the question bank can be filtered by tags In order to find the questions I need As a teacher I want to filter the questions by tags Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Test questions | essay | question 1 name | admin | Question 1 text | | Test questions | essay | question 2 name | teacher1 | Question 2 text | And I am on the "question 1 name" "core_question > edit" page logged in as "teacher1" And I set the following fields to these values: | Tags | foo | And I press "id_submitbutton" And I am on the "question 2 name" "core_question > edit" page And I change window size to "large" And I set the following fields to these values: | Tags | bar | And I press "id_submitbutton" @javascript Scenario: The questions can be filtered by tag When I apply question bank filter "Tag" with value "foo" Then I should see "question 1 name" in the "categoryquestions" "table" And I should not see "question 2 name" in the "categoryquestions" "table" @javascript Scenario: Empty condition should not result in exception When I am on the "Qbank 1" "core_question > question bank" page And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Test questions" When I click on "Add condition" "button" And I set the field "type" in the "Filter 2" "fieldset" to "Tag" And I click on "Apply filters" "button" PK\+L L &tests/behat/edit_question_tags.featurenu[@core @core_question Feature: A teacher can manage tags on questions in the question bank In order to organise my questions As a teacher I need to be able to tag them Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | Test question to be tagged | Write about whatever you want | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" @javascript Scenario: Manage tags on a question When I choose "Manage tags" action for "Test question to be tagged" in the question bank And I should see "Test question to be tagged" in the "Question tags" "dialogue" And I set the field "Tags" to "my-tag" And I click on "Save changes" "button" in the "Question tags" "dialogue" Then I should see "my-tag" in the "Test question to be tagged" "table_row" @javascript Scenario: Manage tags works even on questions of a type is no longer installed Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | missingtype | Broken question | Write something | And I reload the page When I choose "Manage tags" action for "Broken question" in the question bank And I set the field "Tags" to "my-tag" And I click on "Save changes" "button" in the "Question tags" "dialogue" Then I should see "my-tag" in the "Broken question" "table_row" PK\ΑVV.tests/behat/question_filter_pagination.featurenu[@core @core_question @qbank_filter @javascript Feature: A teacher can paginate through question bank questions In order to paginate questions As a teacher I must be able to paginate Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Activity module | qbank1 | top | Used category | Given 100 "questions" exist with the following data: | questioncategory | Used category | | qtype | essay | | name | Tests question [count] | | questiontext | Write about whatever you want | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Used category | essay | Not on first page | Write about whatever you want | Scenario: Questions can be paginated Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Top for Qbank 1" And I set the field "Also show questions from subcategories" to "1" And I press "Apply filters" And I follow "Sort by Question name ascending" And I follow "Sort by Question name descending" And I should see "Tests question 1" And I should not see "Not on first page" And I click on "2" "link" in the ".pagination" "css_element" And I should not see "Tests question 1" And I should see "Not on first page" PK\;xx,tests/behat/edit_question_versioning.featurenu[@core @core_question Feature: Questions in the question bank have versions In order to see modified questions As a teacher I want to view them as different versions Background: Given the following "courses" exist: | fullname | shortname | category | groupmode | | Course 1 | C1 | 0 | 1 | And the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 1 | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | answer 1 | | Test questions | truefalse | First question | Answer the first question | True | And quiz "Quiz 1" contains the following questions: | question | page | | First question | 1 | And I am on the "Quiz 1" "mod_quiz > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" @javascript Scenario: Question version is displayed When I choose "Edit question" action for "First question" in the question bank Then I should see "v1 (latest)" @javascript Scenario: Question version change when question is altered When I choose "Edit question" action for "First question" in the question bank And I should see "v1 (latest)" When I set the field "id_name" to "Renamed question v2" And I set the field "id_questiontext" to "edited question" And I press "id_submitbutton" Then I should not see "First question" And I should see "Renamed question v2" When I choose "Edit question" action for "Renamed question v2" in the question bank Then I should see "v2 (latest)" And I should not see "v1 (latest)" PK\&tests/behat/bank_manage.featurenu[@core @core_question Feature: Manage question banks In order to manage shared questions As a teacher I need to create and update a question bank Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Steve1 | Student1 | student1@example.com | | teacher1 | Terry1 | Teacher1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | section | intro | showdescription | idnumber | | qbank | bank1 | C1 | 0 | Bank 1 intro | 0 | qbank1 | | qbank | bank2 | C1 | 0 | Bank 2 intro | 0 | qbank2 | @javascript Scenario: Show description when show description checkbox ticked Given I am on the "C1" "Course" page logged in as "teacher1" When I navigate to "Question banks" in current page administration And I open the action menu in "bank2" "list_item" And I choose "Edit settings" in the open action menu And I set the field "Display description on manage question banks page" to "1" And I press "Save and return to question bank list" Then I should see "Bank 2 intro" And I should see "bank1" But I should not see "Bank 1 intro" @javascript Scenario: Update a question bank Given I am on the "C1" "Course" page logged in as "teacher1" When I navigate to "Question banks" in current page administration And I open the action menu in "bank1" "list_item" And I choose "Edit settings" in the open action menu And I set the following fields to these values: | Question bank name | Bank 1 updated | | Display description on manage question banks page | 1 | | Description | Bank 1 intro updated | And I press "Save and return to question bank list" Then I should see "Bank 1 updated" And I should see "Bank 1 intro updated" @javascript Scenario: Delete a question bank Given I am on the "C1" "Course" page logged in as "teacher1" When I navigate to "Question banks" in current page administration And I open the action menu in "bank1" "list_item" And I choose "Delete" in the open action menu And I click on "Delete" "button" Then I should not see "bank1" But I should see "bank2" Scenario: A student without permissions to access a bank cannot access the question banks page Given I am on the "C1" "Course" page logged in as "student1" Then "Question banks" "link" should not exist Scenario: A student with permissions to access a bank can access the question banks page Given the following "role assigns" exist: | user | role | contextlevel | reference | | student1 | editingteacher | Activity module | qbank2 | And I am on the "C1" "Course" page logged in as "student1" When I navigate to "Question banks" in current page administration Then I should see "bank2" And I should not see "bank1" And "Add" "button" should not exist in the "region-main" "region" Scenario: A teacher can access the question banks page when there are no question banks yet Given the following "courses" exist: | fullname | shortname | category | | Course 2 | C2 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C2 | editingteacher | When I am on the "C2" "Course" page logged in as "teacher1" And I navigate to "Question banks" in current page administration Then "Create default question bank" "button" should exist PK\d"tests/behat/sort_questions.featurenu[@core @core_question Feature: The questions in the question bank can be sorted in various ways In order to see what questions I have As a teacher I want to view them in different orders Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | idnumber | | Default for Qbank 1 | essay | A question 1 name | admin | Question 1 text | numidnum question bank" page logged in as "teacher1" Scenario: The questions are sorted by type by default Then "A question 1 name" "checkbox" should appear before "C question 3 name" "checkbox" Scenario: The questions can be sorted by idnumber When I change the window size to "large" And I follow "Sort by ID number ascending" Then "C question 3 name" "checkbox" should appear after "A question 1 name" "checkbox" And I should see "numidnum edit" page logged in as "teacher1" And I set the following fields to these values: | Question name | essay 1 edited | And I press "id_submitbutton" And I should see "essay 1 edited" And ".highlight" "css_element" should exist in the "#categoryquestions" "css_element" When I click on "2" "link" in the ".pagination" "css_element" And I click on "1" "link" in the ".pagination" "css_element" Then ".highlight" "css_element" should exist in the "#categoryquestions" "css_element" PK\   #tests/behat/behat_question_base.phpnu[. /** * Behat question-related helper code. * * @package core_question * @category test * @copyright 2013 David Monllaó * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); use Behat\Gherkin\Node\TableNode as TableNode, Behat\Mink\Exception\ExpectationException as ExpectationException, Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; /** * Steps definitions related with the question bank management. * * @package core_question * @category test * @copyright 2013 David Monllaó * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_question_base extends behat_base { /** * Helper used by {@link i_add_a_question_filling_the_form_with()} and * {@link behat_mod_quiz::i_add_question_to_the_quiz_with to finish creating()}. * * @param string $questiontypename The question type name * @param TableNode $questiondata The data to fill the question type form */ protected function finish_adding_question($questiontypename, TableNode $questiondata) { $this->execute('behat_forms::i_set_the_field_to', [$this->escape($questiontypename), 1]); $this->execute("behat_general::i_click_on", ['.submitbutton', "css_element"]); $this->execute("behat_forms::i_set_the_following_fields_to_these_values", $questiondata); $this->execute("behat_forms::press_button", 'id_submitbutton'); } } PK\B+ $tests/behat/select_questions.featurenu[@core @core_question Feature: The questions in the question bank can be selected in various ways In selected to do something for questions As a teacher I want to choose them to move, delete it. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Test questions | essay | A question 1 name | admin | Question 1 text | | Test questions | essay | B question 2 name | teacher1 | Question 2 text | | Test questions | numerical | C question 3 name | teacher1 | Question 3 text | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" @javascript Scenario: The question text can be chosen all in the list of questions Given the field "Select all" matches value "" When I click on "Select all" "checkbox" And the field "A question 1 name" matches value "1" And the field "B question 2 name" matches value "1" And the field "C question 3 name" matches value "1" Then I click on "Deselect all" "checkbox" And the field "A question 1 name" matches value "" And the field "B question 2 name" matches value "" And the field "C question 3 name" matches value "" @javascript Scenario: The question text can be chosen in the list of questions Given the field "Select all" matches value "" When I click on "A question 1 name" "checkbox" Then the field "Select all" matches value "" And I click on "B question 2 name" "checkbox" And I click on "C question 3 name" "checkbox" And the field "Deselect all" matches value "1" @javascript Scenario: The action button can be disabled when the question not be chosen in the list of questions Given the field "Select all" matches value "" When I click on "With selected" "button" And I should not see "Delete" And I should not see "Move to..." And I click on "Select all" "checkbox" And I click on "With selected" "button" Then I should see question bulk action "move" And I should see question bulk action "deleteselected" PK\V$k8tests/behat/filter_questions_combined_conditions.featurenu[@core @core_question Feature: The questions in the question bank can be filtered by combine various conditions In order to find the questions I need As a teacher I want to filter the questions by various conditions Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions 1 | | Activity module | qbank1 | Test questions 2 | | Activity module | qbank1 | Test questions 3 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Test questions 1 | essay | question 1 name | teacher1 | Question 1 text | | Test questions 1 | essay | question 2 name | teacher1 | Question 2 text | | Test questions 2 | essay | question 3 name | teacher1 | Question 3 text | | Test questions 2 | essay | question 4 name | teacher1 | Question 4 text | And the following "core_question > Tags" exist: | question | tag | | question 1 name | foo | | question 3 name | foo | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" @javascript Scenario: The questions can be filtered by matching all conditions When I apply question bank filter "Category" with value "Test questions 1" And I change window size to "large" And I apply question bank filter "Tag" with value "foo" Then I should see "question 1 name" in the "categoryquestions" "table" And I should not see "question 2 name" in the "categoryquestions" "table" And I should not see "question 3 name" in the "categoryquestions" "table" And I should not see "question 4 name" in the "categoryquestions" "table" @javascript Scenario: Filters persist when the page is reloaded Given the following "questions" exist: | questioncategory | qtype | name | user | questiontext | status | | Test questions 1 | essay | hidden question name | teacher1 | Hidden text | hidden | And the following "core_question > Tags" exist: | question | tag | | hidden question name | foo | And I apply question bank filter "Category" with value "Test questions 1" And I apply question bank filter "Tag" with value "foo" And I apply question bank filter "Show hidden questions" with value "Yes" And I should see "question 1 name" in the "categoryquestions" "table" And I should see "hidden question name" in the "categoryquestions" "table" And I should not see "question 2 name" in the "categoryquestions" "table" And I should not see "question 3 name" in the "categoryquestions" "table" And I should not see "question 4 name" in the "categoryquestions" "table" When I reload the page Then I should see "Test questions 1 (2)" in the "Filter 1" "fieldset" And the field "Show hidden questions" matches value "Yes" And I should see "foo" in the "Filter 3" "fieldset" And I should see "question 1 name" in the "categoryquestions" "table" And I should see "hidden question name" in the "categoryquestions" "table" And I should not see "question 2 name" in the "categoryquestions" "table" And I should not see "question 3 name" in the "categoryquestions" "table" And I should not see "question 4 name" in the "categoryquestions" "table" @javascript Scenario: Filtered category should be kept when we create new question Given I apply question bank filter "Category" with value "Test questions 3" And I should not see "question 1 name" And I should not see "question 2 name" And I click on "Create a new question" "button" And I set the field "True/False" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And the following fields match these values: | Category | Test questions 3 | And I set the following fields to these values: | Category | Test questions 2 | | Question name | Question 3 | | Question text | T/F question text | When I press "id_submitbutton" Then I should see "Question 3" And I should see "question 3 name" PK\& \0tests/behat/question_categories_idnumber.featurenu[@core @core_question Feature: A teacher can put questions with idnumbers in categories in the question bank In order to organize my questions As a teacher I move questions between categories (now with idnumbers) Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And I log in as "teacher1" And I am on "Course 1" course homepage Scenario: A question can only have a unique idnumber within a category When the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | | Activity module | qbank1 | Top | top | | | Activity module | qbank1 | top | Used category | c1used | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Used category | essay | Test question 1 | Write about whatever you want | q1 | | Used category | essay | Test question 2 | Write about whatever you want | q2 | And I am on the "Test question 2" "core_question > edit" page And I set the field "ID number" to "q1" And I press "submitbutton" # This is the standard form warning reminding the user that the idnumber needs to be unique for a category. Then I should see "This ID number is already in use" Scenario: A question can be edited and saved without changing the idnumber When the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | | Activity module | qbank1 | Top | top | | | Activity module | qbank1 | top | Used category | c1used | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Used category | essay | Test question 1 | Write about whatever you want | q1 | And I am on the "Test question 1" "core_question > edit" page And I press "Save changes" Then I should not see "This ID number is already in use" Scenario: Question idnumber conflicts found when saving to the same category. When the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Activity module | qbank1 | Top | top | | Activity module | qbank1 | top | Category 1 | | Activity module | qbank1 | top | Category 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Category 1 | essay | Question to edit | Write about whatever you want | q1 | | Category 1 | essay | Other question | Write about whatever you want | q2 | And I am on the "Question to edit" "core_question > edit" page And I set the field "ID number" to "q2" And I press "Save changes" Then I should see "This ID number is already in use" @javascript Scenario: Moving a question between categories can force a change to the idnumber And the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | | Activity module | qbank1 | Top | top | | | Activity module | qbank1 | top | Subcategory | c1sub | | Activity module | qbank1 | top | Used category | c1used | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Used category | essay | Test question 1 | Write about whatever you want | q1 | | Used category | essay | Test question 2 | Write about whatever you want | q2 | | Subcategory | essay | Test question 3 | Write about whatever you want | q3 | When I am on the "Test question 3" "core_question > edit" page # The q1 idnumber is allowed for this question while it is in the Subcategory. And I set the field "ID number" to "q1" And I press "submitbutton" # Javascript is required for the next step. And I click on "Test question 3" "checkbox" in the "Test question 3" "table_row" And I click on "With selected" "button" And I click on question bulk action "move" And I open the autocomplete suggestions list in the ".search-categories" "css_element" And I click on "Used category" "list_item" in the "Move the selected questions to..." "dialogue" And I click on "Move questions" "button" And I should see "Are you sure you want to move these questions?" And I click on "Confirm" "button" And I wait until the page is ready And I choose "Edit question" action for "Test question 3" in the question bank # The question just moved into this category needs to have a unique idnumber, so a number is appended. Then the field "ID number" matches value "q1_1" PK\`V00.tests/behat/delete_question_activities.featurenu[@core @core_question Feature: An activity module instance with questions in its context can be deleted In order to delete an activity module from the course As a teacher I need to be able to delete the activity even if it has questions created in its context Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | Scenario: Synchronously deleting a quiz with existing questions in its context Given the following config values are set as admin: | coursebinenable | 0 | tool_recyclebin | And the following "activity" exists: | activity | quiz | | course | C1 | | name | Test quiz Q001 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | Test quiz Q001 | Default for Test quiz Q001 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Test quiz Q001 | truefalse | Test used question to be deleted | Write about whatever you want | And quiz "Test quiz Q001" contains the following questions: | question | page | | Test used question to be deleted | 1 | And I am on the "Course 1" course page logged in as teacher1 And I am on "Course 1" course homepage with editing mode on When I delete "Test quiz Q001" activity Then I should not see "Test quiz Q001" PK\7j-tests/behat/filter_questions_show_all.featurenu[@core @core_question @qbank_filter @javascript Feature: A teacher can show all of the questions on the question bank and override pagination In order to see all the questions in the question bank As a teacher I must be able to toggle between show all and show paginated Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | | qbank | Qbank 1 | C1 | qbank1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test Category | Given 201 "questions" exist with the following data: | questioncategory | Test Category | | qtype | truefalse | | name | Question #[count] | | questiontext | True or false? | Scenario: Use "Show All" button on the question bank when there are no questions Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Top for Qbank 1" Then I should not see "Question #" And I click on "Show all" "button" Then I should not see "Question #" And I click on "Show 100 per page" "button" Then I should not see "Question #" Scenario: Question bank shows paginated questions by default Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Test Category" Then I should see "100" occurrences of "Question #" in the "div#questionscontainer" "css_element" And I should see "1" in the ".pagination" "css_element" And I should see "2" in the ".pagination" "css_element" And I should see "3" in the ".pagination" "css_element" Scenario: Toggle "Show all" shows all questions Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Test Category" And I click on "Show all" "button" Then I should see "201" occurrences of "Question #" in the "div#questionscontainer" "css_element" And ".pagination" "css_element" should not exist And I should see "Show 100 per page" Scenario: Toggle "Show all" back to paginated shows paginated questions Given I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" When I apply question bank filter "Category" with value "Test Category" And I click on "Show all" "button" And I click on "Show 100 per page" "button" Then I should see "100" occurrences of "Question #" in the "div#questionscontainer" "css_element" And I should see "1" in the ".pagination" "css_element" And I should see "2" in the ".pagination" "css_element" And I should see "3" in the ".pagination" "css_element" And I should see "Show all" Scenario: Show all questions on the question bank when adding questions to a quiz Given I am on the "Test quiz" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "from question bank" And I click on "Switch bank" "button" And I click on "Qbank 1" "link" in the ".modal" "css_element" And I apply question bank filter "Category" with value "Test Category" Then I should see "20" occurrences of "Question #" in the "div#questionscontainer" "css_element" When I click on "Show all" "button" Then I should see "201" occurrences of "Question #" in the "div#questionscontainer" "css_element" When I click on "Select all" "checkbox" And I click on "Add selected questions to the quiz" "button" Then I should see "Questions: 201" PK\Sa a tests/behat/bank_view.featurenu[@core @core_question Feature: A bank view with questions can be managed In order to manage a question bank from the course As a teacher I need to be able to view and manage questions Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | @javascript Scenario: Viewing question bank should not load individual questions When the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Test questions | essay | Essay test question | Write about whatever you want | qid | | Test questions | numerical | Numerical test question | Write about whatever you want | qid | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I should see "Essay test question" And I should see "Numerical test question" And I choose "Delete" action for "Essay test question" in the question bank And I press "Delete" And I should not see "Essay test question" And I choose "Delete" action for "Numerical test question" in the question bank And I press "Delete" @javascript Scenario: Unknown qtype does not break the view When the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | missingtype | Unknown type question | Write about whatever you want | | Test questions | truefalse | Truefalse type question | Write about whatever you want | | Test questions | essay | Essay type question | Write about whatever you want | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" And I should see "Unknown type question" And I should see "Truefalse type question" And I should see "Essay type question" PK\"tests/behat/edit_questions.featurenu[@core @core_question @javascript Feature: A teacher can edit questions in the question bank In order to improve my questions As a teacher I need to be able to edit questions Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | Test question to be edited | Write about whatever you want | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" @javascript Scenario: Edit a previously created question When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1" And I set the following fields to these values: | Question name | Edited question name | | Question text | Write a lot about what you want | And I press "id_submitbutton" Then I should see "Edited question name" And I should not see "Test question to be edited" And I should see "Teacher 1" Scenario: Edit a previously created question without permission 'moodle/question:moveall' and 'moodle/question:movemine' Given I log in as "admin" And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/question:movemine | Prevent | editingteacher | System | | | moodle/question:moveall | Prevent | editingteacher | System | | When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1" And I set the following fields to these values: | Question name | Edited question name | | Question text | Write a lot about what you want | And I press "id_submitbutton" Then I should see "Edited question name" And I should not see "Test question to be edited" And I should see "Teacher 1" Scenario: Edit a previously created question without permission 'moodle/question:editall' and 'moodle/question:editmine' Given I log in as "admin" And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/question:editmine | Prevent | editingteacher | System | | | moodle/question:editall | Prevent | editingteacher | System | | When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1" And I set the following fields to these values: | Question name | Edited question name | | Question text | Write a lot about what you want | And I press "id_submitbutton" Then I should see "You don't have permission to edit questions from here." Scenario: Editing a question can be cancelled When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1" And I set the field "Question name" to "Edited question name" And I press "Cancel" And I apply question bank filter "Category" with value "Test questions" Then I should see "Test question to be edited" And I should see "Admin User" Scenario: A question can have its idnumber removed Given the following "questions" exist: | questioncategory | qtype | name | idnumber | | Test questions | essay | Question with idnumber | frog | When I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" Then I should see "frog" in the "Question with idnumber" "table_row" When I choose "Edit question" action for "Question with idnumber" in the question bank And I set the field "ID number" to "" And I press "id_submitbutton" Then I should not see "frog" in the "Question with idnumber" "table_row" Scenario: If the question type is no longer installed, then most edit actions are not present Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | missingtype | Broken question | Write something | When I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" Then the "Edit question" item should not exist in the "Edit" action menu of the "Broken question" "table_row" And the "Duplicate" item should not exist in the "Edit" action menu of the "Broken question" "table_row" And the "Preview" item should not exist in the "Edit" action menu of the "Broken question" "table_row" And the "Export as XML" item should not exist in the "Edit" action menu of the "Broken question" "table_row" And the "Manage tags" item should exist in the "Edit" action menu of the "Broken question" "table_row" And the "Delete" item should exist in the "Edit" action menu of the "Broken question" "table_row" PK\)0tests/behat/edit_questions_standard_tags.featurenu[@core @core_question @javascript Feature: The questions can be tagged In order to tag questions As a teacher I want to see the standard tags in the tags field Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "tags" exist: | name | isstandard | | foo | 1 | | bar | 1 | Scenario: The tags autocomplete should include standard tags When I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I press "Create a new question ..." And I set the field "item_qtype_truefalse" to "1" And I click on "Add" "button" in the "Choose a question type to add" "dialogue" And I expand all fieldsets And I open the autocomplete suggestions list Then "foo" "autocomplete_suggestions" should exist And "bar" "autocomplete_suggestions" should exist PK\r`GG$tests/behat/delete_questions.featurenu[@core @core_question Feature: A teacher can delete questions in the question bank In order to remove unused questions from the question bank As a teacher I need to delete questions Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | Test question to be deleted | Write about whatever you want | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1" And I apply question bank filter "Category" with value "Test questions" @javascript Scenario: A question not used anywhere can really be deleted When I choose "Delete" action for "Test question to be deleted" in the question bank And I press "Delete" And I apply question bank filter "Show hidden questions" with value "Yes" Then I should not see "Test question to be deleted" @javascript Scenario: Deleting a question can be cancelled When I choose "Delete" action for "Test question to be deleted" in the question bank And I press "Cancel" Then I should see "Test question to be deleted" @javascript Scenario: Delete a question used in a quiz Given the following "activity" exists: | course | C1 | | activity | quiz | | idnumber | Test quiz | | name | Test quiz | And the following "question" exists: | questioncategory | Test questions | | qtype | truefalse | | name | Test used question to be deleted | | questiontext | Write about whatever you want | And quiz "Test quiz" contains the following questions: | question | page | requireprevious | | Test used question to be deleted | 1 | 0 | When I am on the "Qbank 1" "core_question > question bank" page And I apply question bank filter "Category" with value "Test questions" And I choose "Delete" action for "Test used question to be deleted" in the question bank And I should see "This will delete the following question and all its versions:" And I should see "* Denotes questions which can't be deleted because they are in use. Instead, they will be hidden in the question bank unless you set 'Show hidden questions' to 'Yes'." And I press "Delete" Then I should not see "Test used question to be deleted" And I apply question bank filter "Show hidden questions" with value "Yes" And I should see "Test used question to be deleted" And I am on the "Test quiz" "quiz activity" page And I click on "Preview quiz" "button" And I should see "Write about whatever you want" @javascript Scenario: A question can be deleted even if that question type is no longer installed Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | missingtype | Broken question | Write something | And I reload the page When I choose "Delete" action for "Broken question" in the question bank And I press "Delete" And I apply question bank filter "Show hidden questions" with value "Yes" Then I should not see "Broken question" @javascript Scenario: Delete question has multiple versions in question bank page When the following "core_question > updated questions" exist: | questioncategory | question | questiontext | | Test questions | Test question to be deleted | Test question to be deleted version 2 | And I choose "Delete" action for "Test question to be deleted" in the question bank And I should see "This will delete the following question and all its versions:" And I should not see "* Denotes questions which can't be deleted because they are in use. Instead, they will be hidden in the question bank unless you set 'Show hidden questions' to 'Yes'." And I press "Delete" Then I should not see "Test question to be deleted" And I should not see "Test question to be deleted version2" PK\u*_ 'tests/behat/duplicate_questions.featurenu[@core @core_question @javascript Feature: A teacher can duplicate questions in the question bank In order to efficiently expand my question bank As a teacher I need to be able to duplicate existing questions and make small changes Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher | Teacher | One | teacher@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Test questions | essay | Test question to be copied | Write about whatever you want | qid | And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher" And I apply question bank filter "Category" with value "Test questions" Scenario: Duplicating a previously created question When I choose "Duplicate" action for "Test question to be copied" in the question bank And I set the following fields to these values: | Question name | Duplicated question name | | Question text | Write a lot about duplicating questions | And I press "id_submitbutton" Then I should see "Duplicated question name" And I should see "Test question to be copied" And I should see "ID number" in the "Test question to be copied" "table_row" And I should see "qid" in the "Test question to be copied" "table_row" Scenario: Duplicated questions automatically get a new name suggested When I choose "Duplicate" action for "Test question to be copied" in the question bank Then the field "Question name" matches value "Test question to be copied (copy)" Scenario: The duplicate operation can be cancelled When I choose "Duplicate" action for "Test question to be copied" in the question bank And I press "Cancel" Then I should see "Test question to be copied" And I should see "Test questions (1)" in the "Filter 1" "fieldset" Scenario: Duplicating a question with an idnumber increments it Given the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Test questions | essay | Question with idnumber | Write about whatever you want | id101 | And I reload the page When I choose "Duplicate" action for "Question with idnumber" in the question bank And I press "id_submitbutton" Then I should see "Question with idnumber (copy)" Then I should see "id102" in the "Question with idnumber (copy)" "table_row" PK\u⚹ format.phpnu[. /** * Defines the base class for question import and export formats. * * @package moodlecore * @subpackage questionbank * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Base class for question import and export formats. * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_default { public $displayerrors = true; public $category = null; public $questions = array(); public $course = null; public $filename = ''; public $realfilename = ''; public $matchgrades = 'error'; public $catfromfile = 0; public $contextfromfile = 0; public $cattofile = 0; public $contexttofile = 0; public $questionids = array(); public $importerrors = 0; public $stoponerror = true; public $translator = null; public $canaccessbackupdata = true; protected $importcontext = null; /** @var bool $displayprogress Whether to display progress. */ public $displayprogress = true; /** @var context[] */ public $contexts; // functions to indicate import/export functionality // override to return true if implemented /** @return bool whether this plugin provides import functionality. */ public function provide_import() { return false; } /** @return bool whether this plugin provides export functionality. */ public function provide_export() { return false; } /** The string mime-type of the files that this plugin reads or writes. */ public function mime_type() { return mimeinfo('type', $this->export_file_extension()); } /** * @return string the file extension (including .) that is normally used for * files handled by this plugin. */ public function export_file_extension() { return '.txt'; } /** * Check if the given file is capable of being imported by this plugin. * * Note that expensive or detailed integrity checks on the file should * not be performed by this method. Simple file type or magic-number tests * would be suitable. * * @param stored_file $file the file to check * @return bool whether this plugin can import the file */ public function can_import_file($file) { return ($file->get_mimetype() == $this->mime_type()); } /** * Validate the given file. * * For more expensive or detailed integrity checks. * * @param stored_file $file the file to check * @return string the error message that occurred while validating the given file */ public function validate_file(stored_file $file): string { return ''; } /** * Check if the given file has the required utf8 encoding. * * @param stored_file $file the file to check * @return string the error message if the file encoding is not UTF-8 */ protected function validate_is_utf8_file(stored_file $file): string { if (!mb_check_encoding($file->get_content(), "UTF-8")) { return get_string('importwrongfileencoding', 'question', $this->get_name()); } return ''; } /** * Return the localized pluginname string for the question format. * * @return string the pluginname string for the question format */ protected function get_name(): string { return get_string('pluginname', get_class($this)); } // Accessor methods /** * set the category * @param object category the category object */ public function setCategory($category) { if (count($this->questions)) { debugging('You shouldn\'t call setCategory after setQuestions'); } $context = context::instance_by_id($category->contextid); if ($context->contextlevel !== CONTEXT_MODULE) { throw new \core\exception\moodle_exception('Invalid contextlevel for target category, must be CONTEXT_MODULE'); } $this->category = $category; $this->importcontext = $context; } /** * Set the specific questions to export. Should not include questions with * parents (sub questions of cloze question type). * Only used for question export. * @param array of question objects */ public function setQuestions($questions) { if ($this->category !== null) { debugging('You shouldn\'t call setQuestions after setCategory'); } $this->questions = $questions; } /** * set the course class variable * @param course object Moodle course variable */ public function setCourse($course) { $this->course = $course; } /** * set an array of contexts. * @param context[] $contexts */ public function setContexts($contexts) { $this->contexts = $contexts; $this->translator = new core_question\local\bank\context_to_string_translator($this->contexts); } /** * set the filename * @param string filename name of file to import/export */ public function setFilename($filename) { $this->filename = $filename; } /** * set the "real" filename * (this is what the user typed, regardless of wha happened next) * @param string realfilename name of file as typed by user */ public function setRealfilename($realfilename) { $this->realfilename = $realfilename; } /** * set matchgrades * @param string matchgrades error or nearest for grades */ public function setMatchgrades($matchgrades) { $this->matchgrades = $matchgrades; } /** * set catfromfile * @param bool catfromfile allow categories embedded in import file */ public function setCatfromfile($catfromfile) { $this->catfromfile = $catfromfile; } /** * set contextfromfile * @param bool $contextfromfile allow contexts embedded in import file */ public function setContextfromfile($contextfromfile) { $this->contextfromfile = $contextfromfile; } /** * set cattofile * @param bool cattofile exports categories within export file */ public function setCattofile($cattofile) { $this->cattofile = $cattofile; } /** * set contexttofile * @param bool cattofile exports categories within export file */ public function setContexttofile($contexttofile) { $this->contexttofile = $contexttofile; } /** * set stoponerror * @param bool stoponerror stops database write if any errors reported */ public function setStoponerror($stoponerror) { $this->stoponerror = $stoponerror; } /** * @param bool $canaccess Whether the current use can access the backup data folder. Determines * where export files are saved. */ public function set_can_access_backupdata($canaccess) { $this->canaccessbackupdata = $canaccess; } /** * Change whether to display progress messages. * There is normally no need to use this function as the * default for $displayprogress is true. * Set to false for unit tests. * @param bool $displayprogress */ public function set_display_progress($displayprogress) { $this->displayprogress = $displayprogress; } /*********************** * IMPORTING FUNCTIONS ***********************/ /** * Handle parsing error */ protected function error($message, $text='', $questionname='') { $importerrorquestion = get_string('importerrorquestion', 'question'); echo "
    \n"; echo "{$importerrorquestion} {$questionname}"; if (!empty($text)) { $text = s($text); echo "
    {$text}
    \n"; } echo "{$message}\n"; echo "
    "; $this->importerrors++; } /** * Import for questiontype plugins * Do not override. * @param data mixed The segment of data containing the question * @param question object processed (so far) by standard import code if appropriate * @param extra mixed any additional format specific data that may be passed by the format * @param qtypehint hint about a question type from format * @return object question object suitable for save_options() or false if cannot handle */ public function try_importing_using_qtypes($data, $question = null, $extra = null, $qtypehint = '') { // work out what format we are using $formatname = substr(get_class($this), strlen('qformat_')); $methodname = "import_from_{$formatname}"; //first try importing using a hint from format if (!empty($qtypehint)) { $qtype = question_bank::get_qtype($qtypehint, false); if (is_object($qtype) && method_exists($qtype, $methodname)) { $question = $qtype->$methodname($data, $question, $this, $extra); if ($question) { return $question; } } } // loop through installed questiontypes checking for // function to handle this question foreach (question_bank::get_all_qtypes() as $qtype) { if (method_exists($qtype, $methodname)) { if ($importedquestion = $qtype->$methodname($data, $question, $this, $extra)) { return $importedquestion; } } } return false; } /** * Perform any required pre-processing * @return bool success */ public function importpreprocess() { return true; } /** * Process the file * This method should not normally be overidden * @return bool success */ public function importprocess() { global $USER, $DB, $OUTPUT; // Raise time and memory, as importing can be quite intensive. core_php_time_limit::raise(); raise_memory_limit(MEMORY_EXTRA); // STAGE 1: Parse the file if ($this->displayprogress) { echo $OUTPUT->notification(get_string('parsingquestions', 'question'), 'notifysuccess'); } if (! $lines = $this->readdata($this->filename)) { echo $OUTPUT->notification(get_string('cannotread', 'question')); return false; } if (!$questions = $this->readquestions($lines)) { // Extract all the questions echo $OUTPUT->notification(get_string('noquestionsinfile', 'question')); return false; } // STAGE 2: Write data to database if ($this->displayprogress) { echo $OUTPUT->notification(get_string('importingquestions', 'question', $this->count_questions($questions)), 'notifysuccess'); } // check for errors before we continue if ($this->stoponerror and ($this->importerrors>0)) { echo $OUTPUT->notification(get_string('importparseerror', 'question')); return false; } // get list of valid answer grades $gradeoptionsfull = question_bank::fraction_options_full(); // check answer grades are valid // (now need to do this here because of 'stop on error': MDL-10689) $gradeerrors = 0; $goodquestions = array(); foreach ($questions as $question) { if (!empty($question->fraction) and (is_array($question->fraction))) { $fractions = $question->fraction; $invalidfractions = array(); foreach ($fractions as $key => $fraction) { $newfraction = match_grade_options($gradeoptionsfull, $fraction, $this->matchgrades); if ($newfraction === false) { $invalidfractions[] = $fraction; } else { $fractions[$key] = $newfraction; } } if ($invalidfractions) { $a = ['grades' => implode(', ', $invalidfractions), 'question' => $question->name]; echo $OUTPUT->notification(get_string('invalidgradequestion', 'question', $a)); ++$gradeerrors; continue; } else { $question->fraction = $fractions; } } $goodquestions[] = $question; } $questions = $goodquestions; // check for errors before we continue if ($this->stoponerror && $gradeerrors > 0) { echo $OUTPUT->notification(get_string('importparseerror', 'question')); return false; } // count number of questions processed $count = 0; foreach ($questions as $question) { // Process and store each question $transaction = $DB->start_delegated_transaction(); // reset the php timeout core_php_time_limit::raise(); // check for category modifiers if ($question->qtype == 'category') { if ($this->catfromfile) { // find/create category object $catpath = $question->category; $newcategory = $this->create_category_path($catpath, $question); if (!empty($newcategory)) { $this->category = $newcategory; } } $transaction->allow_commit(); continue; } $question->context = $this->importcontext; $count++; if ($this->displayprogress) { echo "

    {$count}. " . $this->format_question_text($question) . "

    "; } $question->category = $this->category->id; $question->stamp = make_unique_id_code(); // Set the unique code (not to be changed) $question->createdby = $USER->id; $question->timecreated = time(); $question->modifiedby = $USER->id; $question->timemodified = time(); if (isset($question->idnumber)) { if ((string) $question->idnumber === '') { // Id number not really set. Get rid of it. unset($question->idnumber); } else { if ($DB->record_exists('question_bank_entries', ['idnumber' => $question->idnumber, 'questioncategoryid' => $question->category])) { // We cannot have duplicate idnumbers in a category. Just remove it. unset($question->idnumber); } } } $fileoptions = array( 'subdirs' => true, 'maxfiles' => -1, 'maxbytes' => 0, ); $question->id = $DB->insert_record('question', $question); // Create a bank entry for each question imported. $questionbankentry = new \stdClass(); $questionbankentry->questioncategoryid = $question->category; $questionbankentry->idnumber = $question->idnumber ?? null; $questionbankentry->ownerid = $question->createdby; $questionbankentry->nextversion = 2; $questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry); // Create a version for each question imported. $questionversion = new \stdClass(); $questionversion->questionbankentryid = $questionbankentry->id; $questionversion->questionid = $question->id; $questionversion->version = 1; $questionversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questionversion->id = $DB->insert_record('question_versions', $questionversion); if (isset($question->questiontextitemid)) { $question->questiontext = file_save_draft_area_files($question->questiontextitemid, $this->importcontext->id, 'question', 'questiontext', $question->id, $fileoptions, $question->questiontext); file_clear_draft_area($question->questiontextitemid); } else if (isset($question->questiontextfiles)) { foreach ($question->questiontextfiles as $file) { question_bank::get_qtype($question->qtype)->import_file( $this->importcontext, 'question', 'questiontext', $question->id, $file); } } if (isset($question->generalfeedbackitemid)) { $question->generalfeedback = file_save_draft_area_files($question->generalfeedbackitemid, $this->importcontext->id, 'question', 'generalfeedback', $question->id, $fileoptions, $question->generalfeedback); file_clear_draft_area($question->generalfeedbackitemid); } else if (isset($question->generalfeedbackfiles)) { foreach ($question->generalfeedbackfiles as $file) { question_bank::get_qtype($question->qtype)->import_file( $this->importcontext, 'question', 'generalfeedback', $question->id, $file); } } $DB->update_record('question', $question); $this->questionids[] = $question->id; // Now to save all the answers and type-specific options $result = question_bank::get_qtype($question->qtype)->save_question_options($question); $event = \core\event\question_created::create_from_question_instance($question, $this->importcontext); $event->trigger(); if (core_tag_tag::is_enabled('core_question', 'question')) { // Course tags on question now deprecated so merge them into the question tags. $mergedtags = array_merge($question->coursetags ?? [], $question->tags ?? []); core_tag_tag::set_item_tags('core_question', 'question', $question->id, $question->context, $mergedtags ); } if (!empty($result->error)) { echo $OUTPUT->notification($result->error); // Can't use $transaction->rollback(); since it requires an exception, // and I don't want to rewrite this code to change the error handling now. $DB->force_transaction_rollback(); return false; } $transaction->allow_commit(); if (!empty($result->notice)) { echo $OUTPUT->notification($result->notice); return true; } } return true; } /** * Count all non-category questions in the questions array. * * @param array questions An array of question objects. * @return int The count. * */ protected function count_questions($questions) { $count = 0; if (!is_array($questions)) { return $count; } foreach ($questions as $question) { if (!is_object($question) || !isset($question->qtype) || ($question->qtype == 'category')) { continue; } $count++; } return $count; } /** * find and/or create the category described by a delimited list * e.g. $course$/tom/dick/harry or tom/dick/harry * * removes any context string no matter whether $getcontext is set * but if $getcontext is set then ignore the context and use selected category context. * * @param string catpath delimited category path * @param object $lastcategoryinfo Contains category information * @return mixed category object or null if fails */ protected function create_category_path($catpath, $lastcategoryinfo = null) { global $DB; $catnames = $this->split_category_path($catpath); $parent = 0; $category = null; // check for context id in path, it might not be there in pre 1.9 exports $matchcount = preg_match('/^\$([a-z]+)\$$/', $catnames[0], $matches); if ($matchcount == 1) { $contextid = $this->translator->string_to_context($matches[1]); array_shift($catnames); } else { $contextid = false; } // Before 3.5, question categories could be created at top level. // From 3.5 onwards, all question categories should be a child of a special category called the "top" category. if (isset($catnames[0]) && (($catnames[0] != 'top') || (count($catnames) < 3))) { array_unshift($catnames, 'top'); } if ($this->contextfromfile && $contextid !== false) { $context = context::instance_by_id($contextid); require_capability('moodle/question:add', $context); } else { $context = context::instance_by_id($this->category->contextid); } // If the file had categories in a deprecated context then the categories need to be applied to a default system type // mod_qbank instance on the course instead. if ($context->contextlevel !== CONTEXT_MODULE) { $qbank = \core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($this->course, true); $context = context_module::instance($qbank->id); } $this->importcontext = $context; // Now create any categories that need to be created. foreach ($catnames as $key => $catname) { if ($parent == 0) { $category = question_get_top_category($context->id, true); $parent = $category->id; } else if ($category = $DB->get_record('question_categories', array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) { // If this category is now the last one in the path we are processing ... if ($key == (count($catnames) - 1) && $lastcategoryinfo) { // Do nothing unless the child category appears before the parent category // in the imported xml file. Because the parent was created without info being available // at that time, this allows the info to be added from the xml data. if (isset($lastcategoryinfo->info) && $lastcategoryinfo->info !== '' && $category->info === '') { $category->info = $lastcategoryinfo->info; if (isset($lastcategoryinfo->infoformat) && $lastcategoryinfo->infoformat !== '') { $category->infoformat = $lastcategoryinfo->infoformat; } } // Same for idnumber. if (isset($lastcategoryinfo->idnumber) && $lastcategoryinfo->idnumber !== '' && $category->idnumber === '') { $category->idnumber = $lastcategoryinfo->idnumber; } $DB->update_record('question_categories', $category); } $parent = $category->id; } else { if ($catname == 'top') { // Should not happen, but if it does just move on. // Occurs when there has been some import/export that has created // multiple nested 'top' categories (due to old bug solved by MDL-63165). // This basically silently cleans up old errors. Not throwing an exception here. continue; } require_capability('moodle/question:managecategory', $context); // Create the new category. This will create all the categories in the catpath, // though only the final category will have any info added if available. $category = new stdClass(); $category->contextid = $context->id; $category->name = $catname; $category->info = ''; // Only add info (category description) for the final category in the catpath. if ($key == (count($catnames) - 1) && $lastcategoryinfo) { if (isset($lastcategoryinfo->info) && $lastcategoryinfo->info !== '') { $category->info = $lastcategoryinfo->info; if (isset($lastcategoryinfo->infoformat) && $lastcategoryinfo->infoformat !== '') { $category->infoformat = $lastcategoryinfo->infoformat; } } // Same for idnumber. if (isset($lastcategoryinfo->idnumber) && $lastcategoryinfo->idnumber !== '') { $category->idnumber = $lastcategoryinfo->idnumber; } } $category->parent = $parent; $category->sortorder = 999; $category->stamp = make_unique_id_code(); $category->id = $DB->insert_record('question_categories', $category); $parent = $category->id; $event = \core\event\question_category_created::create_from_question_category_instance($category, $context); $event->trigger(); } } return $category; } /** * Return complete file within an array, one item per line * @param string filename name of file * @return mixed contents array or false on failure */ protected function readdata($filename) { if (is_readable($filename)) { $filearray = file($filename); // If the first line of the file starts with a UTF-8 BOM, remove it. $filearray[0] = core_text::trim_utf8_bom($filearray[0]); // Check for Macintosh OS line returns (ie file on one line), and fix. if (preg_match("~\r~", $filearray[0]) AND !preg_match("~\n~", $filearray[0])) { return explode("\r", $filearray[0]); } else { return $filearray; } } return false; } /** * Parses an array of lines into an array of questions, * where each item is a question object as defined by * readquestion(). Questions are defined as anything * between blank lines. * * NOTE this method used to take $context as a second argument. However, at * the point where this method was called, it was impossible to know what * context the quetsions were going to be saved into, so the value could be * wrong. Also, none of the standard question formats were using this argument, * so it was removed. See MDL-32220. * * If your format does not use blank lines as a delimiter * then you will need to override this method. Even then * try to use readquestion for each question * @param array lines array of lines from readdata * @return array array of question objects */ protected function readquestions($lines) { $questions = array(); $currentquestion = array(); foreach ($lines as $line) { $line = trim($line); if (empty($line)) { if (!empty($currentquestion)) { if ($question = $this->readquestion($currentquestion)) { $questions[] = $question; } $currentquestion = array(); } } else { $currentquestion[] = $line; } } if (!empty($currentquestion)) { // There may be a final question if ($question = $this->readquestion($currentquestion)) { $questions[] = $question; } } return $questions; } /** * return an "empty" question * Somewhere to specify question parameters that are not handled * by import but are required db fields. * This should not be overridden. * @return object default question */ protected function defaultquestion() { global $CFG; static $defaultshuffleanswers = null; if (is_null($defaultshuffleanswers)) { $defaultshuffleanswers = get_config('quiz', 'shuffleanswers'); } $question = new stdClass(); $question->shuffleanswers = $defaultshuffleanswers; $question->defaultmark = 1; $question->image = ''; $question->usecase = 0; $question->multiplier = array(); $question->questiontextformat = FORMAT_MOODLE; $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_MOODLE; $question->answernumbering = 'abc'; $question->penalty = 0.3333333; $question->length = 1; // this option in case the questiontypes class wants // to know where the data came from $question->export_process = true; $question->import_process = true; $this->add_blank_combined_feedback($question); return $question; } /** * Construct a reasonable default question name, based on the start of the question text. * @param string $questiontext the question text. * @param string $default default question name to use if the constructed one comes out blank. * @return string a reasonable question name. */ public function create_default_question_name($questiontext, $default) { $name = $this->clean_question_name(shorten_text($questiontext, 80)); if ($name) { return $name; } else { return $default; } } /** * Ensure that a question name does not contain anything nasty, and will fit in the DB field. * @param string $name the raw question name. * @return string a safe question name. */ public function clean_question_name($name) { $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does. $name = trim($name); $trimlength = 251; while (core_text::strlen($name) > 255 && $trimlength > 0) { $name = shorten_text($name, $trimlength); $trimlength -= 10; } return $name; } /** * Add a blank combined feedback to a question object. * @param object question * @return object question */ protected function add_blank_combined_feedback($question) { $question->correctfeedback = [ 'text' => '', 'format' => $question->questiontextformat, 'files' => [] ]; $question->partiallycorrectfeedback = [ 'text' => '', 'format' => $question->questiontextformat, 'files' => [] ]; $question->incorrectfeedback = [ 'text' => '', 'format' => $question->questiontextformat, 'files' => [] ]; return $question; } /** * Given the data known to define a question in * this format, this function converts it into a question * object suitable for processing and insertion into Moodle. * * If your format does not use blank lines to delimit questions * (e.g. an XML format) you must override 'readquestions' too * @param $lines mixed data that represents question * @return object question object */ protected function readquestion($lines) { // We should never get there unless the qformat plugin is broken. throw new coding_exception('Question format plugin is missing important code: readquestion.'); return null; } /** * Override if any post-processing is required * @return bool success */ public function importpostprocess() { return true; } /******************* * EXPORT FUNCTIONS *******************/ /** * Provide export functionality for plugin questiontypes * Do not override * @param name questiontype name * @param question object data to export * @param extra mixed any addition format specific data needed * @return string the data to append to export or false if error (or unhandled) */ protected function try_exporting_using_qtypes($name, $question, $extra=null) { // work out the name of format in use $formatname = substr(get_class($this), strlen('qformat_')); $methodname = "export_to_{$formatname}"; $qtype = question_bank::get_qtype($name, false); if (method_exists($qtype, $methodname)) { return $qtype->$methodname($question, $this, $extra); } return false; } /** * Do any pre-processing that may be required * @param bool success */ public function exportpreprocess() { return true; } /** * Enable any processing to be done on the content * just prior to the file being saved * default is to do nothing * @param string output text * @param string processed output text */ protected function presave_process($content) { return $content; } /** * Perform the export. * For most types this should not need to be overrided. * * @param bool $checkcapabilities Whether to check capabilities when exporting the questions. * @return string The content of the export. */ public function exportprocess($checkcapabilities = true) { global $CFG, $DB; // Raise time and memory, as exporting can be quite intensive. core_php_time_limit::raise(); raise_memory_limit(MEMORY_EXTRA); // Get the parents (from database) for this category. $parents = []; if ($this->category) { $parents = question_categorylist_parents($this->category->id); } // get the questions (from database) in this category // only get q's with no parents (no cloze subquestions specifically) if ($this->category) { // Export only the latest version of a question. $questions = get_questions_category($this->category, true, true, true, true); } else { $questions = $this->questions; } $count = 0; // results are first written into string (and then to a file) // so create/initialize the string here $expout = ''; // track which category questions are in // if it changes we will record the category change in the output // file if selected. 0 means that it will get printed before the 1st question $trackcategory = 0; // Array of categories written to file. $writtencategories = []; foreach ($questions as $question) { // used by file api $questionbankentry = question_bank::load_question($question->id); $qcategory = $questionbankentry->category; $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $qcategory]); $question->contextid = $contextid; $question->idnumber = $questionbankentry->idnumber; // Do not export hidden questions. if ($question->status === \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN) { continue; } if ($question->status === \core_question\local\bank\question_version_status::QUESTION_STATUS_READY) { $question->status = 0; } else { $question->status = 1; } // do not export random questions if ($question->qtype == 'random') { continue; } // check if we need to record category change if ($this->cattofile) { $addnewcat = false; if ($question->category != $trackcategory) { $addnewcat = true; $trackcategory = $question->category; } $trackcategoryparents = question_categorylist_parents($trackcategory); // Check if we need to record empty parents categories. foreach ($trackcategoryparents as $trackcategoryparent) { // If parent wasn't written. if (!in_array($trackcategoryparent, $writtencategories)) { // If parent is empty. if (!count($DB->get_records('question_bank_entries', ['questioncategoryid' => $trackcategoryparent]))) { $categoryname = $this->get_category_path($trackcategoryparent, $this->contexttofile); $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategoryparent), 'name, info, infoformat, idnumber', MUST_EXIST); if ($categoryinfo->name != 'top') { // Create 'dummy' question for parent category. $dummyquestion = $this->create_dummy_question_representing_category($categoryname, $categoryinfo); $expout .= $this->writequestion($dummyquestion) . "\n"; $writtencategories[] = $trackcategoryparent; } } } } if ($addnewcat && !in_array($trackcategory, $writtencategories)) { $categoryname = $this->get_category_path($trackcategory, $this->contexttofile); $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategory), 'info, infoformat, idnumber', MUST_EXIST); // Create 'dummy' question for category. $dummyquestion = $this->create_dummy_question_representing_category($categoryname, $categoryinfo); $expout .= $this->writequestion($dummyquestion) . "\n"; $writtencategories[] = $trackcategory; } } // Add the question to result. if (!$checkcapabilities || question_has_capability_on($question, 'view')) { $expquestion = $this->writequestion($question, $contextid); // Don't add anything if witequestion returned nothing. // This will permit qformat plugins to exclude some questions. if ($expquestion !== null) { $expout .= $expquestion . "\n"; $count++; } } } // Did we actually process anything? Then continue path for following error checks. if ($count==0) { $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $this->category->id]); $context = context::instance_by_id($contextid); $continuepath = "{$CFG->wwwroot}/question/bank/exportquestions/export.php?cmid={$context->instanceid}"; throw new \moodle_exception('noquestions', 'question', $continuepath); } // final pre-process on exported data $expout = $this->presave_process($expout); return $expout; } /** * Create 'dummy' question for category export. * @param string $categoryname the name of the category * @param object $categoryinfo description of the category * @return stdClass 'dummy' question for category */ protected function create_dummy_question_representing_category(string $categoryname, $categoryinfo) { $dummyquestion = new stdClass(); $dummyquestion->qtype = 'category'; $dummyquestion->category = $categoryname; $dummyquestion->id = 0; $dummyquestion->questiontextformat = ''; $dummyquestion->contextid = 0; $dummyquestion->info = $categoryinfo->info; $dummyquestion->infoformat = $categoryinfo->infoformat; $dummyquestion->idnumber = $categoryinfo->idnumber; $dummyquestion->name = 'Switch category to ' . $categoryname; return $dummyquestion; } /** * get the category as a path (e.g., tom/dick/harry) * @param int id the id of the most nested catgory * @return string the path */ protected function get_category_path($id, $includecontext = true) { global $DB; if (!$category = $DB->get_record('question_categories', array('id' => $id))) { throw new \moodle_exception('cannotfindcategory', 'error', '', $id); } $contextstring = $this->translator->context_to_string($category->contextid); $pathsections = array(); do { $pathsections[] = $category->name; $id = $category->parent; } while ($category = $DB->get_record('question_categories', array('id' => $id))); if ($includecontext) { $pathsections[] = '$' . $contextstring . '$'; } $path = $this->assemble_category_path(array_reverse($pathsections)); return $path; } /** * Convert a list of category names, possibly preceeded by one of the * context tokens like $course$, into a string representation of the * category path. * * Names are separated by / delimiters. And /s in the name are replaced by //. * * To reverse the process and split the paths into names, use * {@link split_category_path()}. * * @param array $names * @return string */ protected function assemble_category_path($names) { $escapednames = array(); foreach ($names as $name) { $escapedname = str_replace('/', '//', $name); if (substr($escapedname, 0, 1) == '/') { $escapedname = ' ' . $escapedname; } if (substr($escapedname, -1) == '/') { $escapedname = $escapedname . ' '; } $escapednames[] = $escapedname; } return implode('/', $escapednames); } /** * Convert a string, as returned by {@link assemble_category_path()}, * back into an array of category names. * * Each category name is cleaned by a call to clean_param(, PARAM_TEXT), * which matches the cleaning in question/bank/managecategories/category_form.php. * * @param string $path * @return array of category names. */ protected function split_category_path($path) { $rawnames = preg_split('~(?questiontext, $question->questiontextformat)); } } class qformat_based_on_xml extends qformat_default { /** * A lot of imported files contain unwanted entities. * This method tries to clean up all known problems. * @param string str string to correct * @return string the corrected string */ public function cleaninput($str) { $html_code_list = array( "'" => "'", "’" => "'", "“" => "\"", "”" => "\"", "–" => "-", "—" => "-", ); $str = strtr($str, $html_code_list); // Use core_text entities_to_utf8 function to convert only numerical entities. $str = core_text::entities_to_utf8($str, false); return $str; } /** * Return the array moodle is expecting * for an HTML text. No processing is done on $text. * qformat classes that want to process $text * for instance to import external images files * and recode urls in $text must overwrite this method. * @param array $text some HTML text string * @return array with keys text, format and files. */ public function text_field($text) { return array( 'text' => trim($text), 'format' => FORMAT_HTML, 'files' => array(), ); } /** * Return the value of a node, given a path to the node * if it doesn't exist return the default value. * @param array xml data to read * @param array path path to node expressed as array * @param mixed default * @param bool istext process as text * @param string error if set value must exist, return false and issue message if not * @return mixed value */ public function getpath($xml, $path, $default, $istext=false, $error='') { foreach ($path as $index) { if (!isset($xml[$index])) { if (!empty($error)) { $this->error($error); return false; } else { return $default; } } $xml = $xml[$index]; } if ($istext) { if (!is_string($xml)) { $this->error(get_string('invalidxml', 'qformat_xml')); } $xml = trim($xml); } return $xml; } } PK\Lv flags.jsnu[// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * JavaScript library for dealing with the question flags. * * This script, and the YUI libraries that it needs, are inluded by * the $PAGE->requires->js calls in question_get_html_head_contributions in lib/questionlib.php. * * @package moodlecore * @subpackage questionengine * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ M.core_question_flags = { flagattributes: null, actionurl: null, listeners: [], editableSelector: 'div.questionflag.editable', init: function(Y, actionurl, flagattributes) { M.core_question_flags.flagattributes = flagattributes; M.core_question_flags.actionurl = actionurl; Y.all(M.core_question_flags.editableSelector).each(function(flagdiv) { var checkbox = flagdiv.one('input[type=checkbox]'); if (!checkbox) { return; } var input = Y.Node.create(''); input.set('id', checkbox.get('id')); input.set('name', checkbox.get('name')); input.set('value', checkbox.get('checked') ? 1 : 0); var ariaPressed = checkbox.get('checked') ? 'true' : 'false'; var toggle = Y.Node.create('' + '.' + ''); M.core_question_flags.update_flag(input, toggle); checkbox.remove(); flagdiv.one('label').remove(); flagdiv.append(input); flagdiv.append(toggle); }); Y.delegate('click', function(e) { e.halt(); M.core_question_flags.process(this); }, document.body, M.core_question_flags.editableSelector); Y.delegate('key', function(e) { e.halt(); if (e.keyCode == 13) { M.core_question_flags.process(this); } }, document.body, 'down:enter, space', M.core_question_flags.editableSelector); Y.delegate('key', function(e) { e.halt(); M.core_question_flags.process(this); }, document.body, 'up:space', M.core_question_flags.editableSelector); }, update_flag: function(input, toggle) { var value = input.get('value'); toggle.setContent( '' + M.core_question_flags.flagattributes[value].text ); toggle.set('aria-pressed', parseInt(value) ? 'true' : 'false'); toggle.set('aria-label', M.core_question_flags.flagattributes[value].alt); if (M.core_question_flags.flagattributes[value].title != M.core_question_flags.flagattributes[value].text) { toggle.set('title', M.core_question_flags.flagattributes[value].title); } else { toggle.removeAttribute('title'); } }, /** * Process the change of flag status. * * @param {Y.Node} target The root element */ process: function(target) { var input = target.one('input.questionflagvalue'); input.set('value', 1 - input.get('value')); M.core_question_flags.update_flag(input, target.one('[aria-pressed]')); var postdata = target.one('input.questionflagpostdata').get('value') + input.get('value'); Y.io(M.core_question_flags.actionurl, {method: 'POST', 'data': postdata}); M.core_question_flags.fire_listeners(postdata); }, add_listener: function(listener) { M.core_question_flags.listeners.push(listener); }, fire_listeners: function(postdata) { for (var i = 0; i < M.core_question_flags.listeners.length; i++) { M.core_question_flags.listeners[i]( postdata.match(/\bqubaid=(\d+)\b/)[1], postdata.match(/\bslot=(\d+)\b/)[1], postdata.match(/\bnewstate=(\d+)\b/)[1] ); } } }; PK\;Hz z "behaviour/interactive/renderer.phpnu[. /** * Renderer for outputting parts of a question belonging to the interactive * behaviour. * * @package qbehaviour * @subpackage interactive * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Interactive behaviour renderer. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_interactive_renderer extends qbehaviour_renderer { public function controls(question_attempt $qa, question_display_options $options) { if ($options->readonly === qbehaviour_interactive::TRY_AGAIN_VISIBLE || $options->readonly === qbehaviour_interactive::TRY_AGAIN_VISIBLE_READONLY) { // We are in the try again state, so no submit button. return ''; } return $this->submit_button($qa, $options); } public function feedback(question_attempt $qa, question_display_options $options) { // Show the Try again button if we are in try-again state. if (!$qa->get_state()->is_active() || ($options->readonly !== qbehaviour_interactive::TRY_AGAIN_VISIBLE && $options->readonly !== qbehaviour_interactive::TRY_AGAIN_VISIBLE_READONLY)) { return ''; } $attributes = [ 'type' => 'submit', 'id' => $qa->get_behaviour_field_name('tryagain'), 'name' => $qa->get_behaviour_field_name('tryagain'), 'value' => get_string('tryagain', 'qbehaviour_interactive'), 'class' => 'submit btn btn-secondary', 'data-savescrollposition' => 'true', ]; if ($options->readonly === qbehaviour_interactive::TRY_AGAIN_VISIBLE_READONLY) { // This means the question really was rendered with read-only option. $attributes['disabled'] = 'disabled'; } $output = html_writer::empty_tag('input', $attributes); if (empty($attributes['disabled'])) { $this->page->requires->js_call_amd('core_question/question_engine', 'initSubmitButton', [$attributes['id']]); } return $output; } } PK\x'o8behaviour/interactive/lang/en/qbehaviour_interactive.phpnu[. /** * Strings for component 'qbehaviour_interactive', language 'en'. * * @package qbehaviour * @subpackage interactive * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Interactive with multiple tries'; $string['privacy:metadata'] = 'The Interactive with multiple tries question behaviour plugin does not store any personal data.'; $string['triesremaining'] = 'Tries remaining: {$a}'; $string['tryagain'] = 'Try again'; PK\=k 6[[!behaviour/interactive/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage interactive * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_interactive'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\fU&&#behaviour/interactive/behaviour.phpnu[. /** * Question behaviour where the student can submit questions one at a * time for immediate feedback. * * @package qbehaviour * @subpackage interactive * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour for the interactive model. * * Each question has a submit button next to it which the student can use to * submit it. Once the question is submitted, it is not possible for the * student to change their answer any more, but the student gets full feedback * straight away. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_interactive extends question_behaviour_with_multiple_tries { /** * Constant used only in {@link adjust_display_options()} below and * {@link (qbehaviour_interactive_renderer}. * @var int */ const TRY_AGAIN_VISIBLE = 0x10; /** * Constant used only in {@link adjust_display_options()} below and * {@link (qbehaviour_interactive_renderer}. * @var int */ const TRY_AGAIN_VISIBLE_READONLY = 0x11; public function is_compatible_question(question_definition $question) { return $question instanceof question_automatically_gradable; } public function can_finish_during_attempt() { return true; } public function get_right_answer_summary() { return $this->question->get_right_answer_summary(); } /** * @return bool are we are currently in the try_again state. */ public function is_try_again_state() { $laststep = $this->qa->get_last_step(); return $this->qa->get_state()->is_active() && $laststep->has_behaviour_var('submit') && $laststep->has_behaviour_var('_triesleft'); } public function adjust_display_options(question_display_options $options) { // We only need different behaviour in try again states. if (!$this->is_try_again_state()) { parent::adjust_display_options($options); if ($this->qa->get_state() == question_state::$invalid && $options->marks == question_display_options::MARK_AND_MAX) { $options->marks = question_display_options::MAX_ONLY; } return; } // The question in in a try-again state. We need the to let the renderer know this. // The API for question-rendering is defined by the question engine, but we // don't want to add logic in the renderer, so we are limited in how we can do this. // However, when the question is in this state, all the question-type controls // need to be rendered read-only. Therefore, we can conveniently pass this information // by setting special true-like values in $options->readonly (but this is a bit of a hack). $options->readonly = $options->readonly ? self::TRY_AGAIN_VISIBLE_READONLY : self::TRY_AGAIN_VISIBLE; // Let the hint adjust the options. $hint = $this->get_applicable_hint(); if (!is_null($hint)) { $hint->adjust_display_options($options); } // Now call the base class method, but protect some fields from being overwritten. $save = clone($options); parent::adjust_display_options($options); $options->feedback = $save->feedback; $options->numpartscorrect = $save->numpartscorrect; } public function get_applicable_hint() { if (!$this->is_try_again_state()) { return null; } return $this->question->get_hint(count($this->question->hints) - $this->qa->get_last_behaviour_var('_triesleft'), $this->qa); } public function get_expected_data() { if ($this->is_try_again_state()) { return array( 'tryagain' => PARAM_BOOL, ); } else if ($this->qa->get_state()->is_active()) { return array( 'submit' => PARAM_BOOL, ); } return parent::get_expected_data(); } public function get_expected_qt_data() { $hint = $this->get_applicable_hint(); if (!empty($hint->clearwrong)) { return $this->question->get_expected_data(); } return parent::get_expected_qt_data(); } public function get_state_string($showcorrectness) { $state = $this->qa->get_state(); if (!$state->is_active() || $state == question_state::$invalid) { return parent::get_state_string($showcorrectness); } return get_string('triesremaining', 'qbehaviour_interactive', $this->qa->get_last_behaviour_var('_triesleft')); } public function init_first_step(question_attempt_step $step, $variant) { parent::init_first_step($step, $variant); $step->set_behaviour_var('_triesleft', count($this->question->hints) + 1); } public function process_action(question_attempt_pending_step $pendingstep) { if ($pendingstep->has_behaviour_var('finish')) { return $this->process_finish($pendingstep); } if ($this->is_try_again_state()) { if ($pendingstep->has_behaviour_var('tryagain')) { return $this->process_try_again($pendingstep); } else { return question_attempt::DISCARD; } } else { if ($pendingstep->has_behaviour_var('comment')) { return $this->process_comment($pendingstep); } else if ($pendingstep->has_behaviour_var('submit')) { return $this->process_submit($pendingstep); } else { return $this->process_save($pendingstep); } } } public function summarise_action(question_attempt_step $step) { if ($step->has_behaviour_var('comment')) { return $this->summarise_manual_comment($step); } else if ($step->has_behaviour_var('finish')) { return $this->summarise_finish($step); } else if ($step->has_behaviour_var('tryagain')) { return get_string('tryagain', 'qbehaviour_interactive'); } else if ($step->has_behaviour_var('submit')) { return $this->summarise_submit($step); } else { return $this->summarise_save($step); } } public function process_try_again(question_attempt_pending_step $pendingstep) { $pendingstep->set_state(question_state::$todo); return question_attempt::KEEP; } public function process_submit(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } if (!$this->is_complete_response($pendingstep)) { $pendingstep->set_state(question_state::$invalid); } else { $triesleft = $this->qa->get_last_behaviour_var('_triesleft'); $response = $pendingstep->get_qt_data(); list($fraction, $state) = $this->question->grade_response($response); if ($state == question_state::$gradedright || $triesleft == 1) { $pendingstep->set_state($state); $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep)); } else { $pendingstep->set_behaviour_var('_triesleft', $triesleft - 1); $pendingstep->set_state(question_state::$todo); } $pendingstep->set_new_response_summary($this->question->summarise_response($response)); } return question_attempt::KEEP; } protected function adjust_fraction($fraction, question_attempt_pending_step $pendingstep) { $totaltries = $this->qa->get_step(0)->get_behaviour_var('_triesleft'); $triesleft = $this->qa->get_last_behaviour_var('_triesleft'); $fraction -= ($totaltries - $triesleft) * $this->question->penalty; $fraction = max($fraction, 0); return $fraction; } public function process_finish(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } $response = $this->qa->get_last_qt_data(); if (!$this->question->is_gradable_response($response)) { $pendingstep->set_state(question_state::$gaveup); } else { list($fraction, $state) = $this->question->grade_response($response); $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep)); $pendingstep->set_state($state); } $pendingstep->set_new_response_summary($this->question->summarise_response($response)); return question_attempt::KEEP; } public function process_save(question_attempt_pending_step $pendingstep) { $status = parent::process_save($pendingstep); if ($status == question_attempt::KEEP && $pendingstep->get_state() == question_state::$complete) { $pendingstep->set_state(question_state::$todo); } return $status; } } PK\2behaviour/interactive/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_interactive. * * @package qbehaviour_interactive * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_interactive\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_interactive implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\?Vª'behaviour/interactive/behaviourtype.phpnu[. /** * Question behaviour type for interactive behaviour. * * @package qbehaviour_interactive * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour type information for interactive behaviour. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_interactive_type extends question_behaviour_type { public function is_archetypal() { return true; } public function allows_multiple_submitted_responses() { return true; } public function can_questions_finish_during_the_attempt() { return true; } } PK\ff0behaviour/interactive/tests/walkthrough_test.phpnu[. namespace qbehaviour_interactive; use question_display_options; use question_hint; use question_hint_with_parts; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the interactive behaviour. * * @package qbehaviour_interactive * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_interactive_feedback_multichoice_right(): void { // Create a multichoice single question. $mc = \test_question_maker::make_a_multichoice_single_question(); $mc->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, false, false), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true), ); $this->start_attempt_at_question($mc, 'interactive'); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Save the wrong answer. $this->process_submission(array('answer' => $wrongindex)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation()); // Submit the wrong answer. $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), new \question_pattern_expectation('/Tries remaining: 2/'), $this->get_contains_hint_expectation('This is the first hint')); // Check that, if we review in this state, the try again button is disabled. $displayoptions = new question_display_options(); $displayoptions->readonly = true; $html = $this->quba->render_question($this->slot, $displayoptions); $this->assert($this->get_contains_try_again_button_expectation(false), $html); // Do try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); // Submit the right answer. $this->process_submission(array('answer' => $rightindex, '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(0.6666667); $this->check_current_output( $this->get_contains_mark_summary(0.6666667), $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Finish the attempt - should not need to add a new state. $numsteps = $this->get_step_count(); $this->quba->finish_all_questions(); // Verify. $this->assertEquals($numsteps, $this->get_step_count()); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(0.6666667); $this->check_current_output( $this->get_contains_mark_summary(0.6666667), $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_correct_expectation(), $this->get_no_hint_visible_expectation()); // Process a manual comment. $this->manual_grade('Not good enough!', 0.5, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.5); $this->check_current_output( $this->get_contains_mark_summary(0.5), $this->get_contains_partcorrect_expectation(), new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Check regrading does not mess anything up. $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.5); $this->check_current_output( $this->get_contains_mark_summary(0.5), $this->get_contains_partcorrect_expectation()); $autogradedstep = $this->get_step($this->get_step_count() - 2); $this->assertEqualsWithDelta($autogradedstep->get_fraction(), 0.6666667, 0.0000001); } public function test_interactive_finish_when_try_again_showing(): void { // Create a multichoice single question. $mc = \test_question_maker::make_a_multichoice_single_question(); $mc->showstandardinstruction = true; $mc->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, false, false), ); $this->start_attempt_at_question($mc, 'interactive'); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation(), new \question_pattern_expectation('/' . preg_quote(get_string('selectone', 'qtype_multichoice'), '/') . '/')); // Submit the wrong answer. $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), new \question_pattern_expectation('/Tries remaining: 1/'), $this->get_contains_hint_expectation('This is the first hint')); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(0); $this->check_current_output( $this->get_contains_mark_summary(0), $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_incorrect_expectation(), $this->get_no_hint_visible_expectation()); } public function test_interactive_shortanswer_try_to_submit_blank(): void { // Create a short answer question. $sa = \test_question_maker::make_question('shortanswer'); $sa->hints = array( new question_hint(0, 'This is the first hint.', FORMAT_HTML), new question_hint(0, 'This is the second hint.', FORMAT_HTML), ); $this->start_attempt_at_question($sa, 'interactive'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Submit blank. $this->process_submission(array('-submit' => 1, 'answer' => '')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it wrong. $this->process_submission(array('-submit' => 1, 'answer' => 'newt')); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_does_not_contain_submit_button_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_contains_try_again_button_expectation(true), new \question_pattern_expectation('/Tries remaining: 2/'), $this->get_contains_hint_expectation('This is the first hint')); $this->assertEquals('newt', $this->quba->get_response_summary($this->slot)); // Try again. $this->process_submission(array('-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now submit blank again. $this->process_submission(array('-submit' => 1, 'answer' => '')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_contains_validation_error_expectation(), $this->get_does_not_contain_try_again_button_expectation(), $this->get_no_hint_visible_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'answer' => 'frog')); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(0.6666667); $this->check_current_output( $this->get_contains_mark_summary(0.6666667), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_correct_expectation(), $this->get_does_not_contain_validation_error_expectation(), $this->get_no_hint_visible_expectation()); $this->assertEquals('frog', $this->quba->get_response_summary($this->slot)); } public function test_interactive_feedback_multichoice_multiple_reset(): void { // Create a multichoice multiple question. $mc = \test_question_maker::make_a_multichoice_multi_question(); $mc->showstandardinstruction = true; $mc->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, true, true), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true), ); $this->start_attempt_at_question($mc, 'interactive', 2); $right = array_keys($mc->get_correct_response()); $wrong = array_diff(array('choice0', 'choice1', 'choice2', 'choice3'), $right); $wrong = array_values(array_diff( array('choice0', 'choice1', 'choice2', 'choice3'), $right)); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_checkbox_expectation('choice0', true, false), $this->get_contains_mc_checkbox_expectation('choice1', true, false), $this->get_contains_mc_checkbox_expectation('choice2', true, false), $this->get_contains_mc_checkbox_expectation('choice3', true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_tries_remaining_expectation(3), $this->get_no_hint_visible_expectation(), new \question_pattern_expectation('/' . preg_quote(get_string('selectmulti', 'qtype_multichoice'), '/') . '/')); // Submit an answer with one right, and one wrong. $this->process_submission(array($right[0] => 1, $wrong[0] => 1, '-submit' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_checkbox_expectation($right[0], false, true), $this->get_contains_mc_checkbox_expectation($right[1], false, false), $this->get_contains_mc_checkbox_expectation($wrong[0], false, true), $this->get_contains_mc_checkbox_expectation($wrong[1], false, false), $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_try_again_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), new \question_pattern_expectation('/Tries remaining: 2/'), $this->get_contains_hint_expectation('This is the first hint'), $this->get_contains_num_parts_correct(1), $this->get_contains_standard_incorrect_combined_feedback_expectation(), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . $right[0], '1'), $this->get_does_not_contain_hidden_expectation( $this->quba->get_field_prefix($this->slot) . $right[1]), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . $wrong[0], '0'), $this->get_does_not_contain_hidden_expectation( $this->quba->get_field_prefix($this->slot) . $wrong[1])); // Do try again. $this->process_submission(array($right[0] => 1, '-tryagain' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_marked_out_of_summary(), $this->get_contains_mc_checkbox_expectation($right[0], true, true), $this->get_contains_mc_checkbox_expectation($right[1], true, false), $this->get_contains_mc_checkbox_expectation($wrong[0], true, false), $this->get_contains_mc_checkbox_expectation($wrong[1], true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(2), $this->get_no_hint_visible_expectation()); } public function test_interactive_regrade_changing_num_tries_leaving_open(): void { // Create a multichoice multiple question. $q = \test_question_maker::make_question('shortanswer'); $q->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, true, true), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true), ); $this->start_attempt_at_question($q, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_tries_remaining_expectation(3)); // Submit the right answer. $this->process_submission(array('answer' => 'frog', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); // Now change the quiestion so that answer is only partially right, and regrade. $q->answers[13]->fraction = 0.6666667; $q->answers[14]->fraction = 1; $this->quba->regrade_all_questions(false); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); } public function test_interactive_regrade_changing_num_tries_finished(): void { // Create a multichoice multiple question. $q = \test_question_maker::make_question('shortanswer'); $q->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, true, true), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true), ); $this->start_attempt_at_question($q, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_tries_remaining_expectation(3)); // Submit the right answer. $this->process_submission(array('answer' => 'frog', '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); // Now change the quiestion so that answer is only partially right, and regrade. $q->answers[13]->fraction = 0.6666667; $q->answers[14]->fraction = 1; $this->quba->regrade_all_questions(true); // Verify. $this->check_current_state(question_state::$gradedpartial); // TODO I don't think 1 is the right fraction here. However, it is what // you get attempting a question like this without regrading being involved, // and I am currently interested in testing regrading here. $this->check_current_mark(1); } public function test_review_of_interactive_questions_before_finished(): void { // Create a multichoice multiple question. $q = \test_question_maker::make_question('shortanswer'); $q->hints = array( new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, true, true), new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true), ); $this->start_attempt_at_question($q, 'interactive', 3); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_try_again_button_expectation()); // Now check what the teacher sees when they review the question. $this->displayoptions->readonly = true; $this->check_current_output( $this->get_contains_submit_button_expectation(false), $this->get_does_not_contain_feedback_expectation(), $this->get_tries_remaining_expectation(3), $this->get_does_not_contain_try_again_button_expectation()); $this->displayoptions->readonly = false; // Submit a wrong answer. $this->process_submission(array('answer' => 'cat', '-submit' => 1)); // Check the Try again button now shows up correctly. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint.'), $this->get_tries_remaining_expectation(2), $this->get_contains_try_again_button_expectation(true)); // And check that a disabled Try again button shows up when the question is reviewed. $this->displayoptions->readonly = true; $this->check_current_output( $this->get_does_not_contain_submit_button_expectation(), $this->get_contains_hint_expectation('This is the first hint.'), $this->get_tries_remaining_expectation(2), $this->get_contains_try_again_button_expectation(false)); } } PK\͟att3behaviour/interactive/tests/behaviour_type_test.phpnu[. namespace qbehaviour_interactive; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the interactive with multiple tries behaviour type class. * * @package qbehaviour_interactive * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \basic_testcase { /** @var qbehaviour_interactive_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('interactive'); } public function test_is_archetypal(): void { $this->assertTrue($this->behaviourtype->is_archetypal()); } public function test_get_unused_display_options(): void { $this->assertEquals(array(), $this->behaviourtype->get_unused_display_options()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } } PK\Wbehaviour/behaviourtypebase.phpnu[. /** * Defines the question behaviour type base class * * @package core * @subpackage questionbehaviours * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * This class represents the type of behaviour, rather than the instance of the * behaviour which control a particular question attempt. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_behaviour_type { /** * Certain behaviours are definitive of a way that questions can behave when * attempted. For example deferredfeedback model, interactive model, etc. * These are the options that should be listed in the user-interface, and * for these behaviours this method should return true. Other behaviours are * more implementation details, for example the informationitem behaviours, * or a special subclass like interactive_adapted_for_my_qtype. These * behaviours should return false. * @return bool whether this is an archetypal behaviour. */ public function is_archetypal() { return false; } /** * Override this method if there are some display options that do not make * sense 'during the attempt'. * @return array of {@link question_display_options} field names, that are * not relevant to this behaviour before a 'finish' action. */ public function get_unused_display_options() { return array(); } /** * With this behaviour, is it possible that a question might finish as the student * interacts with it, without a call to the {@link question_attempt::finish()} method? * @return bool whether with this behaviour, questions may finish naturally. */ public function can_questions_finish_during_the_attempt() { return false; } /** * Adjust a random guess score for a question using this model. You have to * do this without knowing details of the specific question, or which usage * it is in. * @param number $fraction the random guess score from the question type. * @return number the adjusted fraction. */ public function adjust_random_guess_score($fraction) { return $fraction; } /** * Get summary information about a queston usage. * * Behaviours are not obliged to do anything here, but this is an opportunity * to provide additional information that can be displayed in places like * at the top of the quiz review page. * * In the return value, the array keys should be identifiers of the form * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary. * The values should be arrays with two items, title and content. Each of these * should be either a string, or a renderable. * * To understand how to implement this method, look at the CBM behaviours, * and their unit tests. * * @param question_usage_by_activity $quba the usage to provide summary data for. * @return array as described above. */ public function summarise_usage(question_usage_by_activity $quba, question_display_options $options) { return array(); } /** * Does this question behaviour accept multiple submissions of responses within one attempt eg. multiple tries for the * interactive or adaptive question behaviours. * * @return bool */ public function allows_multiple_submitted_responses() { return false; } } /** * This class exists to allow behaviours that worked in Moodle 2.3 to continue * to work. It implements the question_behaviour_type API for the other behaviour * as much as possible in a backwards-compatible way. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_behaviour_type_fallback extends question_behaviour_type { /** @var string the behaviour class name. */ protected $behaviourclass; /** * @param string $behaviourtype the type of behaviour we are providing a fallback for. */ public function __construct($behaviour) { question_engine::load_behaviour_class($behaviour); $this->behaviourclass = 'qbehaviour_' . $behaviour; } public function is_archetypal() { return constant($this->behaviourclass . '::IS_ARCHETYPAL'); } /** * Override this method if there are some display options that do not make * sense 'during the attempt'. * @return array of {@link question_display_options} field names, that are * not relevant to this behaviour before a 'finish' action. */ public function get_unused_display_options() { return call_user_func(array($this->behaviourclass, 'get_unused_display_options')); } /** * Adjust a random guess score for a question using this model. You have to * do this without knowing details of the specific question, or which usage * it is in. * @param number $fraction the random guess score from the question type. * @return number the adjusted fraction. */ public function adjust_random_guess_score($fraction) { return call_user_func(array($this->behaviourclass, 'adjust_random_guess_score'), $fraction); } } PK\ )k&behaviour/informationitem/renderer.phpnu[. /** * Defines the renderer the information item behaviour. * * @package qbehaviour_informationitem * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer for outputting parts of a question belonging to the information * item behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_informationitem_renderer extends qbehaviour_renderer { public function controls(question_attempt $qa, question_display_options $options) { if ($options->readonly || $qa->get_state() != question_state::$todo) { return ''; } // Hidden input to move the question into the complete state. return html_writer::empty_tag('input', array( 'type' => 'hidden', 'name' => $qa->get_behaviour_field_name('seen'), 'value' => 1, )); } } PK\ uu@behaviour/informationitem/lang/en/qbehaviour_informationitem.phpnu[. /** * Strings for component 'qbehaviour_informationitem', language 'en'. * * @package qbehaviour * @subpackage informationitem * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Behaviour for information items'; $string['privacy:metadata'] = 'The Information items question behaviour plugin does not store any personal data.'; $string['seen'] = 'Seen'; PK\W1%cc%behaviour/informationitem/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage informationitem * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_informationitem'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\?9'behaviour/informationitem/behaviour.phpnu[. /** * This behaviour is for information items. * * @package qbehaviour * @subpackage informationitem * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour informaiton items. * * For example for the 'Description' 'Question type'. There is no grade, * and the question type is marked complete the first time the user navigates * away from a page that contains that question. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_informationitem extends question_behaviour { public function is_compatible_question(question_definition $question) { return true; } public function get_expected_data() { if ($this->qa->get_state() == question_state::$todo) { return array('seen' => PARAM_BOOL); } return parent::get_expected_data(); } public function get_correct_response() { if ($this->qa->get_state() == question_state::$todo) { return array('seen' => 1); } return array(); } public function adjust_display_options(question_display_options $options) { parent::adjust_display_options($options); $options->marks = question_display_options::HIDDEN; // At the moment, the code exists to process a manual comment on an // information item, but we don't display the UI unless there is already // a comment. if (!$this->qa->get_state()->is_commented()) { $options->manualcomment = question_display_options::HIDDEN; } } public function get_state_string($showcorrectness) { return ''; } public function process_action(question_attempt_pending_step $pendingstep) { if ($pendingstep->has_behaviour_var('comment')) { return $this->process_comment($pendingstep); } else if ($pendingstep->has_behaviour_var('finish')) { return $this->process_finish($pendingstep); } else if ($pendingstep->has_behaviour_var('seen')) { return $this->process_seen($pendingstep); } else { return question_attempt::DISCARD; } } public function summarise_action(question_attempt_step $step) { if ($step->has_behaviour_var('comment')) { return $this->summarise_manual_comment($step); } else if ($step->has_behaviour_var('finish')) { return $this->summarise_finish($step); } else if ($step->has_behaviour_var('seen')) { return get_string('seen', 'qbehaviour_informationitem'); } return $this->summarise_start($step); } public function process_comment(question_attempt_pending_step $pendingstep) { if ($pendingstep->has_behaviour_var('mark')) { throw new coding_exception('Information items cannot be graded.'); } return parent::process_comment($pendingstep); } /** * Handle the 'finish' case of {@see process_action()}. * * @param question_attempt_pending_step $pendingstep step representing the action. * @return bool either {@see question_attempt::KEEP} or {@see question_attempt::DISCARD}. */ public function process_finish(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } if (!$this->qa->get_state()->is_active()) { throw new coding_exception('It should be impossible for question_attempt ' . $this->qa->get_database_id() . ' using ' . get_class($this) . " to receive a 'finish' action while not active. " . 'Failing with an error, rather than continuing and doing undefined processing, ' . 'so the bug can be identified.'); } $pendingstep->set_state(question_state::$finished); return question_attempt::KEEP; } /** * Handle the 'seen' case of {@see process_action()}. * * @param question_attempt_pending_step $pendingstep step representing the action. * @return bool either {@see question_attempt::KEEP} or {@see question_attempt::DISCARD}. */ public function process_seen(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } // Assert so we get a clear error message if the assumptions on which this code relies is invalid. if (!$this->qa->get_state()->is_active()) { throw new coding_exception('It should be impossible for question_attempt ' . $this->qa->get_database_id() . ' using ' . get_class($this) . " to receive a 'seen' action while not active. " . 'Failing with an error, rather than continuing and doing undefined processing, ' . 'so the bug can be identified.'); } $pendingstep->set_state(question_state::$complete); return question_attempt::KEEP; } } PK\..6behaviour/informationitem/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_informationitem. * * @package qbehaviour_informationitem * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_informationitem\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_informationitem implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\+behaviour/informationitem/behaviourtype.phpnu[. /** * Question behaviour type for information item behaviour. * * @package qbehaviour_informationitem * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour type information for informationitem items. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_informationitem_type extends question_behaviour_type { } PK\(cMM4behaviour/informationitem/tests/walkthrough_test.phpnu[. namespace qbehaviour_informationitem; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the information item behaviour. * * @package qbehaviour_informationitem * @category test * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \qbehaviour_informationitem */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_informationitem_feedback_description(): void { // Create a true-false question with correct answer true. $description = \test_question_maker::make_question('description'); $this->start_attempt_at_question($description, 'deferredfeedback'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($description), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . '-seen', 1), $this->get_does_not_contain_feedback_expectation()); // Check no hidden input when read-only. $this->displayoptions->readonly = true; $this->check_current_output($this->get_contains_question_text_expectation($description), $this->get_does_not_contain_hidden_expectation( $this->quba->get_field_prefix($this->slot) . '-seen', 1)); $this->displayoptions->readonly = false; // Process a submission indicating this question has been seen. $this->process_submission(['-seen' => 1]); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output($this->get_does_not_contain_correctness_expectation(), new \question_no_pattern_expectation( '/type=\"hidden\"[^>]*name=\"[^"]*seen\"|name=\"[^"]*seen\"[^>]*type=\"hidden\"/'), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$finished); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($description), $this->get_contains_general_feedback_expectation($description)); // Process a manual comment. $this->manual_grade('Not good enough!', null, FORMAT_HTML); $this->check_current_state(question_state::$manfinished); $this->check_current_mark(null); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Check that trying to process a manual comment with a grade causes an exception. $this->expectException('moodle_exception'); $this->manual_grade('Not good enough!', 1, FORMAT_HTML); } public function test_informationitem_regrade(): void { // Create a true-false question with correct answer true. $description = \test_question_maker::make_question('description'); $this->start_attempt_at_question($description, 'deferredfeedback'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($description), $this->get_contains_hidden_expectation( $this->quba->get_field_prefix($this->slot) . '-seen', 1), $this->get_does_not_contain_feedback_expectation()); // Process a submission indicating this question has been seen. $this->process_submission(['-seen' => 1]); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output($this->get_does_not_contain_correctness_expectation(), new \question_no_pattern_expectation( '/type=\"hidden\"[^>]*name=\"[^"]*seen\"|name=\"[^"]*seen\"[^>]*type=\"hidden\"/'), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$finished); $this->check_current_mark(null); $this->check_step_count(3); $this->check_current_output( $this->get_contains_question_text_expectation($description), $this->get_contains_general_feedback_expectation($description)); // Regrade the attempt. $this->quba->regrade_all_questions(true); // Verify. $this->check_current_mark(null); $this->check_step_count(3); $this->check_current_output( $this->get_contains_question_text_expectation($description), $this->get_contains_general_feedback_expectation($description)); } } PK\ww7behaviour/informationitem/tests/behaviour_type_test.phpnu[. namespace qbehaviour_informationitem; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the information item behaviour type class. * * @package qbehaviour_informationitem * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \basic_testcase { /** @var qbehaviour_informationitem_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('informationitem'); } public function test_is_archetypal(): void { $this->assertFalse($this->behaviourtype->is_archetypal()); } public function test_get_unused_display_options(): void { $this->assertEquals(array(), $this->behaviourtype->get_unused_display_options()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } } PK\fss behaviour/deferredcbm/styles.cssnu[.qbehaviour_deferredcbm_slightlyunderconfident, .qbehaviour_deferredcbm_slightlyoverconfident { font-weight: bold; color: #600; } .qbehaviour_deferredcbm_underconfident, .qbehaviour_deferredcbm_overconfident { font-weight: bold; color: #c00; } .qbehaviour_deferredcbm_judgementok { font-weight: bold; color: #080; } .qbehaviour_deferredcbm_actual_percentage { font-weight: bold; } .qbehaviour_deferredcbm_summary_heading { margin: 0; } .que.deferredcbm .certaintychoices input[type="radio"] { margin-left: 0.5em; } .que.deferredcbm .certaintychoices label { white-space: nowrap; } PK\NZZ"behaviour/deferredcbm/renderer.phpnu[. /** * Defines the renderer for the deferred feedback with certainty based marking * behaviour. * * @package qbehaviour * @subpackage deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer for outputting parts of a question belonging to the deferred * feedback with certainty based marking behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredcbm_renderer extends qbehaviour_renderer { protected function certainty_choices($controlname, $selected, $readonly) { $attributes = array( 'type' => 'radio', 'name' => $controlname, ); if ($readonly) { $attributes['disabled'] = 'disabled'; } $choices = ''; foreach (question_cbm::$certainties as $certainty) { $id = $controlname . $certainty; $attributes['id'] = $id; $attributes['value'] = $certainty; if ($selected == $certainty) { $attributes['checked'] = 'checked'; } else { unset($attributes['checked']); } $choices .= ' ' . html_writer::tag('label', html_writer::empty_tag('input', $attributes) . question_cbm::get_string($certainty), array('for' => $id)); } return $choices; } public function controls(question_attempt $qa, question_display_options $options) { $a = new stdClass(); $a->help = $this->output->help_icon('certainty', 'qbehaviour_deferredcbm'); $a->choices = $this->certainty_choices($qa->get_behaviour_field_name('certainty'), $qa->get_last_behaviour_var('certainty'), $options->readonly); return html_writer::tag('div', get_string('howcertainareyou', 'qbehaviour_deferredcbm', $a), array('class' => 'certaintychoices')); } public function feedback(question_attempt $qa, question_display_options $options) { if (!$options->feedback) { return ''; } if ($qa->get_state() == question_state::$gaveup || $qa->get_state() == question_state::$mangaveup) { return ''; } $feedback = ''; if (!$qa->get_last_behaviour_var('certainty') && $qa->get_last_behaviour_var('_assumedcertainty')) { $feedback .= html_writer::tag('p', get_string('assumingcertainty', 'qbehaviour_deferredcbm', question_cbm::get_string($qa->get_last_behaviour_var('_assumedcertainty')))); } return $feedback; } public function marked_out_of_max(question_attempt $qa, core_question_renderer $qoutput, question_display_options $options) { return get_string('weightx', 'qbehaviour_deferredcbm', $qa->format_fraction_as_mark( question_cbm::adjust_fraction(1, question_cbm::default_certainty()), $options->markdp)); } public function mark_out_of_max(question_attempt $qa, core_question_renderer $qoutput, question_display_options $options) { return get_string('cbmmark', 'qbehaviour_deferredcbm', $qa->format_mark($options->markdp)) . '
    ' . $this->marked_out_of_max($qa, $qoutput, $options); } } PK\oKK8behaviour/deferredcbm/lang/en/qbehaviour_deferredcbm.phpnu[. /** * Strings for component 'qbehaviour_deferredcbm', language 'en'. * * @package qbehaviour * @subpackage deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['accuracy'] = 'Accuracy'; $string['accuracyandbonus'] = 'Accuracy + Bonus'; $string['assumingcertainty'] = 'You did not select a certainty. Assuming: {$a}.'; $string['averagecbmmark'] = 'Average CBM mark'; $string['basemark'] = 'Base mark {$a}'; $string['breakdownbycertainty'] = 'Break-down by certainty'; $string['cbmbonus'] = 'CBM bonus'; $string['cbmmark'] = 'CBM mark {$a}'; $string['cbmgradeexplanation'] = 'For CBM, the grade above is shown relative to the maximum for all correct at C=1.'; $string['cbmgrades'] = 'CBM grades'; $string['cbmgrades_help'] = 'With Certainty Based Marking (CBM) getting every question correct with C=1 (low certainty) gives a grade of 100%. Grades may be as high as 300% if every question is correct with C=3 (high certainty). Misconceptions (confident wrong responses) lower grades much more than wrong responses that are acknowledged to be uncertain. This may even lead to negative overall grades. **Accuracy** is the % correct ignoring certainty but weighted for the maximum mark of each question. Successfully distinguishing more and less reliable responses gives a better grade than selecting the same certainty for each question. This is reflected in the **CBM Bonus**. **Accuracy** + **CBM Bonus** is a better measure of knowledge than **Accuracy**. Misconceptions can lead to a negative bonus, a warning to look carefully at what is and is not known.'; $string['cbmgrades_link'] = 'qbehaviour/deferredcbm/certaintygrade'; $string['certainty'] = 'Certainty'; $string['certainty_help'] = 'Certainty-based marking requires you to indicate how reliable you think your answer is. The available levels are: Certainty level | C=1 (Unsure) | C=2 (Mid) | C=3 (Quite sure) ------------------- | ------------ | --------- | ---------------- Mark if correct | 1 | 2 | 3 Mark if wrong | 0 | -2 | -6 Probability correct | <67% | 67-80% | >80% Best marks are gained by acknowledging uncertainty. For example, if you think there is more than a 1 in 3 chance of being wrong, you should enter C=1 and avoid the risk of a negative mark. '; $string['certainty_link'] = 'qbehaviour/deferredcbm/certainty'; $string['certainty-1'] = 'No Idea'; $string['certainty1'] = 'C=1 (Unsure: <67%)'; $string['certainty2'] = 'C=2 (Mid: >67%)'; $string['certainty3'] = 'C=3 (Quite sure: >80%)'; $string['certaintyshort-1'] = 'No Idea'; $string['certaintyshort1'] = 'C=1'; $string['certaintyshort2'] = 'C=2'; $string['certaintyshort3'] = 'C=3'; $string['dontknow'] = 'No idea'; $string['foransweredquestions'] = 'Results for just the {$a} answered questions'; $string['forentirequiz'] = 'Results for the whole quiz ({$a} questions)'; $string['judgementok'] = 'OK'; $string['judgementsummary'] = 'Responses: {$a->responses}. Accuracy: {$a->fraction}. (Optimal range {$a->idealrangelow} to {$a->idealrangehigh}). You were {$a->judgement} using this certainty level.'; $string['howcertainareyou'] = 'Certainty{$a->help}: {$a->choices}'; $string['noquestions'] = 'No responses'; $string['overconfident'] = 'over-confident'; $string['pluginname'] = 'Deferred feedback with CBM'; $string['privacy:metadata'] = 'The Deferred feedback with CBM question behaviour plugin does not store any personal data.'; $string['slightlyoverconfident'] = 'a bit over-confident'; $string['slightlyunderconfident'] = 'a bit under-confident'; $string['underconfident'] = 'under-confident'; $string['weightx'] = 'Weight {$a}'; PK\ߩ!behaviour/deferredcbm/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage deferredcbm * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_deferredcbm'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->dependencies = [ 'qbehaviour_deferredfeedback' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\#behaviour/deferredcbm/behaviour.phpnu[. /** * Question behaviour that is like the deferred feedback model, but with * certainty based marking. That is, in addition to the other controls, there are * where the student can indicate how certain they are that their answer is right. * * @package qbehaviour * @subpackage deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../deferredfeedback/behaviour.php'); /** * Question behaviour for deferred feedback with certainty based marking. * * The student enters their response during the attempt, along with a certainty, * that is, how sure they are that they are right, and it is saved. Later, * when the whole attempt is finished, their answer is graded. Their degree * of certainty affects their score. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredcbm extends qbehaviour_deferredfeedback { const IS_ARCHETYPAL = true; public function get_min_fraction() { return question_cbm::adjust_fraction(0, question_cbm::HIGH); } public function get_max_fraction() { return question_cbm::adjust_fraction(1, question_cbm::HIGH); } public function get_expected_data() { if ($this->qa->get_state()->is_active()) { return array('certainty' => PARAM_INT); } return parent::get_expected_data(); } public function get_right_answer_summary() { $summary = parent::get_right_answer_summary(); return question_cbm::summary_with_certainty($summary, question_cbm::HIGH); } public function get_correct_response() { if ($this->qa->get_state()->is_active()) { return array('certainty' => question_cbm::HIGH); } return array(); } protected function get_our_resume_data() { $lastcertainty = $this->qa->get_last_behaviour_var('certainty'); if ($lastcertainty) { return array('-certainty' => $lastcertainty); } else { return array(); } } protected function is_same_response(question_attempt_step $pendingstep) { return parent::is_same_response($pendingstep) && $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty'); } protected function is_complete_response(question_attempt_step $pendingstep) { return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty'); } public function process_finish(question_attempt_pending_step $pendingstep) { $status = parent::process_finish($pendingstep); if ($status == question_attempt::KEEP) { $fraction = $pendingstep->get_fraction(); if ($this->qa->get_last_step()->has_behaviour_var('certainty')) { $certainty = $this->qa->get_last_step()->get_behaviour_var('certainty'); } else { $certainty = question_cbm::default_certainty(); $pendingstep->set_behaviour_var('_assumedcertainty', $certainty); } if (!is_null($fraction)) { $pendingstep->set_behaviour_var('_rawfraction', $fraction); $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty)); } $pendingstep->set_new_response_summary( question_cbm::summary_with_certainty($pendingstep->get_new_response_summary(), $this->qa->get_last_step()->get_behaviour_var('certainty'))); } return $status; } public function summarise_action(question_attempt_step $step) { $summary = parent::summarise_action($step); if ($step->has_behaviour_var('certainty')) { $summary = question_cbm::summary_with_certainty($summary, $step->get_behaviour_var('certainty')); } return $summary; } } PK\$ 2behaviour/deferredcbm/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_deferredcbm. * * @package qbehaviour_deferredcbm * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_deferredcbm\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_deferredcbm implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\ܓFb-b-'behaviour/deferredcbm/behaviourtype.phpnu[. /** * Question behaviour type for deferred feedback with CBM behaviour. * * @package qbehaviour_deferredcbm * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../deferredfeedback/behaviourtype.php'); /** * Question behaviour type information for deferred feedback with CBM behaviour. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredcbm_type extends qbehaviour_deferredfeedback_type { public function adjust_random_guess_score($fraction) { return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty()); } public function summarise_usage(question_usage_by_activity $quba, question_display_options $options) { global $OUTPUT; $summarydata = parent::summarise_usage($quba, $options); if ($options->marks < question_display_options::MARK_AND_MAX) { return $summarydata; } // Prepare accumulators to hold the data we are about to collect. $totalquestions = 0; $notansweredcount = 0; $notansweredweight = 0; $attemptcount = array( question_cbm::HIGH => 0, question_cbm::MED => 0, question_cbm::LOW => 0, ); $totalweight = array( question_cbm::HIGH => 0, question_cbm::MED => 0, question_cbm::LOW => 0, ); $totalrawscore = array( question_cbm::HIGH => 0, question_cbm::MED => 0, question_cbm::LOW => 0, ); $totalcbmscore = array( question_cbm::HIGH => 0, question_cbm::MED => 0, question_cbm::LOW => 0, ); // Loop through the data, and add it to the accumulators. foreach ($quba->get_attempt_iterator() as $qa) { if (strpos($qa->get_behaviour_name(), 'cbm') === false || $qa->get_max_mark() < 0.0000005) { continue; } $totalquestions += 1; $gradedstep = $qa->get_last_step_with_behaviour_var('_rawfraction'); if (!$gradedstep->has_behaviour_var('_rawfraction')) { $notansweredcount += 1; $notansweredweight += $qa->get_max_mark(); continue; } $certainty = $qa->get_last_behaviour_var('certainty'); if (is_null($certainty) || $certainty == -1) { // Certainty -1 has never been used in standard Moodle, but is // used in Tony-Gardiner Medwin's patches to mean 'No idea' which // we intend to implement: MDL-42077. In the mean time, avoid // errors for people who have used TGM's patches. $certainty = question_cbm::default_certainty(); } $attemptcount[$certainty] += 1; $totalweight[$certainty] += $qa->get_max_mark(); $totalrawscore[$certainty] += $qa->get_max_mark() * $gradedstep->get_behaviour_var('_rawfraction'); $totalcbmscore[$certainty] += $qa->get_mark(); } // Did we find any CBM questions? If not, just quit here. if ($totalquestions === 0) { return $summarydata; } // Hence compute some statistics. $totalquestions = $notansweredcount + array_sum($attemptcount); $grandtotalweight = $notansweredweight + array_sum($totalweight); $accuracy = array_sum($totalrawscore) / $grandtotalweight; $averagecbm = array_sum($totalcbmscore) / $grandtotalweight; $cbmbonus = $this->calculate_bonus($averagecbm, $accuracy); $accuracyandbonus = $accuracy + $cbmbonus; // Add a note to explain the max mark. $summarydata['qbehaviour_cbm_grade_explanation'] = array( 'title' => '', 'content' => html_writer::tag('i', get_string('cbmgradeexplanation', 'qbehaviour_deferredcbm')) . $OUTPUT->help_icon('cbmgrades', 'qbehaviour_deferredcbm'), ); // Now we can start generating some of the summary: overall values. $summarydata['qbehaviour_cbm_entire_quiz_heading'] = array( 'title' => '', 'content' => html_writer::tag('h3', get_string('forentirequiz', 'qbehaviour_deferredcbm', $totalquestions), array('class' => 'qbehaviour_deferredcbm_summary_heading')), ); $summarydata['qbehaviour_cbm_entire_quiz_cbm_average'] = array( 'title' => get_string('averagecbmmark', 'qbehaviour_deferredcbm'), 'content' => format_float($averagecbm, $options->markdp), ); $summarydata['qbehaviour_cbm_entire_quiz_accuracy'] = array( 'title' => get_string('accuracy', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($accuracy, 1), ); $summarydata['qbehaviour_cbm_entire_quiz_cbm_bonus'] = array( 'title' => get_string('cbmbonus', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($cbmbonus, 1), ); $summarydata['qbehaviour_cbm_entire_quiz_accuracy_and_bonus'] = array( 'title' => get_string('accuracyandbonus', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($accuracyandbonus, 1), ); if ($notansweredcount && array_sum($attemptcount) > 0) { $totalquestions = array_sum($attemptcount); $grandtotalweight = array_sum($totalweight); $accuracy = array_sum($totalrawscore) / $grandtotalweight; $averagecbm = array_sum($totalcbmscore) / $grandtotalweight; $cbmbonus = $this->calculate_bonus($averagecbm, $accuracy); $accuracyandbonus = $accuracy + $cbmbonus; $summarydata['qbehaviour_cbm_answered_quiz_heading'] = array( 'title' => '', 'content' => html_writer::tag('h3', get_string('foransweredquestions', 'qbehaviour_deferredcbm', $totalquestions), array('class' => 'qbehaviour_deferredcbm_summary_heading')), ); $summarydata['qbehaviour_cbm_answered_quiz_cbm_average'] = array( 'title' => get_string('averagecbmmark', 'qbehaviour_deferredcbm'), 'content' => format_float($averagecbm, $options->markdp), ); $summarydata['qbehaviour_cbm_answered_quiz_accuracy'] = array( 'title' => get_string('accuracy', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($accuracy, 1), ); $summarydata['qbehaviour_cbm_answered_quiz_cbm_bonus'] = array( 'title' => get_string('cbmbonus', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($cbmbonus, 1), ); $summarydata['qbehaviour_cbm_answered_quiz_accuracy_and_bonus'] = array( 'title' => get_string('accuracyandbonus', 'qbehaviour_deferredcbm'), 'content' => $this->format_probability($accuracyandbonus, 1), ); } // Now per-certainty level values. $summarydata['qbehaviour_cbm_judgement_heading'] = array( 'title' => '', 'content' => html_writer::tag('h3', get_string('breakdownbycertainty', 'qbehaviour_deferredcbm'), array('class' => 'qbehaviour_deferredcbm_summary_heading')), ); foreach ($attemptcount as $certainty => $count) { $key = 'qbehaviour_cbm_judgement' . $certainty; $title = question_cbm::get_short_string($certainty); if ($count == 0) { $summarydata[$key] = array( 'title' => $title, 'content' => get_string('noquestions', 'qbehaviour_deferredcbm'), ); continue; } $lowerlimit = question_cbm::optimal_probablility_low($certainty); $upperlimit = question_cbm::optimal_probablility_high($certainty); $fraction = $totalrawscore[$certainty] / $totalweight[$certainty]; $a = new stdClass(); $a->responses = $count; $a->idealrangelow = $this->format_probability($lowerlimit); $a->idealrangehigh = $this->format_probability($upperlimit); $a->fraction = html_writer::tag('span', $this->format_probability($fraction), array('class' => 'qbehaviour_deferredcbm_actual_percentage')); if ($fraction < $lowerlimit - 0.0000005) { if ((pow($fraction - $lowerlimit, 2) * $count) > 0.5) { // Rough indicator of significance: t > 1.5 or 1.8. $judgement = 'overconfident'; } else { $judgement = 'slightlyoverconfident'; } } else if ($fraction > $upperlimit + 0.0000005) { if ((pow($fraction - $upperlimit, 2) * $count) > 0.5) { $judgement = 'underconfident'; } else { $judgement = 'slightlyunderconfident'; } } else { $judgement = 'judgementok'; } $a->judgement = html_writer::tag('span', get_string($judgement, 'qbehaviour_deferredcbm'), array('class' => 'qbehaviour_deferredcbm_' . $judgement)); $summarydata[$key] = array( 'title' => $title, 'content' => get_string('judgementsummary', 'qbehaviour_deferredcbm', $a), ); } return $summarydata; } protected function format_probability($probability, $dp = 0) { return format_float($probability * 100, $dp) . '%'; } public function calculate_bonus($total, $accuracy) { $expectedforaccuracy = max( $accuracy * question_cbm::adjust_fraction(1, question_cbm::LOW) + (1 - $accuracy) * question_cbm::adjust_fraction(0, question_cbm::LOW), $accuracy * question_cbm::adjust_fraction(1, question_cbm::MED) + (1 - $accuracy) * question_cbm::adjust_fraction(0, question_cbm::MED), $accuracy * question_cbm::adjust_fraction(1, question_cbm::HIGH) + (1 - $accuracy) * question_cbm::adjust_fraction(0, question_cbm::HIGH) ); // The constant 0.1 here is determinted empirically from looking at lots // for CBM quiz results. See www.ucl.ac.uk/~ucgbarg/tea/IUPS_2013a.pdf. // It approximately maximises the reliability of accuracy + bonus. return 0.1 * ($total - $expectedforaccuracy); } } PK\y0g220behaviour/deferredcbm/tests/walkthrough_test.phpnu[. namespace qbehaviour_deferredcbm; use question_cbm; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the deferred feedback with certainty base marking behaviour. * * @package qbehaviour_deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_deferred_cbm_truefalse_high_certainty(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'deferredcbm', 2); // Verify. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($tf), $this->get_contains_tf_true_radio_expectation(true, false), $this->get_contains_tf_false_radio_expectation(true, false), $this->get_contains_cbm_radio_expectation(1, true, false), $this->get_contains_cbm_radio_expectation(2, true, false), $this->get_contains_cbm_radio_expectation(3, true, false), $this->get_does_not_contain_feedback_expectation()); // Process the data extracted for this question. $this->process_submission(array('answer' => 1, '-certainty' => 3)); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('answersaved', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(true, true), $this->get_contains_cbm_radio_expectation(3, true, true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Process the same data again, check it does not create a new step. $numsteps = $this->get_step_count(); $this->process_submission(array('answer' => 1, '-certainty' => 3)); $this->check_step_count($numsteps); // Process different data, check it creates a new step. $this->process_submission(array('answer' => 1, '-certainty' => 1)); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$complete); // Change back, check it creates a new step. $this->process_submission(array('answer' => 1, '-certainty' => 3)); $this->check_step_count($numsteps + 2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(6); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(false, true), $this->get_contains_cbm_radio_expectation(3, false, true), $this->get_contains_correct_expectation()); // Process a manual comment. $this->manual_grade('Not good enough!', 5, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrright); $this->check_current_mark(5); $this->check_current_output(new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Now change the correct answer to the question, and regrade. $tf->rightanswer = false; $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$mangrright); $this->check_current_mark(5); $autogradedstep = $this->get_step($this->get_step_count() - 2); $this->assertEqualsWithDelta(-6, $autogradedstep->get_fraction(), 0.0000001); } public function test_deferred_cbm_truefalse_low_certainty(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'deferredcbm', 2); // Verify. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_contains_cbm_radio_expectation(1, true, false), $this->get_does_not_contain_feedback_expectation()); // Submit ansewer with low certainty. $this->process_submission(array('answer' => 1, '-certainty' => 1)); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('answersaved', 'question'); $this->check_current_mark(null); $this->check_current_output($this->get_does_not_contain_correctness_expectation(), $this->get_contains_cbm_radio_expectation(1, true, true), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output($this->get_contains_correct_expectation(), $this->get_contains_cbm_radio_expectation(1, false, true)); $this->assertEquals(get_string('true', 'qtype_truefalse') . ' [' . question_cbm::get_short_string(question_cbm::LOW) . ']', $this->quba->get_response_summary($this->slot)); } public function test_deferred_cbm_truefalse_default_certainty(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'deferredcbm', 2); // Verify. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_contains_cbm_radio_expectation(1, true, false), $this->get_does_not_contain_feedback_expectation()); // Submit ansewer with low certainty and finish the attempt. $this->process_submission(array('answer' => 1)); $this->quba->finish_all_questions(); // Verify. $qa = $this->quba->get_question_attempt($this->slot); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output($this->get_contains_correct_expectation(), $this->get_contains_cbm_radio_expectation(1, false, false), new \question_pattern_expectation('/' . preg_quote( get_string('assumingcertainty', 'qbehaviour_deferredcbm', question_cbm::get_string( $qa->get_last_behaviour_var('_assumedcertainty'))), '/') . '/')); $this->assertEquals(get_string('true', 'qtype_truefalse'), $this->quba->get_response_summary($this->slot)); } public function test_deferredcbm_resume_multichoice_single(): void { // Create a multiple-choice question. $mc = \test_question_maker::make_a_multichoice_single_question(); // Attempt it getting it wrong. $this->start_attempt_at_question($mc, 'deferredcbm', 1); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; $this->process_submission(array('answer' => $wrongindex, '-certainty' => 2)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(-2); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_cbm_radio_expectation(2, false, true), $this->get_contains_incorrect_expectation()); $this->assertEquals('A [' . question_cbm::get_short_string(question_cbm::HIGH) . ']', $this->quba->get_right_answer_summary($this->slot)); $this->assertMatchesRegularExpression('/' . preg_quote($mc->questiontext, '/') . '/', $this->quba->get_question_summary($this->slot)); $this->assertMatchesRegularExpression( '/(B|C) \[' . preg_quote(question_cbm::get_short_string(question_cbm::MED), '/') . '\]/', $this->quba->get_response_summary($this->slot)); // Save the old attempt. $oldqa = $this->quba->get_question_attempt($this->slot); // Reinitialise. $this->setUp(); $this->quba->set_preferred_behaviour('deferredcbm'); $this->slot = $this->quba->add_question($mc, 1); $this->quba->start_question_based_on($this->slot, $oldqa); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('notchanged', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_contains_cbm_radio_expectation(2, true, true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_correctness_expectation()); $this->assertEquals('A [' . question_cbm::get_short_string(question_cbm::HIGH) . ']', $this->quba->get_right_answer_summary($this->slot)); $this->assertMatchesRegularExpression('/' . preg_quote($mc->questiontext, '/') . '/', $this->quba->get_question_summary($this->slot)); $this->assertNull($this->quba->get_response_summary($this->slot)); // Now get it right. $this->process_submission(array('answer' => $rightindex, '-certainty' => 3)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_cbm_radio_expectation(question_cbm::HIGH, false, true), $this->get_contains_correct_expectation()); $this->assertMatchesRegularExpression( '/(A) \[' . preg_quote(question_cbm::get_short_string(question_cbm::HIGH), '/') . '\]/', $this->quba->get_response_summary($this->slot)); } public function test_deferred_cbm_truefalse_no_certainty_feedback_when_not_answered(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'deferredcbm', 2); // Verify. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_contains_cbm_radio_expectation(1, true, false), $this->get_does_not_contain_feedback_expectation()); // Finish without answering. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( new \question_no_pattern_expectation('/class=\"im-feedback/')); } } PK\ 1behaviour/deferredcbm/tests/question_cbm_test.phpnu[. namespace qbehaviour_deferredcbm; use question_cbm; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); /** * Unit tests for the deferred feedback with certainty base marking behaviour. * * @package qbehaviour_deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class question_cbm_test extends \basic_testcase { public function test_adjust_fraction(): void { $this->assertEqualsWithDelta( 1, question_cbm::adjust_fraction( 1, question_cbm::LOW), 0.0000001); $this->assertEqualsWithDelta( 2, question_cbm::adjust_fraction( 1, question_cbm::MED), 0.0000001); $this->assertEqualsWithDelta( 3, question_cbm::adjust_fraction( 1, question_cbm::HIGH), 0.0000001); $this->assertEqualsWithDelta( 0, question_cbm::adjust_fraction( 0, question_cbm::LOW), 0.0000001); $this->assertEqualsWithDelta(-2, question_cbm::adjust_fraction( 0, question_cbm::MED), 0.0000001); $this->assertEqualsWithDelta(-6, question_cbm::adjust_fraction( 0, question_cbm::HIGH), 0.0000001); $this->assertEqualsWithDelta( 0.5, question_cbm::adjust_fraction( 0.5, question_cbm::LOW), 0.0000001); $this->assertEqualsWithDelta( 1, question_cbm::adjust_fraction( 0.5, question_cbm::MED), 0.0000001); $this->assertEqualsWithDelta( 1.5, question_cbm::adjust_fraction( 0.5, question_cbm::HIGH), 0.0000001); $this->assertEqualsWithDelta( 0, question_cbm::adjust_fraction(-0.25, question_cbm::LOW), 0.0000001); $this->assertEqualsWithDelta(-2, question_cbm::adjust_fraction(-0.25, question_cbm::MED), 0.0000001); $this->assertEqualsWithDelta(-6, question_cbm::adjust_fraction(-0.25, question_cbm::HIGH), 0.0000001); } } PK\΋3behaviour/deferredcbm/tests/behaviour_type_test.phpnu[. namespace qbehaviour_deferredcbm; use question_display_options; use question_engine; use question_testcase; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the deferred feedback with certainty base marking behaviour. * * @package qbehaviour_deferredcbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \qbehaviour_walkthrough_test_base { /** @var qbehaviour_deferredcbm_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('deferredcbm'); } public function test_is_archetypal(): void { $this->assertTrue($this->behaviourtype->is_archetypal()); } public function test_get_unused_display_options(): void { $this->assertEquals(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'), $this->behaviourtype->get_unused_display_options()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } public function test_summarise_usage_max_mark_1(): void { // Create a usage comprising 3 true-false questions. $this->quba->set_preferred_behaviour('deferredcbm'); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 3); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 3); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 3); $this->quba->start_all_questions(); // Process responses right, high certainty; right, med certainty; wrong, med certainty. $this->quba->process_action(1, array('answer' => 1, '-certainty' => 3)); $this->quba->process_action(2, array('answer' => 1, '-certainty' => 2)); $this->quba->process_action(3, array('answer' => 0, '-certainty' => 2)); $this->quba->finish_all_questions(); // Get the summary. $summarydata = $this->quba->get_summary_information(new question_display_options()); // Verify. $this->assertStringContainsString(get_string('breakdownbycertainty', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement_heading']['content']); $this->assertStringContainsString('100%', $summarydata['qbehaviour_cbm_judgement3']['content']); $this->assertStringContainsString(get_string('judgementok', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement3']['content']); $this->assertStringContainsString('50%', $summarydata['qbehaviour_cbm_judgement2']['content']); $this->assertStringContainsString(get_string('slightlyoverconfident', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement2']['content']); $this->assertStringContainsString(get_string('noquestions', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement1']['content']); } public function test_summarise_usage_max_mark_3(): void { // Create a usage comprising 3 true-false questions. $this->quba->set_preferred_behaviour('deferredcbm'); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 1); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 1); $this->quba->add_question(\test_question_maker::make_question('truefalse', 'true'), 1); $this->quba->start_all_questions(); // Process responses right, high certainty; right, med certainty; wrong, med certainty. $this->quba->process_action(1, array('answer' => 1, '-certainty' => 3)); $this->quba->process_action(2, array('answer' => 1, '-certainty' => 2)); $this->quba->process_action(3, array('answer' => 0, '-certainty' => 2)); $this->quba->finish_all_questions(); // Get the summary. $summarydata = $this->quba->get_summary_information(new question_display_options()); // Verify. $this->assertStringContainsString(get_string('breakdownbycertainty', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement_heading']['content']); $this->assertStringContainsString('100%', $summarydata['qbehaviour_cbm_judgement3']['content']); $this->assertStringContainsString(get_string('judgementok', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement3']['content']); $this->assertStringContainsString('50%', $summarydata['qbehaviour_cbm_judgement2']['content']); $this->assertStringContainsString(get_string('slightlyoverconfident', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement2']['content']); $this->assertStringContainsString(get_string('noquestions', 'qbehaviour_deferredcbm'), $summarydata['qbehaviour_cbm_judgement1']['content']); } /** * Test that CBM summary is not shown when there are no CBM questions in the attempt. * @covers \qbehaviour_deferredcbm_type::summarise_usage */ public function test_summarise_usage_no_cbm_questions(): void { // Create a usage comprising 2 essay questions. $this->quba->set_preferred_behaviour('deferredcbm'); $this->quba->add_question(\test_question_maker::make_an_essay_question(), 1); $this->quba->add_question(\test_question_maker::make_an_essay_question(), 1); $this->quba->start_all_questions(); // Process responses right, high certainty; right, med certainty; wrong, med certainty. $this->quba->process_action(1, ['answer' => 'essay answer1', 'answerformat' => FORMAT_PLAIN]); $this->quba->process_action(2, ['answer' => 'essay answer2', 'answerformat' => FORMAT_PLAIN]); $this->quba->finish_all_questions(); // Get the summary. $summarydata = $this->quba->get_summary_information(new question_display_options()); // Verify that there is no CBM summary. $this->assertArrayNotHasKey('qbehaviour_cbm_entire_quiz_heading', $summarydata); } public function test_calculate_bonus(): void { $this->assertEqualsWithDelta(0.05, $this->behaviourtype->calculate_bonus(1, 1 / 2), question_testcase::GRADE_DELTA); $this->assertEqualsWithDelta(-0.01, $this->behaviourtype->calculate_bonus(2, 9 / 10), question_testcase::GRADE_DELTA); $this->assertEqualsWithDelta(0, $this->behaviourtype->calculate_bonus(3, 1), question_testcase::GRADE_DELTA); } } PK\FS=rr#behaviour/immediatecbm/renderer.phpnu[. /** * Defines the renderer for the immediate feedback with CBM behaviour. * * @package qbehaviour * @subpackage immediatecbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../deferredcbm/renderer.php'); /** * Renderer for outputting parts of a question belonging to the immediate * feedback with CBM behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_immediatecbm_renderer extends qbehaviour_deferredcbm_renderer { public function controls(question_attempt $qa, question_display_options $options) { $output = parent::controls($qa, $options); if ($qa->get_state() == question_state::$invalid && !$qa->get_last_step()->has_behaviour_var('certainty')) { $output .= html_writer::tag('div', get_string('pleaseselectacertainty', 'qbehaviour_immediatecbm'), array('class' => 'validationerror')); } $output .= $this->submit_button($qa, $options); return $output; } } PK\1z:behaviour/immediatecbm/lang/en/qbehaviour_immediatecbm.phpnu[. /** * Strings for component 'qbehaviour_immediatecbm', language 'en'. * * @package qbehaviour * @subpackage immediatecbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pleaseselectacertainty'] = 'Please select a certainty.'; $string['pluginname'] = 'Immediate feedback with CBM'; $string['privacy:metadata'] = 'The Immediate feedback with CBM question behaviour plugin does not store any personal data.'; PK\,L"behaviour/immediatecbm/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage immediatecbm * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_immediatecbm'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->dependencies = [ 'qbehaviour_immediatefeedback' => 2025040800, 'qbehaviour_deferredcbm' => 2025040800, ]; $plugin->maturity = MATURITY_STABLE; PK\U3&$behaviour/immediatecbm/behaviour.phpnu[. /** * Question behaviour where the student can submit questions one at a * time for immediate feedback, with certainty based marking. * * @package qbehaviour * @subpackage immediatecbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../immediatefeedback/behaviour.php'); /** * Question behaviour for immediate feedback with CBM. * * Each question has a submit button next to it along with some radio buttons * to input a certainty, that is, how sure they are that they are right. * The student can submit their answer at any time for immediate feedback. * Once the qustion is submitted, it is not possible for the student to change * their answer any more. The student's degree of certainty affects their score. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_immediatecbm extends qbehaviour_immediatefeedback { public function get_min_fraction() { return question_cbm::adjust_fraction(parent::get_min_fraction(), question_cbm::HIGH); } public function get_max_fraction() { return question_cbm::adjust_fraction(parent::get_max_fraction(), question_cbm::HIGH); } public function get_expected_data() { if ($this->qa->get_state()->is_active()) { return array( 'submit' => PARAM_BOOL, 'certainty' => PARAM_INT, ); } return parent::get_expected_data(); } public function get_right_answer_summary() { $summary = parent::get_right_answer_summary(); return question_cbm::summary_with_certainty($summary, question_cbm::HIGH); } public function get_correct_response() { if ($this->qa->get_state()->is_active()) { return array('certainty' => question_cbm::HIGH); } return array(); } protected function get_our_resume_data() { $lastcertainty = $this->qa->get_last_behaviour_var('certainty'); if ($lastcertainty) { return array('-certainty' => $lastcertainty); } else { return array(); } } protected function is_same_response(question_attempt_step $pendingstep) { return parent::is_same_response($pendingstep) && $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty'); } protected function is_complete_response(question_attempt_step $pendingstep) { return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty'); } public function process_submit(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } if (!$this->question->is_gradable_response($pendingstep->get_qt_data()) || !$pendingstep->has_behaviour_var('certainty')) { $pendingstep->set_state(question_state::$invalid); return question_attempt::KEEP; } return $this->do_grading($pendingstep, $pendingstep); } public function process_finish(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } $laststep = $this->qa->get_last_step(); return $this->do_grading($laststep, $pendingstep); } protected function do_grading(question_attempt_step $responsesstep, question_attempt_pending_step $pendingstep) { if (!$this->question->is_gradable_response($responsesstep->get_qt_data())) { $pendingstep->set_state(question_state::$gaveup); } else { $response = $responsesstep->get_qt_data(); list($fraction, $state) = $this->question->grade_response($response); if ($responsesstep->has_behaviour_var('certainty')) { $certainty = $responsesstep->get_behaviour_var('certainty'); } else { $certainty = question_cbm::default_certainty(); $pendingstep->set_behaviour_var('_assumedcertainty', $certainty); } $pendingstep->set_behaviour_var('_rawfraction', $fraction); $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty)); $pendingstep->set_state($state); $pendingstep->set_new_response_summary(question_cbm::summary_with_certainty( $this->question->summarise_response($response), $responsesstep->get_behaviour_var('certainty'))); } return question_attempt::KEEP; } public function summarise_action(question_attempt_step $step) { $summary = parent::summarise_action($step); if ($step->has_behaviour_var('certainty')) { $summary = question_cbm::summary_with_certainty($summary, $step->get_behaviour_var('certainty')); } return $summary; } } PK\""3behaviour/immediatecbm/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_immediatecbm. * * @package qbehaviour_immediatecbm * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_immediatecbm\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_immediatecbm implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\ɵ(behaviour/immediatecbm/behaviourtype.phpnu[. /** * Question behaviour type for immediate feedback with CBM behaviour. * * @package qbehaviour_adaptive * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../deferredcbm/behaviourtype.php'); /** * Question behaviour type information for immediate feedback with CBM. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_immediatecbm_type extends qbehaviour_deferredcbm_type { public function get_unused_display_options() { return array(); } public function can_questions_finish_during_the_attempt() { return true; } } PK\[.NJF3F31behaviour/immediatecbm/tests/walkthrough_test.phpnu[. namespace qbehaviour_immediatecbm; use question_cbm; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the immediate cbm behaviour. * * @package qbehaviour_immediatecbm * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_immediatecbm_feedback_multichoice_right(): void { // Create a true-false question with correct answer true. $mc = \test_question_maker::make_a_multichoice_single_question(); $this->start_attempt_at_question($mc, 'immediatecbm'); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation()); $this->assertEquals('A [' . question_cbm::get_short_string(question_cbm::HIGH) . ']', $this->quba->get_right_answer_summary($this->slot)); $this->assertMatchesRegularExpression('/' . preg_quote($mc->questiontext, '/') . '/', $this->quba->get_question_summary($this->slot)); $this->assertNull($this->quba->get_response_summary($this->slot)); // Save the wrong answer. $this->process_submission(array('answer' => $wrongindex, '-certainty' => 1)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Submit the right answer. $this->process_submission( array('answer' => $rightindex, '-certainty' => 2, '-submit' => 1)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_correct_expectation()); $this->assertEquals('A [' . question_cbm::get_short_string(2) . ']', $this->quba->get_response_summary($this->slot)); $numsteps = $this->get_step_count(); // Finish the attempt - should not need to add a new state. $this->quba->finish_all_questions(); // Verify. $this->assertEquals($numsteps, $this->get_step_count()); $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), $this->get_contains_correct_expectation()); // Process a manual comment. $this->manual_grade('Not good enough!', 0.5, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.5); $this->check_current_output( $this->get_contains_partcorrect_expectation(), new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Now change the correct answer to the question, and regrade. $mc->answers[13]->fraction = -0.33333333; $mc->answers[15]->fraction = 1; $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.5); $this->check_current_output( $this->get_contains_partcorrect_expectation()); $autogradedstep = $this->get_step($this->get_step_count() - 2); $this->assertEqualsWithDelta($autogradedstep->get_fraction(), -2, 0.0000001); } public function test_immediatecbm_feedback_multichoice_try_to_submit_blank(): void { // Create a true-false question with correct answer true. $mc = \test_question_maker::make_a_multichoice_single_question(); $this->start_attempt_at_question($mc, 'immediatecbm'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation()); // Submit nothing. $this->process_submission(array('-submit' => 1)); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_validation_error_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation(0, false, false), $this->get_contains_mc_radio_expectation(1, false, false), $this->get_contains_mc_radio_expectation(2, false, false)); // Process a manual comment. $this->manual_grade('Not good enough!', 0.5, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.5); $this->check_current_output( $this->get_contains_partcorrect_expectation(), new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); } public function test_immediatecbm_feedback_shortanswer_try_to_submit_no_certainty(): void { // Create a short answer question with correct answer true. $sa = \test_question_maker::make_question('shortanswer'); $this->start_attempt_at_question($sa, 'immediatecbm'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation()); // Submit with certainty missing. $this->process_submission(array('-submit' => 1, 'answer' => 'frog')); // Verify. $this->check_current_state(question_state::$invalid); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation(), $this->get_contains_validation_error_expectation()); // Now get it right. $this->process_submission(array('-submit' => 1, 'answer' => 'frog', '-certainty' => 3)); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_does_not_contain_validation_error_expectation()); } public function test_immediatecbm_feedback_multichoice_wrong_on_finish(): void { // Create a true-false question with correct answer true. $mc = \test_question_maker::make_a_multichoice_single_question(); $this->start_attempt_at_question($mc, 'immediatecbm'); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_feedback_expectation()); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; // Save the wrong answer. $this->process_submission(array('answer' => $wrongindex, '-certainty' => 3)); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), $this->get_contains_submit_button_expectation(true), $this->get_does_not_contain_correctness_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(-6); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), $this->get_contains_incorrect_expectation()); } public function test_immediatecbm_cbm_truefalse_no_certainty_feedback_when_not_answered(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'immediatecbm', 2); // Verify. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_contains_cbm_radio_expectation(1, true, false), $this->get_does_not_contain_feedback_expectation()); // Finish without answering. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( new \question_no_pattern_expectation('/class=\"im-feedback/')); } } PK\D4behaviour/immediatecbm/tests/behaviour_type_test.phpnu[. namespace qbehaviour_immediatecbm; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the immediate feedback with CBM behaviour type class. * * @package qbehaviour_immediatecbm * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \qbehaviour_walkthrough_test_base { /** @var qbehaviour_immediatecbm_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('immediatecbm'); } public function test_is_archetypal(): void { $this->assertTrue($this->behaviourtype->is_archetypal()); } public function test_get_unused_display_options(): void { $this->assertEquals(array(), $this->behaviourtype->get_unused_display_options()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } } PK\Hy[@hhbehaviour/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes for question behaviour plugins. === 4.0 === 1) The major question bank changes should not affect behaviour plugins. The navigation changes may affect Behat tests. If you encounter this, the best way to fix it is to use the new navigation steps in MDL-74130. === 3.10 === 1) The slot parameter of method M.core_question_engine.init_submit_button now removed. The method will get the unique id by using the 'Check' button element. === 3.1 === 1) The standard behaviours that use a 'Check' button have all been changed so that they only show the button when the question is active. Your behaviour may interit this behaviour, because the change was made in the base class, and this is probably good for consistency. However, if your question behaviour uses the Check button, your probably want to test it carefully, and you will probably have to update your unit tests. See MDL-53304 for more details. === 2.9 === 1) There are new methods question_behaviour::can_finish_during_attempt and question_behaviour_type::can_finish_during_attempt. These methods both return false by default. You should override it if, with your behaviour, questions may finish just through the student interacting with them (e.g. by clicking the Check button within the question). The behaviour type method answers the the question for this behaviour in general, without reference to a specific question. The method on the behaviour class answers the question for a specific attempt at a specific question. === 2.7 === 1) question_behaviour_type has a new method allows_multiple_submitted_responses which defaults to false but should return true if this question behaviour accepts multiple submissions of responses within one attempt eg. multiple tries for the interactive or adaptive question behaviours. question_behaviour has a new method step_has_a_submitted_response($step). For question behaviours where it is not only the final response that is submitted by the student, you need to override this method to return true for other steps where a student has submitted a response. See question_behaviour_with_multiple_tries::step_has_a_submitted_response($step) for example. This method only needs to be overriden if you are returning true from allows_multiple_response_submissions. === 2.6 === 1) Legacy required_question_definition_type no longer supported. (See 2.2 point 2) below.) 2) Behaviours now have to define an extra class class qbehaviour_mybehaviour_type extends question_behaviour_type { This class returns information about the type of behaviour, as opposed to the qbehaviour_mybehaviour class which controls a particular question_attempt. That is like the difference between the qtype_mytype and the qtype_mytype_question classes. Practically, what this means is that any of the methods that used to be static methods of qbehaviour_mybehaviour class are now normal instance methods of the qbehaviour_mybehaviour_type class. Specifically. 2.5 / qbehaviour_mybehaviour -> 2.6 / qbehaviour_mybehaviour_type IS_ARCHETYPAL -> is_archetypal() adjust_random_guess_score() -> adjust_random_guess_score() get_unused_display_options() -> get_unused_display_options() 3) The static method is_manual_grade_in_range has moved from the question_behaviour class to the question_engine class. 4) Behaviours can now control how the marks information is displayed in the grey info area to the left of the question. There is a new method mark_summary that you can override, although the default implementation is fine in most cases. it uses the marked_out_of_max and mark_out_of_max methods as appropriate, so you may just wish to override those. === 2.3 === 1) This plugin type now supports cron in the standard way. If required, Create a lib.php file containing function qbehaviour_mypluginname_cron() {}; === 2.2 === 1) The old public static function get_required_behaviours() method is no more. Instead use the ->dependencies facility in version.php. E.g. $plugin->dependencies = array( 'qbehaviour_immediatefeedback' => 2011102700, 'qbehaviour_deferredcbm' => 2011102700 ); 2) The old required_question_definition_type method has been replaced by a new is_compatible_question method. You should change your behaviour to override the new method, not the old one. This change has been implemented in a backwards-compatible way, so behaviours will not break. PK\jbehaviour/missing/renderer.phpnu[. /** * Defines the renderer for when the actual behaviour used is not available. * * @package qbehaviour * @subpackage missing * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer for outputting parts of a question when the actual behaviour * used is not available. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_missing_renderer extends qbehaviour_renderer { public function controls(question_attempt $qa, question_display_options $options) { return html_writer::tag('div', get_string('questionusedunknownmodel', 'qbehaviour_missing'), array('class' => 'warning')); } }PK\X40behaviour/missing/lang/en/qbehaviour_missing.phpnu[. /** * Strings for component 'qbehaviour_missing', language 'en'. * * @package qbehaviour * @subpackage missing * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Missing behaviour'; $string['questionusedunknownmodel'] = 'This question was attempted with a behaviour that is not currently available. The question is being displayed as well as possible, but some parts may be missing or wrong.'; $string['privacy:metadata'] = 'The Missing question behaviour plugin does not store any personal data.'; PK\֎ SSbehaviour/missing/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage missing * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_missing'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\A behaviour/missing/behaviour.phpnu[. /** * Fake question behaviour that is used when the actual behaviour was not * available. * * @package qbehaviour * @subpackage missing * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Fake question behaviour that is used when the actual behaviour * is not available. * * Imagine, for example, that a quiz attempt has been restored from another * Moodle site with more behaviours installed, or a behaviour * that used to be available in this site has been uninstalled. Obviously all we * can do is have some code to prevent fatal errors. * * The approach we take is: The rendering code is still implemented, as far as * possible. A warning is shown that behaviour specific bits may be missing. * Any attempt to process anything causes an exception to be thrown. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_missing extends question_behaviour { public function is_compatible_question(question_definition $question) { return true; } public function summarise_action(question_attempt_step $step) { return ''; } public function init_first_step(question_attempt_step $step, $variant) { throw new coding_exception('The behaviour used for this question is not available. ' . 'No processing is possible.'); } public function process_action(question_attempt_pending_step $pendingstep) { throw new coding_exception('The behaviour used for this question is not available. ' . 'No processing is possible.'); } public function get_min_fraction() { throw new coding_exception('The behaviour used for this question is not available. ' . 'No processing is possible.'); } public function get_max_fraction() { throw new coding_exception('The behaviour used for this question is not available. ' . 'No processing is possible.'); } } PK\k. /** * Privacy Subsystem implementation for qbehaviour_missing. * * @package qbehaviour_missing * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_missing\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_missing implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\0#behaviour/missing/behaviourtype.phpnu[. /** * Fake question behaviour type that is used when the actual behaviour is not * available. * * @package qbehaviour_missing * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Fake question behaviour type information that is used when the actual * behaviour is not available. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_missing_type extends question_behaviour_type { } PK\|]991behaviour/missing/tests/missingbehaviour_test.phpnu[. namespace qbehaviour_missing; use qbehaviour_missing; use question_attempt; use question_attempt_pending_step; use question_attempt_step; use question_bank; use question_display_options; use question_state; use question_test_recordset; use question_usage_null_observer; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); require_once(__DIR__ . '/../behaviour.php'); /** * Unit tests for the 'missing' behaviour. * * @package qbehaviour_missing * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class missingbehaviour_test extends \advanced_testcase { public function test_missing_cannot_start(): void { $qa = new question_attempt(\test_question_maker::make_question('truefalse', 'true'), 0); $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); $this->expectException(\moodle_exception::class); $behaviour->init_first_step(new question_attempt_step(array()), 1); } public function test_missing_cannot_process(): void { $qa = new question_attempt(\test_question_maker::make_question('truefalse', 'true'), 0); $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); $this->expectException(\moodle_exception::class); $behaviour->process_action(new question_attempt_pending_step(array())); } public function test_missing_cannot_get_min_fraction(): void { $qa = new question_attempt(\test_question_maker::make_question('truefalse', 'true'), 0); $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); $this->expectException(\moodle_exception::class); $behaviour->get_min_fraction(); } public function test_missing_cannot_get_max_fraction(): void { $qa = new question_attempt(\test_question_maker::make_question('truefalse', 'true'), 0); $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); $this->expectException(\moodle_exception::class); $behaviour->get_max_fraction(); } public function test_render_missing(): void { $records = new question_test_recordset(array( array('questionattemptid', 'contextid', 'questionusageid', 'slot', 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', 'attemptstepid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value'), array(1, 123, 1, 1, 'strangeunknown', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, '_order', '1,2,3'), array(1, 123, 1, 1, 'strangeunknown', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1, '-submit', '1'), array(1, 123, 1, 1, 'strangeunknown', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1, 'choice0', '1'), )); $question = \test_question_maker::make_question('truefalse', 'true'); $question->id = -1; question_bank::start_unit_test(); question_bank::load_test_question_data($question); $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback'); question_bank::end_unit_test(); $this->assertEquals(2, $qa->get_num_steps()); $step = $qa->get_step(0); $this->assertEquals(question_state::$todo, $step->get_state()); $this->assertNull($step->get_fraction()); $this->assertEquals(1256233700, $step->get_timecreated()); $this->assertEquals(1, $step->get_user_id()); $this->assertEquals(array('_order' => '1,2,3'), $step->get_all_data()); $step = $qa->get_step(1); $this->assertEquals(question_state::$complete, $step->get_state()); $this->assertEquals(0.5, $step->get_fraction()); $this->assertEquals(1256233705, $step->get_timecreated()); $this->assertEquals(1, $step->get_user_id()); $this->assertEquals(array('-submit' => '1', 'choice0' => '1'), $step->get_all_data()); $output = $qa->render(new question_display_options(), '1'); $this->assertMatchesRegularExpression('/' . preg_quote($qa->get_question(false)->questiontext, '/') . '/', $output); $this->assertMatchesRegularExpression('/' . preg_quote( get_string('questionusedunknownmodel', 'qbehaviour_missing'), '/') . '/', $output); $this->assertTag(array('tag'=>'div', 'attributes'=>array('class'=>'warning')), $output); } } PK\;XX/behaviour/missing/tests/behaviour_type_test.phpnu[. namespace qbehaviour_missing; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the missing behaviour type stand-in class. * * @package qbehaviour_missing * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \basic_testcase { /** @var qbehaviour_missing_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('missing'); } public function test_is_archetypal(): void { $this->assertFalse($this->behaviourtype->is_archetypal()); } public function test_get_unused_display_options(): void { $this->assertEquals(array(), $this->behaviourtype->get_unused_display_options()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } } PK\3''behaviour/deferredfeedback/renderer.phpnu[. /** * Defines the renderer for the deferred feedback behaviour. * * @package qbehaviour * @subpackage deferredfeedback * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer for outputting parts of a question belonging to the deferred * feedback behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredfeedback_renderer extends qbehaviour_renderer { } PK\R8FFBbehaviour/deferredfeedback/lang/en/qbehaviour_deferredfeedback.phpnu[. /** * Strings for component 'qbehaviour_deferredfeedback', language 'en'. * * @package qbehaviour * @subpackage deferredfeedback * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Deferred feedback'; $string['privacy:metadata'] = 'The Deferred feedback behaviour plugin does not store any personal data.'; PK\Zee&behaviour/deferredfeedback/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage deferredfeedback * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_deferredfeedback'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\)A  (behaviour/deferredfeedback/behaviour.phpnu[. /** * Question behaviour for the case when the student's answer is just * saved until they submit the whole attempt, and then it is graded. * * @package qbehaviour * @subpackage deferredfeedback * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour for deferred feedback. * * The student enters their response during the attempt, and it is saved. Later, * when the whole attempt is finished, their answer is graded. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredfeedback extends question_behaviour_with_save { public function is_compatible_question(question_definition $question) { return $question instanceof question_automatically_gradable; } public function get_min_fraction() { return $this->question->get_min_fraction(); } public function get_right_answer_summary() { return $this->question->get_right_answer_summary(); } public function process_action(question_attempt_pending_step $pendingstep) { if ($pendingstep->has_behaviour_var('comment')) { return $this->process_comment($pendingstep); } else if ($pendingstep->has_behaviour_var('finish')) { return $this->process_finish($pendingstep); } else { return $this->process_save($pendingstep); } } /* * Like the parent method, except that when a respones is gradable, but not * completely, we move it to the invalid state. * * TODO refactor, to remove the duplication. */ public function process_save(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } else if (!$this->qa->get_state()->is_active()) { throw new coding_exception('Question is not active, cannot process_actions.'); } if ($this->is_same_response($pendingstep)) { return question_attempt::DISCARD; } if ($this->is_complete_response($pendingstep)) { $pendingstep->set_state(question_state::$complete); } else if ($this->question->is_gradable_response($pendingstep->get_qt_data())) { $pendingstep->set_state(question_state::$invalid); } else { $pendingstep->set_state(question_state::$todo); } return question_attempt::KEEP; } public function summarise_action(question_attempt_step $step) { if ($step->has_behaviour_var('comment')) { return $this->summarise_manual_comment($step); } else if ($step->has_behaviour_var('finish')) { return $this->summarise_finish($step); } else { return $this->summarise_save($step); } } public function process_finish(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } $response = $this->qa->get_last_step()->get_qt_data(); if (!$this->question->is_gradable_response($response)) { $pendingstep->set_state(question_state::$gaveup); } else { list($fraction, $state) = $this->question->grade_response($response); $pendingstep->set_fraction($fraction); $pendingstep->set_state($state); } $pendingstep->set_new_response_summary($this->question->summarise_response($response)); return question_attempt::KEEP; } } PK\ 2227behaviour/deferredfeedback/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_deferredfeedback. * * @package qbehaviour_deferredfeedback * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_deferredfeedback\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_deferredfeedback implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\,behaviour/deferredfeedback/behaviourtype.phpnu[. /** * Question behaviour type for deferred feedback behaviour. * * @package qbehaviour_deferredfeedback * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour type information for deferred feedback behaviour. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_deferredfeedback_type extends question_behaviour_type { public function is_archetypal() { return true; } public function get_unused_display_options() { return array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'); } } PK\Pߏp4p45behaviour/deferredfeedback/tests/walkthrough_test.phpnu[. namespace qbehaviour_deferredfeedback; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the deferred feedback behaviour. * * @package qbehaviour_deferredfeedback * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_deferredfeedback_feedback_truefalse(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'deferredfeedback', 2); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($tf), $this->get_does_not_contain_feedback_expectation()); $this->assertEquals(get_string('true', 'qtype_truefalse'), $this->quba->get_right_answer_summary($this->slot)); $this->assertMatchesRegularExpression('/' . preg_quote($tf->questiontext, '/') . '/', $this->quba->get_question_summary($this->slot)); $this->assertNull($this->quba->get_response_summary($this->slot)); // Process a true answer and check the expected result. $this->process_submission(array('answer' => 1)); $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('answersaved', 'question'); $this->check_current_mark(null); $this->check_current_output($this->get_contains_tf_true_radio_expectation(true, true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Process the same data again, check it does not create a new step. $numsteps = $this->get_step_count(); $this->process_submission(array('answer' => 1)); $this->check_step_count($numsteps); // Process different data, check it creates a new step. $this->process_submission(array('answer' => 0)); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$complete); // Change back, check it creates a new step. $this->process_submission(array('answer' => 1)); $this->check_step_count($numsteps + 2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(2); $this->check_current_output($this->get_contains_correct_expectation(), $this->get_contains_tf_true_radio_expectation(false, true), new \question_pattern_expectation('/class="r0 correct"/')); $this->assertEquals(get_string('true', 'qtype_truefalse'), $this->quba->get_response_summary($this->slot)); // Process a manual comment. $this->manual_grade('Not good enough!', 1, FORMAT_HTML); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(1); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Now change the correct answer to the question, and regrade. $tf->rightanswer = false; $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(1); $autogradedstep = $this->get_step($this->get_step_count() - 2); $this->assertEqualsWithDelta($autogradedstep->get_fraction(), 0, 0.0000001); } public function test_deferredfeedback_feedback_multichoice_single(): void { // Create a true-false question with correct answer true. $mc = \test_question_maker::make_a_multichoice_single_question(); $this->start_attempt_at_question($mc, 'deferredfeedback', 3); // Start a deferred feedback attempt and add the question to it. $rightindex = $this->get_mc_right_answer_index($mc); $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($mc), $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_does_not_contain_feedback_expectation()); // Process the data extracted for this question. $this->process_submission(array('answer' => $rightindex)); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('answersaved', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, true, true), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_correct_expectation()); // Now change the correct answer to the question, and regrade. $mc->answers[13]->fraction = -0.33333333; $mc->answers[14]->fraction = 1; $this->quba->regrade_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(-1); $this->check_current_output( $this->get_contains_incorrect_expectation()); } public function test_deferredfeedback_resume_multichoice_single(): void { // Create a multiple-choice question. $mc = \test_question_maker::make_a_multichoice_single_question(); // Attempt it getting it wrong. $this->start_attempt_at_question($mc, 'deferredfeedback', 3); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->process_submission(array('answer' => $wrongindex)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(-1); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_incorrect_expectation()); // Save the old attempt. $oldqa = $this->quba->get_question_attempt($this->slot); // Reinitialise. $this->setUp(); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($mc, 3); $this->quba->start_question_based_on($this->slot, $oldqa); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('notchanged', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_correctness_expectation()); // Now get it right. $this->process_submission(array('answer' => $rightindex)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_correct_expectation()); } public function test_deferredfeedback_resume_multichoice_single_emptyanswer_first(): void { // Create a multiple-choice question. $mc = \test_question_maker::make_a_multichoice_single_question(); // Attempt it and submit empty. $this->start_attempt_at_question($mc, 'deferredfeedback', 3); $rightindex = $this->get_mc_right_answer_index($mc); $wrongindex = ($rightindex + 1) % 3; $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->process_submission(array('-submit' => 1)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation(0, false, false), $this->get_contains_mc_radio_expectation(1, false, false), $this->get_contains_mc_radio_expectation(2, false, false), $this->get_contains_general_feedback_expectation($mc)); // Save the old attempt. $oldqa = $this->quba->get_question_attempt($this->slot); // Reinitialise. $this->setUp(); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($mc, 3); $this->quba->start_question_based_on($this->slot, $oldqa); // Verify. $this->check_current_state(question_state::$todo); $this->check_output_contains_lang_string('notyetanswered', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation(0, true, false), $this->get_contains_mc_radio_expectation(1, true, false), $this->get_contains_mc_radio_expectation(2, true, false), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_correctness_expectation()); // Now get it wrong. $this->process_submission(array('answer' => $wrongindex)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedwrong); $this->check_current_mark(-1); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, false, true), $this->get_contains_incorrect_expectation()); // Save the old attempt. $oldqa = $this->quba->get_question_attempt($this->slot); // Reinitialise. $this->setUp(); $this->quba->set_preferred_behaviour('deferredfeedback'); $this->slot = $this->quba->add_question($mc, 3); $this->quba->start_question_based_on($this->slot, $oldqa); // Verify. $this->check_current_state(question_state::$complete); $this->check_output_contains_lang_string('notchanged', 'question'); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_mc_radio_expectation($wrongindex, true, true), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_correctness_expectation()); // Now get it right. $this->process_submission(array('answer' => $rightindex)); $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gradedright); $this->check_current_mark(3); $this->check_current_output( $this->get_contains_mc_radio_expectation($rightindex, false, true), $this->get_contains_correct_expectation()); } } PK\\r8behaviour/deferredfeedback/tests/behaviour_type_test.phpnu[. namespace qbehaviour_deferredfeedback; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the deferred feedback behaviour type class. * * @package qbehaviour_deferredfeedback * @category test * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class behaviour_type_test extends \qbehaviour_walkthrough_test_base { /** @var qbehaviour_deferredfeedback_type */ protected $behaviourtype; public function setUp(): void { parent::setUp(); $this->behaviourtype = question_engine::get_behaviour_type('deferredfeedback'); } public function test_is_archetypal(): void { $this->assertTrue($this->behaviourtype->is_archetypal()); } public function test_can_questions_finish_during_the_attempt(): void { $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt()); } public function test_get_unused_display_options(): void { $this->assertEquals(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'), $this->behaviourtype->get_unused_display_options()); } public function test_adjust_random_guess_score(): void { $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0)); $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1)); } } PK\%e#behaviour/manualgraded/renderer.phpnu[. /** * Defines the renderer for the manual graded behaviour. * * @package qbehaviour * @subpackage manualgraded * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Renderer for outputting parts of a question belonging to the manual * graded behaviour. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_manualgraded_renderer extends qbehaviour_renderer { } PK\!0rr%behaviour/manualgraded/db/install.phpnu[. /** * Post-install script for manual graded question behaviour. * @package qbehaviour_manualgraded * @copyright 2013 The Open Universtiy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Post-install script */ function xmldb_qbehaviour_manualgraded_install() { // Hide the manualgraded behaviour from the list of behaviours that users // can select in the user-interface. If a user accidentally chooses manual // graded behaviour for a quiz, there is no way to get the questions automatically // graded after the student has answered them. If teachers really want to do // this they can ask their admin to enable it on the manage behaviours // screen in the UI. $disabledbehaviours = get_config('question', 'disabledbehaviours'); if (!empty($disabledbehaviours)) { $disabledbehaviours = explode(',', $disabledbehaviours); } else { $disabledbehaviours = array(); } if (array_search('manualgraded', $disabledbehaviours) === false) { $disabledbehaviours[] = 'manualgraded'; set_config('disabledbehaviours', implode(',', $disabledbehaviours), 'question'); } } PK\J_NN%behaviour/manualgraded/db/upgrade.phpnu[. /** * Post-install script for the manual graded question behaviour. * * @package qbehaviour_manualgraded * @copyright 2013 The Open Universtiy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Manual graded question behaviour upgrade code. */ function xmldb_qbehaviour_manualgraded_upgrade($oldversion) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK\CC:behaviour/manualgraded/lang/en/qbehaviour_manualgraded.phpnu[. /** * Strings for component 'qbehaviour_manualgraded', language 'en'. * * @package qbehaviour * @subpackage manualgraded * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Manually graded'; $string['privacy:metadata'] = 'The Manually graded question behaviour plugin does not store any personal data.'; PK\Q]]"behaviour/manualgraded/version.phpnu[. /** * Version information for the calculated question type. * * @package qbehaviour * @subpackage manualgraded * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbehaviour_manualgraded'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK\H;$behaviour/manualgraded/behaviour.phpnu[. /** * Question behaviour for questions that can only be graded manually. * * @package qbehaviour * @subpackage manualgraded * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour for questions that can only be graded manually. * * The student enters their response during the attempt, and it is saved. Later, * when the whole attempt is finished, the attempt goes into the NEEDS_GRADING * state, and the teacher must grade it manually. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_manualgraded extends question_behaviour_with_save { public function is_compatible_question(question_definition $question) { return $question instanceof question_with_responses; } public function adjust_display_options(question_display_options $options) { parent::adjust_display_options($options); if ($this->qa->get_state()->is_finished()) { // Hide all feedback except genfeedback and manualcomment. $save = clone($options); $options->hide_all_feedback(); $options->generalfeedback = $save->generalfeedback; $options->manualcomment = $save->manualcomment; } } public function process_action(question_attempt_pending_step $pendingstep) { if ($pendingstep->has_behaviour_var('comment')) { return $this->process_comment($pendingstep); } else if ($pendingstep->has_behaviour_var('finish')) { return $this->process_finish($pendingstep); } else { return $this->process_save($pendingstep); } } /** * Like the parent method, except that when a response is gradable, but not * completely, we move it to the invalid state. * @param question_attempt_pending_step $pendingstep a partially initialised step * containing all the information about the action that is being performed. * @return bool either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD} */ public function process_save(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } else if (!$this->qa->get_state()->is_active()) { throw new coding_exception('Question is not active, cannot process_actions.'); } if ($this->is_same_response($pendingstep)) { return question_attempt::DISCARD; } if ($this->is_complete_response($pendingstep)) { $pendingstep->set_state(question_state::$complete); } else if ($this->question->is_gradable_response($pendingstep->get_qt_data())) { $pendingstep->set_state(question_state::$invalid); } else { $pendingstep->set_state(question_state::$todo); } return question_attempt::KEEP; } public function summarise_action(question_attempt_step $step) { if ($step->has_behaviour_var('comment')) { return $this->summarise_manual_comment($step); } else if ($step->has_behaviour_var('finish')) { return $this->summarise_finish($step); } else { return $this->summarise_save($step); } } public function process_finish(question_attempt_pending_step $pendingstep) { if ($this->qa->get_state()->is_finished()) { return question_attempt::DISCARD; } $response = $this->qa->get_last_step()->get_qt_data(); if (!$this->question->is_gradable_response($response)) { $pendingstep->set_state(question_state::$gaveup); } else { $pendingstep->set_state(question_state::$needsgrading); } $pendingstep->set_new_response_summary($this->question->summarise_response($response)); return question_attempt::KEEP; } } PK\p""3behaviour/manualgraded/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for qbehaviour_manualgraded. * * @package qbehaviour_manualgraded * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace qbehaviour_manualgraded\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for qbehaviour_manualgraded implementing null_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } PK\*(behaviour/manualgraded/behaviourtype.phpnu[. /** * Question behaviour type for manually graded behaviour. * * @package qbehaviour_manualgraded * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Question behaviour type information for manually graded behaviour. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qbehaviour_manualgraded_type extends question_behaviour_type { public function is_archetypal() { return true; } public function get_unused_display_options() { return array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'); } } PK\0}fxfx1behaviour/manualgraded/tests/walkthrough_test.phpnu[. namespace qbehaviour_manualgraded; use question_display_options; use question_state; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../engine/lib.php'); require_once(__DIR__ . '/../../../engine/tests/helpers.php'); /** * Unit tests for the manual graded behaviour. * * @package qbehaviour_manualgraded * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class walkthrough_test extends \qbehaviour_walkthrough_test_base { public function test_manual_graded_essay(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Process the same data again, check it does not create a new step. $numsteps = $this->get_step_count(); $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); $this->check_step_count($numsteps); // Process different data, check it creates a new step. $this->process_submission(array('answer' => '', 'answerformat' => FORMAT_HTML)); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$todo); // Change back, check it creates a new step. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); $this->check_step_count($numsteps + 2); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Process a manual comment. $this->manual_grade('Not good enough!', 10, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrright); $this->check_current_mark(10); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); // Now change the max mark for the question and regrade. $this->quba->regrade_question($this->slot, true, 1); // Verify. $this->check_current_state(question_state::$mangrright); $this->check_current_mark(1); } public function test_manual_graded_essay_not_answered(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$gaveup); $this->check_current_mark(null); $this->assertEquals('', $this->quba->get_response_summary($this->slot)); // Process a manual comment. $this->manual_grade('Not good enough!', 1, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(1); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Not good enough!') . '/')); // Now change the max mark for the question and regrade. $this->quba->regrade_question($this->slot, true, 1); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(0.1); } public function test_manual_graded_truefalse(): void { // Create a true-false question with correct answer true. $tf = \test_question_maker::make_question('truefalse', 'true'); $this->start_attempt_at_question($tf, 'manualgraded', 2); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_question_text_expectation($tf), $this->get_does_not_contain_feedback_expectation()); // Process a true answer and check the expected result. $this->process_submission(array('answer' => 1)); $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( $this->get_contains_tf_true_radio_expectation(true, true), $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_specific_feedback_expectation()); // Process a manual comment. $this->manual_grade('Not good enough!', 1, FORMAT_HTML); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(1); $this->check_current_output( $this->get_does_not_contain_correctness_expectation(), $this->get_does_not_contain_specific_feedback_expectation(), new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); } public function test_manual_grade_ungraded_question(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 0); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Process a manual comment. Note: null mark is the whole point here. $this->manual_grade('Not good enough!', null, FORMAT_HTML); // Verify. // I am pretty sure this next assertion is incorrect. We should change // the question state to indicate that this quetion has now been commented // on. However, that is tricky, because what if, after that, the mam mark // for the qusetions is changed. So, for now, this assertion verifies // the current behaviour. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Not good enough!', '/') . '/')); } public function test_manual_graded_ignore_repeat_sumbission(): void { // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Process a blank manual comment. Ensure it does not change the state. $numsteps = $this->get_step_count(); $this->manual_grade('', '', FORMAT_HTML); $this->check_step_count($numsteps); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); // Process a comment, but with the mark blank. Should be recorded, but // not change the mark. $this->manual_grade('I am not sure what grade to award.', '', FORMAT_HTML); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('I am not sure what grade to award.', '/') . '/')); // Now grade it. $this->manual_grade('Pretty good!', '9.00000', FORMAT_HTML); $this->check_step_count($numsteps + 2); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(9); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Pretty good!', '/') . '/')); // Process the same data again, and make sure it does not add a step. $this->manual_grade('Pretty good!', '9.00000', FORMAT_HTML); $this->check_step_count($numsteps + 2); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(9); // Now set the mark back to blank. $this->manual_grade('Actually, I am not sure any more.', '', FORMAT_HTML); $this->check_step_count($numsteps + 3); $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Actually, I am not sure any more.', '/') . '/')); $qa = $this->quba->get_question_attempt($this->slot); $this->assertEquals('Commented: Actually, I am not sure any more.', $qa->summarise_action($qa->get_last_step())); } public function test_manual_graded_ignore_repeat_sumbission_commas(): void { // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Now grade it with a mark with a comma. $numsteps = $this->get_step_count(); $this->manual_grade('Pretty good!', '9,00000', FORMAT_HTML); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(9); $qa = $this->get_question_attempt(); $this->assertEquals('Manually graded 9 with comment: Pretty good!', $qa->summarise_action($qa->get_last_step())); $this->check_current_output( new \question_pattern_expectation('/' . preg_quote('Pretty good!', '/') . '/')); // Process the same mark with a dot. Verify it does not add a new step. $this->manual_grade('Pretty good!', '9.00000', FORMAT_HTML); $this->check_step_count($numsteps + 1); $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(9); } public function test_manual_graded_essay_can_grade_0(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Process a blank comment and a grade of 0. $this->manual_grade('', 0, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrwrong); $this->check_current_mark(0); } public function test_manual_graded_change_comment_format(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Finish the attempt. $this->quba->finish_all_questions(); // Process an example comment and a grade of 0. $this->manual_grade('example', 0, FORMAT_HTML); // Verify the format is FORMAT_HTML. $this->check_comment('example', FORMAT_HTML); // Process the same grade and comment with different format. $this->manual_grade('example', 0, FORMAT_MARKDOWN); // Verify the format is FORMAT_MARKDOWN. $this->check_comment('example', FORMAT_MARKDOWN); } public function test_manual_graded_respects_display_options(): void { // This test is for MDL-43874. Manual comments were not respecting the // Display options for feedback. global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Process a comment and a grade. $this->manual_grade('This should only appear if the displya options allow it', 5, FORMAT_HTML); // Verify. $this->check_current_state(question_state::$mangrpartial); $this->check_current_mark(5); $this->displayoptions->manualcomment = question_display_options::HIDDEN; $this->check_output_does_not_contain('This should only appear if the displya options allow it'); $this->displayoptions->manualcomment = question_display_options::VISIBLE; $this->check_output_contains('This should only appear if the displya options allow it'); } public function test_manual_graded_invalid_value_throws_exception(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Try to process a an invalid grade. $this->expectException('coding_exception'); $this->manual_grade('Comment', 'frog', FORMAT_HTML); } public function test_manual_graded_out_of_range_throws_exception(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Check the initial state. $this->check_current_state(question_state::$todo); $this->check_current_mark(null); $this->check_current_output($this->get_contains_question_text_expectation($essay), $this->get_does_not_contain_feedback_expectation()); // Simulate some data submitted by the student. $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML)); // Verify. $this->check_current_state(question_state::$complete); $this->check_current_mark(null); $this->check_current_output( new \question_contains_tag_with_attribute('textarea', 'name', $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), $this->get_does_not_contain_feedback_expectation()); // Finish the attempt. $this->quba->finish_all_questions(); // Verify. $this->check_current_state(question_state::$needsgrading); $this->check_current_mark(null); $this->assertEquals('This is my wonderful essay!', $this->quba->get_response_summary($this->slot)); // Try to process a an invalid grade. $this->expectException('coding_exception'); $this->manual_grade('Comment', '10.1', FORMAT_HTML); } public function test_manual_graded_displays_proper_comment_format(): void { global $PAGE; // The current text editor depends on the users profile setting - so it needs a valid user. $this->setAdminUser(); // Required to init a text editor. $PAGE->set_url('/'); // Create an essay question. $essay = \test_question_maker::make_an_essay_question(); $this->start_attempt_at_question($essay, 'deferredfeedback', 10); // Check the right model is being used. $this->assertEquals('manualgraded', $this->quba->get_question_attempt( $this->slot)->get_behaviour_name()); // Simulate some data submitted by the student. $this->process_submission( array( 'answer' => "A submission!", 'answerformat' => FORMAT_PLAIN ) ); // Finish the attempt. $this->quba->finish_all_questions(); // Write a manual comment in markdown. $this->manual_grade("*one\n*two\n*three\n", 10, FORMAT_MARKDOWN); // Check that feedback contains the original markdown format. $preg = '/